Skip to content
BuildStackHQHosting
All docs

Docs

Migrating from a generic host

Moving an app off Railway, Render, Fly or Heroku, and the harder case of a bot with state on disk.

This page has two parts. The first moves an ordinary app (an API, a worker, a frontend and its database) from Railway, Render, Fly or Heroku onto Keel. The second is the original walk-through for a bot with an outbound WebSocket and state on disk, which exercises the harder cases: it starts at "Migrating a bot".

Part 1: an app from Railway, Render, Fly or Heroku

The mapping

On Railway, Render, Fly or Heroku On Keel
A service, web dyno or Fly app A service agent: mode: service and service: { kind: app } in keel.yaml (a regular backend; leave the kind out or set agent for an AI agent or bot, the price is 79 EUR per replica-month either way), one keel deploy per release
Procfile web: line, fly.toml [processes], Render start command process.command in keel.yaml, or the Dockerfile CMD
$PORT Still PORT; declare the same number under ingress.port
Nixpacks, Heroku buildpacks, Render native runtimes keel deploy builds from your Dockerfile today; with @keel/buildpacks a Node, Python or Go project needs no Dockerfile
Environment variables in the dashboard Plain values under env: in keel.yaml; anything secret with keel secrets set NAME, listed under secrets:
Heroku Postgres, Railway Postgres, Render Postgres, Fly Postgres keel db create postgres, connection string injected as the DATABASE_URL secret
Redis add-on keel db create valkey, VALKEY_URL
Custom domain with an ACM or Let's Encrypt certificate keel domains add api.example.com, certificate issued and renewed by Keel
Static site or Render static site keel sites deploy ./dist, included on Pro and Enterprise
Horizontal scaling slider, fly scale count keel services scale <slug> --replicas N, 79 EUR per replica-month
Open outbound internet Deny-all egress; every host you call is declared under egress.customHosts
Region picker with US defaults One region, eu-central-1, for compute, data, logs, traces and backups
railway logs, heroku logs --tail, fly logs keel logs <slug> --follow, plus a step trace of every outbound model and tool call

1. Write keel.yaml next to the Dockerfile

name: Orders API
slug: orders-api
mode: service
service:
  kind: app
build:
  dockerfile: Dockerfile
  context: .
  platform: linux/arm64
process:
  command: ["node", "dist/server.js"]
resources:
  cpu: 512
  memoryMb: 1024
ingress:
  port: 8080
egress:
  customHosts:
    - host: api.stripe.com
      mode: tunnel
    - host: hooks.slack.com
      mode: inspect
env:
  NODE_ENV: production
  LOG_LEVEL: info
secrets:
  - DATABASE_URL
  - STRIPE_SECRET_KEY
  • process.command is your Procfile web: line as an array. If the Dockerfile already has the right CMD, omit it.
  • platform: linux/arm64: the single-host profile runs on an ARM instance. Most base images are multi-arch; a native module without an arm64 build is the one thing that bites here, and docker build --platform linux/arm64 locally tells you before keel deploy does.
  • ingress.port is what the app listens on. Keep listening on PORT; Keel sets it to the declared value.
  • No Procfile worker: line: a worker is a second service agent with its own keel.yaml and no ingress.

2. Move the environment

Export what you have:

heroku config -s -a orders-api        # Heroku
railway variables --kv                # Railway
fly secrets list                      # Fly prints names only

Split the list in two. Values that are not secret go under env:. Everything else is set once:

keel secrets set STRIPE_SECRET_KEY
keel secrets set SESSION_SECRET

Each prompt reads the value hidden. Drop the platform-specific variables (RAILWAY_*, RENDER_*, FLY_*, DYNO, HEROKU_*); the Keel equivalents are KEEL_RUN_ID, KEEL_INGRESS_URL and KEEL_REGION. A declared secret must have a value or the container refuses to start, so set the secrets before the first deploy.

3. Move the database

keel db create postgres

This stores the new DATABASE_URL as a secret. Migrate the data with the tools you already use:

heroku pg:backups:capture -a orders-api && heroku pg:backups:download -a orders-api
railway connect postgres            # or pg_dump against the Railway URL
pg_restore --no-owner --no-acl -d "$DATABASE_URL" latest.dump

