Migrating to HostSSH
Move an app off Coolify, Vercel, Heroku, or a raw VPS onto HostSSH with zero downtime — the per-app playbook, cutover, soak, and decommission.
Migrating to HostSSH
Moving an existing app onto HostSSH is a repeatable, rollback-safe loop. This is the same loop we use to migrate our own fleet — if it can't move our apps from the dashboard, it isn't done. The core idea: prepare the new box completely, prove it, then flip DNS — with the old host still standing by for instant rollback.
Golden rule: never delete the old thing until the new thing has served real traffic for a soak window. Every step below preserves a way back until it provably doesn't.
Before you start: inventory the app
Spend five minutes recording what the app actually is. This is where migrations go wrong.
- Stateful? Does it have uploads, a SQLite file, or named volumes? → you'll need volumes and volume-inclusive backups.
- Background work? Cron jobs, queue workers, scheduled tasks? → deploy those as separate worker Apps, and stop the old workers before cutover so you don't double-process.
- DNS record set. Not just the
Arecord — notewww,api.,ws.siblings, and any MX / SPF / DKIM / DMARC / TXT records (critical if the app sends email). - Environment. Freeze env edits on the old host from now on, and snapshot the full set. Watch for host-generated vars your app implicitly reads.
- Websockets / SSE / long connections? If you'll front it with Cloudflare, know that the orange-cloud proxy has a ~100s idle timeout; keep websocket subdomains grey-cloud or verify the timeout works for you.
The per-app loop
1. Add a Dockerfile (or use hostpack)
Commit a build recipe to the app's repo — a Dockerfile, or nothing at all if hostpack
detects your stack. No on-box artifacts; the build must be reproducible from source. Two proven
patterns:
- Static site:
nginx:alpineserving your built HTML. - Next.js standalone: the framework's standalone output; HostSSH injects
HOSTNAME=0.0.0.0so it binds correctly.
2. Move the database (if any)
Stand up (or point at) Postgres on a HostSSH Data node and load the app's data. Set the app's
DATABASE_URL to the private Mesh address (e.g. 10.10.0.2:5432) so the DB is never public.
Set a conservative connection-pool size — during a cutover the old and new instances overlap, so
plan for roughly double the connections briefly.
3. Deploy to a HostSSH Node (staging proof)
Deploy from source into a Slot, with env via sealed secrets, while DNS still points at the old host. Then prove parity against the live origin: same routes, same bytes on key pages, correct status codes. The image is now pre-built — cutover day is run-image-only, never a fresh build.
4. Pre-issue the certificate (DNS-01)
While DNS still points away, issue the real Let's Encrypt cert via DNS-01 and verify it's trusted:
$ curl --resolve app.example.com:443:<node-ip> https://app.example.com -I
HTTP/2 200
5. Back up first
Confirm a recent backup of the target Node including volume data, and (for DB apps) that the app's rows are really on the canonical Postgres. A "backup ✓" gate that skips volume data is fiction — HostSSH now captures volume data, but verify it. See Backups & recovery.
6. Stop the old workers
If the app has background workers, stop them on the old host now. Web traffic can overlap briefly during the flip, but two live workers on a shared database means duplicate webhooks, double-sent email, and double-claimed jobs.
7. Cut over (the explicit flip)
Change the DNS A record to the Node's IP (TTL 300), orange-cloud, SSL Full (strict). Because
the cert is pre-issued, there's zero HTTPS downtime. HostSSH auto-creates an uptime monitor
for the domain the moment it's routed.
8. Verify live
Define "good" before you flip, then check it: the monitor is green, error rate is at or below baseline for the watch window, and — for any mail-sending app — a real test email actually delivers.
9. Soak (72h), with rollback ready
Leave the old app stopped, not deleted for a soak window. Rollback = flip the A record back
(and, if you were behind Cloudflare orange-cloud with Full-strict, revert to grey-cloud so the old
host's HTTP-01 renewals aren't broken by the strict proxy).
10. Decommission
Only after the soak: delete the app's GitHub webhook and deploy key from the repo (or the old platform will keep ghost-deploying), then remove the app from the old host. Register HostSSH's own git hook if you want push-to-deploy going forward.
11. Record it
Note the app → node → domain(s) → database → env source → status in your fleet registry (the control plane's fleet/app view becomes this once you're fully on HostSSH).
Moving off Coolify specifically
HostSSH can import apps from Coolify. The importer reads your Coolify instance's API and pulls each app's source, port, domain, and env, then batch-creates HostSSH Deployments (with sealed secrets, pinned to a Node) behind a review screen so you approve before anything is created. This kills the single largest manual step — re-keying dozens of app specs by hand. Find it under Migrate → Import from Coolify in the dashboard.
After import, each app still follows the per-app loop above (Dockerfile/hostpack → staging proof → pre-issue cert → cutover → soak → decommission).
Live migration — move a running container as-is
Instead of rebuilding from source, you can move a running container directly off the source box. The agent reads the container's live config over SSH, physically moves its image and (opt-in) its named volumes, and redeploys it here — without touching DNS. From the CLI:
hostssh migrate <container> --source-ssh root@31.220.104.207 --app myapp --dry-run # preview
hostssh migrate <container> --source-ssh root@31.220.104.207 --app myapp --volumes # for real
--dry-run prints the exact plan (image, port, env count, volumes) and stops. In the dashboard this
is the "Migrate live" button on each Coolify import candidate.
Multi-container apps
If an app is several containers (web + worker + redis), migrate them together onto the shared network so they stay reachable by name — list them dependencies first:
hostssh migrate --source-ssh root@31.220.104.207 \
--containers redis,api,web --volumes --dry-run
The migration is ordered and fail-closed: on a real run, the first container that fails stops the migration and reports what already landed, so a half-migrated app never masquerades as done. Cutting DNS is still the separate, explicit step below.
Wave ordering (do the safe ones first)
If you're moving a whole fleet, migrate in risk-ascending waves:
- Static / marketing sites — near-zero risk, same nginx pattern.
- Stateless product apps — standard web apps with an external database.
- Apps with workers / websockets — stop old workers before flipping.
- Stateful apps (WordPress, anything with local files) — only after volume-inclusive backups are proven; do it in a maintenance window. See WordPress migration.
- Your most important revenue app — last. By then the loop is boring.
Moving off Vercel / Heroku / a raw VPS
Same loop. The differences:
- From Vercel: you're trading their edge for your own box. HostSSH runs real Next.js (streaming, RSC, ISR, middleware). Point your image/asset storage at your own R2 bucket via a Connection. Keep Cloudflare in front for the edge/CDN layer.
- From Heroku: your
Procfileweb process becomes a web App; each worker process becomes a worker App (--command). Config vars become sealed secrets. - From a raw VPS: the win is everything you were doing by hand — TLS, restarts, backups,
monitoring — becomes automatic. Deploy from your existing repo with
hostpackor your Dockerfile.
Next steps
- The mechanics of a single deploy → Deploying apps
- The certificate story in depth → Domains & TLS
- Prove your backups before you rely on them → Backups & recovery