Pular para o conteúdo

Buscar na documentação

Buscar na documentação do Skiffly

Migrar um app do Heroku

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.com

Everything 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 SUCCESS

Expected: 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 starts

The 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.cloud

Expected: 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 habitOn Skiffly
SSL forced by X-Forwarded-Protosame 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 URLnot needed (private network); some drivers fail with it — remove it
Ephemeral diskthe same; uploads go to S3 or a volume
heroku runskiffly 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.

SchedulerSkiffly service
node dist/jobs/digest.js, daily 03:00service digest, cron 0 3 * * *, start command node dist/jobs/digest.js
node dist/jobs/cleanup.js, every 10 minservice 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 proxy

pg_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 minute

Lower 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).

skiffly config init                     # writes .skiffly/skiffly.ts with everything created above
skiffly config plan                     # no changes
.skiffly/skiffly.ts
import { 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-api

What is different day to day#

  • Deploys: every push to main builds (or skiffly up from 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_dump cron as well.