keel db create prints the connection string once and stores it as the secret; it is never shown again, so keep it in your shell for the restore. Run your schema migrations against the new database before the cutover, then do a final dump and restore inside the maintenance window. Keep the old database untouched until the rollback week is over. A Redis add-on moves the same way with keel db create valkey; caches usually do not need their data copied.

4. Declare every host you call out to

Egress is deny-all. Grep your code and dependencies for hostnames, or deploy once and read the egress_denied steps in keel runs steps <run-id>. Providers on the verified list (Bedrock in eu-central-1, eu-west-1 and eu-north-1, api.eu.mistral.ai, api.scaleway.ai) need no entry and are recorded as model steps with token counts; everything else is a custom host in inspect mode (recorded as a tool step with its payload) or tunnel mode (recorded as a connection only). WebSockets must be tunnels. Private addresses are refused.

Inspected hosts are terminated by the Keel gateway with a certificate from the Keel CA. The Dockerfile from keel init sets SSL_CERT_FILE, NODE_EXTRA_CA_CERTS, REQUESTS_CA_BUNDLE, AWS_CA_BUNDLE and CURL_CA_BUNDLE to /keel/ca/bundle.pem; copy those lines into your own Dockerfile. Node, Python requests, curl, Go and the AWS SDKs then need nothing. Java and Python certifi-only clients need the one-line fixes in Part 2, step 4.

5. Deploy next to the old host

keel deploy
keel service start orders-api
keel service status orders-api --follow

The deploy prints https://orders-api-<org>.svc.keelhosting.dev. Point a staging client at it, watch keel logs orders-api --follow for restarts and the trace for egress_denied and tls_handshake_failed steps, fix, keel deploy again. Every later deploy is a rolling restart.

6. Move the domain

keel domains add api.example.com

Add the CNAME the command prints. Keel issues the certificate once DNS resolves and keeps the old target working until then, so the switch is one DNS change with the old host still up. Lower the TTL a day before. When the certificate is issued and traffic arrives on Keel, stop the old service.

7. Rollback

Point the CNAME back at the old host and start the old service. Nothing on Keel needs undoing; the service, its secrets and its database stay for the next attempt. Keep the old host and database for a week after the cutover.

Checklist

  • keel.yaml with mode: service, service.kind: app, ingress.port, platform: linux/arm64, process.command from the Procfile
  • every secret set with keel secrets set, everything else under env:, platform variables dropped
  • keel db create postgres, schema migrated, data restored, DATABASE_URL declared under secrets:
  • every outbound host declared; WebSockets as tunnels; the Dockerfile trusts /keel/ca/bundle.pem
  • a parallel run on the Keel hostname before the CNAME moves
  • TTL lowered, CNAME moved, certificate issued, old host stopped a week later

Part 2: migrating a bot

You have a bot that runs from a Dockerfile on a VPS, Fly, Railway, Heroku or a laptop under a screen session. This part moves it onto Keel as a service agent without a big-bang cutover. The example is a Slack bot in Socket Mode, because it exercises everything that matters: an outbound WebSocket, a model provider, secrets, and state on disk. Telegram, Discord and Matrix bots follow the same steps with different hostnames.

What changes and what does not

Generic host Keel
Process docker run or a process manager one run per service, restarted by the reconciler, billed per hour up (Services and APIs)
Outbound network anything deny-all; every host is declared, model providers on the verified list are inspected, everything else is a custom host (the gateway notes)
Secrets .env on the box keel secrets set, injected at start, redacted from logs and traces
State on disk a bind mount or nothing a persistent workspace volume declared in keel.yaml
Database and cache a managed add on or a container on the same box keel db create main gives a Postgres database (or Valkey with --kind valkey) whose URL arrives as the DATABASE_URL (REDIS_URL) secret (Databases)
Logs docker logs keel logs <slug>, the dashboard, and the step trace of every model and tool call
Inbound HTTP a public port none by default; ingress gives a https://<slug>-<org>.svc.<domain> hostname when you need one

Your Dockerfile stays. Your code stays, with at most two edits: the data directory path and, if the runtime ships its own root store, trusting the Keel CA bundle.

