Move an app off Heroku, step by step
A worked migration of a typical Heroku app (web + worker dynos, Heroku Postgres, Heroku Redis, Scheduler, config vars, a custom domain) to Skiffly with zero data loss and a controlled DNS cutover.
The Heroku migration guide maps the concepts. This tutorial walks one concrete app through the move: a Node/Express API called acme-api with a web and a worker dyno, Heroku Postgres, Heroku Redis, two Scheduler jobs, 30 config vars and api.example.com. Adjust the commands to your stack — the language pages have the Rails, Django and Laravel equivalents.
You need: the Heroku CLI still logged in, the code on GitHub, skiffly (npm i -g skiffly && skiffly login), pg_restore and redis-cli locally. Plan for 1–2 hours including a maintenance window of a few minutes for the database copy.
0. Inventory#
heroku ps -a acme-api # dynos: web.1, worker.1
cat Procfile # web: node dist/server.js / worker: node dist/worker.js
heroku addons -a acme-api # heroku-postgresql, heroku-redis, scheduler
heroku config -s -a acme-api > heroku.env # KEY=value, one per line
heroku domains -a acme-api # api.example.comEverything in Procfile becomes a service; every add-on becomes a template; release: (if you have one) becomes part of the start command.
1. Project and databases on Skiffly#
cd acme-api
skiffly init --name acme-api
skiffly deploy --template postgres # "Postgres", 10 GB volume — size it with the dashboard if your DB is bigger
skiffly deploy --template redis
skiffly status # both SUCCESSExpected: Postgres and Redis running on the private network with DATABASE_URL and REDIS_URL variables of their own.
2. The web service#
skiffly up -y -d # creates service "acme-api", first build startsThe build succeeds but the app crashes without its variables — expected for now. Set them:
# drop the add-on URLs Heroku managed, keep the rest
grep -v -E '^(DATABASE_URL|REDIS_URL|REDIS_TLS_URL|HEROKU_|PORT=)' heroku.env > vars.env
skiffly variables set -s acme-api --skip-deploys $(cat vars.env | xargs)
skiffly variables set -s acme-api --skip-deploys 'DATABASE_URL=${{Postgres.DATABASE_URL}}' 'REDIS_URL=${{Redis.REDIS_URL}}'Quoting: xargs breaks on values with spaces or #; set those individually (skiffly variables set KEY="a value") or use the dashboard's raw editor, which accepts the .env text as is.
Now the start command. Heroku ran web: node dist/server.js; Railpack would pick npm start, which is usually the same thing. If you had a release phase (release: npm run migrate), fold it in:
- Settings → Deploy → Start command:
sh -c "npm run migrate && node dist/server.js" - Healthcheck path:
/healthz(add the route if the app has none; Heroku did not need it, Skiffly's zero-downtime rollouts do).
skiffly redeploy -s acme-api
skiffly logs -s acme-api -f
skiffly domain -s acme-api # https://acme-api-x1y2.skiffly.cloudExpected: SUCCESS, and the generated URL answers. PORT is set by Skiffly exactly as Heroku did; heroku local-style .env files are not read — everything is variables.
Differences to check now, before the data move:
| Heroku habit | On Skiffly |
|---|---|
SSL forced by X-Forwarded-Proto | same header; keep your trust proxy / force_ssl setting |
REDIS_TLS_URL / rediss:// | the Redis template speaks plain redis:// on the private network; drop TLS options |
?sslmode=require on the Postgres URL | not needed (private network); some drivers fail with it — remove it |
| Ephemeral disk | the same; uploads go to S3 or a volume |
heroku run | skiffly ssh -- <cmd> |
3. The worker#
Create a second service from the same repository — dashboard New service → GitHub repo (same repo, name worker), or with config as code (step 7). Settings:
- Start command:
sh -c 'node -e "require(\"http\").createServer((_,r)=>r.end(\"ok\")).listen(process.env.PORT)" & exec node dist/worker.js'— Skiffly probes every long-running service on its port; the one-liner keeps the rollout healthy for a worker that has no HTTP server of its own (see Workers). - No domain.
- Variables: the same list. Use references so they are not duplicated:
DATABASE_URL=${{Postgres.DATABASE_URL}},REDIS_URL=${{Redis.REDIS_URL}}, and for app secrets either${{acme-api.SESSION_SECRET}}or move them to shared variables of the environment (skiffly variables set --shared).
Expected: skiffly logs -s worker shows the worker connecting to Redis.
4. Scheduler jobs#
Each Heroku Scheduler entry becomes a service with a cron schedule (UTC) and the job's command as the start command; it runs one container per tick and exits. Cron services are not probed and get no domain.
| Scheduler | Skiffly service |
|---|---|
node dist/jobs/digest.js, daily 03:00 | service digest, cron 0 3 * * *, start command node dist/jobs/digest.js |
node dist/jobs/cleanup.js, every 10 min | service cleanup, cron */10 * * * * |
Create them like the worker (same repository), or in the config file below.
5. Copy the data#
Do this in a short maintenance window: put Heroku in maintenance mode, copy, verify, then switch DNS.
heroku maintenance:on -a acme-api
heroku pg:backups:capture -a acme-api
heroku pg:backups:download -a acme-api # latest.dump
skiffly connect postgres --print # opens a TCP proxy, prints postgresql://postgres:…@edge.skiffly.cloud:2xxxx/app
pg_restore --no-owner --no-acl --clean --if-exists -d "postgresql://postgres:…@edge.skiffly.cloud:2xxxx/app" latest.dump
psql "postgresql://postgres:…@edge.skiffly.cloud:2xxxx/app" -c "select count(*) from users"
skiffly proxy delete <port> # close the public proxypg_restore warnings about heroku_ext schema or extensions owned by Heroku roles are normal; errors about missing extensions mean you need the postgis/pgvector template instead of plain postgres (deploy that one and repeat).
Redis on Heroku is usually cache and queues: let it start empty. If you must move keys, redis-cli --rdb on the Heroku side and redis-cli -u "$(skiffly connect redis --print)" --pipe after DEBUG RELOAD is the manual route.
Restart the app so pools reconnect: skiffly restart -s acme-api.
6. Domain cutover#
skiffly domain -s acme-api api.example.com
# add the two records at your DNS provider:
# api.example.com. CNAME edge.skiffly.cloud.
# _skiffly.api.example.com. TXT "<token>"
skiffly domain status api.example.com # wait for "verified", certificate issued within a minuteLower the TTL of api.example.com a day before if you can. Until the CNAME propagates, Heroku keeps answering (it is in maintenance mode, so users see the maintenance page for a few minutes at most). Once curl -sI https://api.example.com returns headers from Skiffly, you are live.
Expected: SKIFFLY_PUBLIC_DOMAIN on the service now shows the custom domain; update any variable that held the Heroku hostname (APP_URL, OAuth callbacks, CORS origins).
7. Config as code (recommended before you delete anything)#
skiffly config init # writes .skiffly/skiffly.ts with everything created above
skiffly config plan # no changesimport { defineSkiffly, github, postgres, preserve, project, redis, service, shared } from "@skiffly/config";
export default defineSkiffly(() => {
const db = postgres("Postgres");
const cache = redis("Redis");
const env = { NODE_ENV: "production", DATABASE_URL: db.env.DATABASE_URL, REDIS_URL: cache.env.REDIS_URL, SESSION_SECRET: shared("SESSION_SECRET") };
const src = github("acme/acme-api");
const web = service("acme-api", { source: src, start: 'sh -c "npm run migrate && node dist/server.js"', healthcheck: "/healthz", env, domains: ["api.example.com"], domain: true });
const worker = service("worker", { source: src, start: "sh -c 'node -e \"require(\\\"http\\\").createServer((_,r)=>r.end(\\\"ok\\\")).listen(process.env.PORT)\" & exec node dist/worker.js'", env });
const digest = service("digest", { source: src, start: "node dist/jobs/digest.js", cron: "0 3 * * *", env });
return project("acme-api", { resources: [db, cache, web, worker, digest] });
});Commit it. skiffly config apply --yes in CI (with a SKIFFLY_TOKEN secret) now replaces the Heroku pipeline.
8. Decommission Heroku#
After a few days of traffic on Skiffly with no surprises in skiffly logs and Project → Observability:
heroku pg:backups:capture -a acme-api # one last backup, download and keep it
heroku apps:destroy -a acme-apiWhat is different day to day#
- Deploys: every push to
mainbuilds (orskiffly upfrom your machine). PR previews replace review apps. - Logs: last 5 000 lines per deployment in
skiffly logs; forward to your log service if you kept Papertrail-style history. - Scaling: replicas (
numReplicas) and CPU/memory limits per service instead of dyno types; a service with a volume stays at one replica. - Billing: a prepaid balance instead of a monthly invoice — top up before the balance hits zero (3-day grace, then services pause).
- Backups: daily volume snapshots plus a logical dump for Postgres, restored by support; keep your own
pg_dumpcron as well.