1. Write keel.yaml

Put this next to the Dockerfile:

name: Support Bot
slug: support-bot
mode: service
build:
  dockerfile: Dockerfile
  context: .
  platform: linux/arm64
process:
  command: ["node", "bot.js"]
resources:
  cpu: 512
  memoryMb: 1024
workspace:
  mountPath: /data
  sizeGb: 1
egress:
  customHosts:
    - host: slack.com
      mode: tunnel
    - host: wss-primary.slack.com
      mode: tunnel
    - host: api.eu.mistral.ai
      mode: inspect
secrets:
  - SLACK_BOT_TOKEN
  - SLACK_APP_TOKEN
  - MISTRAL_API_KEY
  • mode: service makes Keel keep exactly one run alive. It needs a plan with the services feature; on the single host profile the operator sets the plan by hand (deploy/single-host/README.md). A bot is an agent, so service.kind stays at its default agent; only a plain backend without an AI in it is an app, and both cost 79 EUR per replica-month.
  • workspace.mountPath is the bot's data directory. Point it at whatever the bot writes today (/data, /app/state, /var/lib/bot); the same path is mounted on every restart and redeploy. If the bot has no state, drop workspace.
  • platform: linux/arm64 because the single host is an ARM instance. If your image only builds for amd64, build a multi-arch image or fix the base image first; an amd64 image will not start on the t4g instance.

2. Declare every host the bot talks to

Egress is deny-all. Find the hosts with a quick grep of your code and dependencies, or run the bot once on Keel and read the egress_denied steps in keel runs steps <run-id>.

For Slack in Socket Mode:

  • slack.com in tunnel mode. The Web API (apps.connections.open, chat.postMessage, conversations.history) lives there. Slack's API is HTTP/1.1 and would work in inspect mode too, but the tunnel keeps message bodies out of the trace; choose inspect if you want every Slack call recorded as a tool step with its payload.
  • wss-primary.slack.com in tunnel mode. This is the Socket Mode WebSocket. It must be a tunnel: the gateway does not proxy WebSocket upgrades through the inspecting path. Slack occasionally hands out wss-backup.slack.com; add it as a second tunnel host if your logs show connections to it.

Claude, GPT and the other American models. Calling api.anthropic.com or api.openai.com directly works, as a custom host, and the dashboard says where it says every custom host does: that traffic is outside the residency guarantee. For Claude there is a better route that costs nothing to take. The same models are served by Bedrock in Frankfurt, Ireland and Stockholm, which is already on the verified list. Going that way keeps the request in the EU, and Bedrock stores neither the input nor the output, not even for abuse monitoring, where Anthropic's own API holds both for about thirty days on servers in the United States. If the residency guarantee is why you are here, use Bedrock and point ANTHROPIC_BEDROCK or your SDK's Bedrock client at eu-central-1.

For the model provider, check the verified list first. It is published by the operator (deploy/single-host/publish-verified-list.ts lists the entries) and pinned by version on every revision; keel status <slug> prints the version in the egress summary. A host on the verified list (Bedrock bedrock-runtime.eu-central-1.amazonaws.com, api.eu.mistral.ai, api.scaleway.ai) needs no entry in keel.yaml: every call to it is inspected, recorded as a model step with token counts, and covered by the residency guarantee. A provider that is not on the list, for example an Azure OpenAI resource in Sweden Central (<resource>.openai.azure.com) or api.openai.com, goes in as a custom host in inspect mode:

egress:
  customHosts:
    - host: my-resource.openai.azure.com
      mode: inspect

Custom hosts are outside the residency guarantee by design. Keel records every connection to them in the trace, but it does not vouch for where the data is processed; that is your call, and the dashboard says so where you add them. An Azure OpenAI resource you created in Sweden Central or France Central keeps your data in the EU per Microsoft's data residency terms, but the hostname alone cannot prove it, which is why it is not on the verified list.

Anything else the bot calls (a CRM, your own backend, a vector database) is also a custom host. Private addresses are refused: the gateway will not tunnel to RFC 1918, link-local or cloud metadata ranges.

3. Secrets

Never copy the .env into the image or into env: in keel.yaml. Set each value once:

keel secrets set SLACK_BOT_TOKEN
keel secrets set SLACK_APP_TOKEN
keel secrets set MISTRAL_API_KEY

Each prompt reads the value hidden. The names in secrets: are injected as environment variables of the same name at start; the supervisor redacts their values from stdout, stderr and captured payloads.

4. Trust the Keel CA

Inspected hosts are terminated by the Keel gateway, which presents a certificate for the real hostname issued by the Keel CA. The runtime finds the bundle through SSL_CERT_FILE, NODE_EXTRA_CA_CERTS, REQUESTS_CA_BUNDLE, CURL_CA_BUNDLE, AWS_CA_BUNDLE, GIT_SSL_CAINFO, DENO_CERT and PIP_CERT, all pointing at /keel/ca/bundle.pem, and through the read-only mount over /etc/ssl/certs. Node, Python requests, curl, Go, Deno and the AWS SDKs need nothing. Clients that ship their own root store need one Dockerfile line; keel init shows the pattern:

  • Java: add the bundle to cacerts at build time, or set -Djavax.net.ssl.trustStore.
  • Python httpx and aiohttp read SSL_CERT_FILE; certifi on its own does not, set REQUESTS_CA_BUNDLE or pass verify=os.environ["SSL_CERT_FILE"].
  • Go binaries built with CGO_ENABLED=0 read SSL_CERT_FILE on Linux.

Tunnel hosts are unaffected: the bot sees Slack's own certificate.

5. Run in parallel before cutting over

The one rule: the old bot and the new bot must never share credentials or state.

  • Create a second Slack app (or at least a second app-level token and bot token) for the Keel deployment. Two Socket Mode clients on one app-level token both receive every event and both answer, and Slack disconnects the older one when the newer connects. Separate tokens give you a bot you can invite to one test channel while production stays on the old host.
  • Give the Keel deployment its own data backend: an empty workspace, a separate database or a separate table prefix. Copying the old state in later is a one-time job; sharing it during the parallel run produces double processing and lost offsets.
  • Keep the old host running untouched.

Then:

keel deploy
keel service start support-bot
keel service status support-bot --follow
keel logs support-bot --follow

Watch for egress_denied steps in the trace (a host you missed), tls_handshake_failed (a client that does not trust the bundle), and restarts (keel service status shows Restarts and Last exit). Fix, keel deploy again; a redeploy is a rolling restart and the workspace is kept.

6. Cut over one agent at a time

When the parallel bot has been up for a day in its test channel:

  1. Copy state if needed. For files, docker cp out of the old container and keel service stop, then write it into the workspace on the next start with a small init step in the bot, or seed it through the bot's own import path. For a database, point the Keel bot at the production backend by rotating its secrets: keel secrets set DATABASE_URL, then keel service restart support-bot.
  2. Swap the credentials: keel secrets set SLACK_BOT_TOKEN and SLACK_APP_TOKEN with the production app's tokens, keel service restart support-bot.
  3. Stop the old host's bot within the same minute. Two Socket Mode clients on one token fight each other, so the order is: restart on Keel, confirm connected in the bot's log, stop the old one.
  4. Leave the old container stopped but not deleted for a week.

Repeat per bot. Do not move five bots in one evening; the second one will be routine and the first one will teach you what the second one needs.

7. Rollback

Rolling back is starting the old container again and stopping the Keel service:

keel service stop support-bot
docker start old-support-bot

Nothing in Keel needs undoing: the agent, its secrets and its workspace stay for the next attempt. If the rollback is because of state divergence, the workspace can be inspected from the host with docker run --rm -v keel-ws-<agent id>:/w alpine ls /w and the agent id from keel agents list.

Checklist

  • keel.yaml with mode: service, workspace at the data directory, platform: linux/arm64
  • every host the bot dials is a verified list entry or a custom host with the right mode; WebSockets are tunnels
  • secrets set with keel secrets set, none in the image or in env:
  • clients trust /keel/ca/bundle.pem (or every inspected host is a tunnel)
  • a second set of tokens and an empty data backend for the parallel run
  • cutover order: restart on Keel, confirm connected, stop the old host