Move from Fly.io to your own server
Follow this on your own server. Start free, no card needed
Fly bills by usage, which is hard to forecast, and “Fly Postgres” is still a Postgres you operate yourself, just on Fly’s machines instead of your own. If you already have a Dockerfile that builds and runs on Fly, you have almost everything you need to run it anywhere else too. This guide moves an existing Fly app, its environment, its secrets and its Postgres data onto a single server you own: Docker Compose, Caddy for HTTPS, a real backup job, then a DNS cutover with no downtime window. The shortcut at the end uses Railyard’s own Fly importer for the parts that don’t need a manual dump and restore.
What you’ll need
- A server. A DigitalOcean Basic Droplet with 2 vCPUs and 2 GB of RAM runs most small apps and a Postgres instance together at $18/month; go to 4 GB at $24/month if your app does any real background work. Any other provider’s equivalent Ubuntu box works the same way.
- A domain name you can repoint, ideally the one already attached to the Fly app.
- The
flyctlCLI, already authenticated against the account that owns the app (fly auth whoamishould print your account, not an error).
Step 1: Inventory what the Fly app actually needs
Pull the current app config so you know what you’re reproducing, rather than guessing:
fly config show -a your-fly-app
You should get back the same JSON Fly deploys from, including the [http_service] or [[services]] block with internal_port, the [env] block with your non-secret variables, and any [[mounts]] for volumes. Note the internal port specifically, it’s the one value every other service in this guide needs to agree on.
List the secrets by name, since Fly never shows you their values:
fly secrets list -a your-fly-app
You should see a table of secret names, digests and deployment status, not the values themselves. Pull the actual values from wherever you set them originally (a password manager, your CI secrets, a teammate), since this is the one thing Fly genuinely can’t hand back to you.
Step 2: Provision the server and install Docker
curl -fsSL https://get.docker.com | sh
docker compose version
You should see a Compose v2.x version string. Point your domain’s A record at the new server now, since the later HTTPS step depends on it:
dig +short app.example.com
Step 3: Write the Docker Compose file
Most Fly apps already build from a Dockerfile in the repo, Fly requires one (or a buildpack that produces one) to deploy at all, so the same image that runs on Fly’s machines runs here too. Add Postgres and Caddy around it:
services:
app:
build: .
restart: unless-stopped
env_file: .env
depends_on:
db:
condition: service_healthy
networks:
- app_net
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: app
POSTGRES_USER: app
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 5s
start_period: 10s
retries: 20
networks:
- app_net
caddy:
image: caddy:2
restart: unless-stopped
depends_on:
- app
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
networks:
- app_net
networks:
app_net:
volumes:
postgres_data:
caddy_data:
caddy_config:
Build the .env from the [env] block fly config show printed earlier, plus the secret values you retrieved:
POSTGRES_PASSWORD=a-long-random-string
DATABASE_URL=postgres://app:a-long-random-string@db:5432/app
PORT=8080
# everything from fly config show's [env] block goes here too
# everything fly secrets list named goes here, with its real value
PORT should match the internal_port from Step 1, since that’s the port your app’s own code is listening on regardless of which platform runs it.
Step 4: Move the Postgres data
If the app uses Fly’s unmanaged Postgres (a Postgres app you deployed and attached yourself, the common case for apps running a while), open a proxy to it:
fly proxy 15432:5432 -a your-postgres-app
In a second terminal, dump the database through that proxy:
pg_dump -Fc -h localhost -p 15432 -U postgres your_database -f fly-dump.pgdump
You should end up with a non-empty fly-dump.pgdump file; ls -lh fly-dump.pgdump should show a size roughly in line with how much data you expect, not a handful of bytes.
Copy it to the new server and restore it into the db container once the stack is up:
scp fly-dump.pgdump root@YOUR_SERVER_IP:/root/
docker compose cp /root/fly-dump.pgdump db:/tmp/fly-dump.pgdump
docker compose exec db pg_restore -U app -d app --no-owner --no-acl /tmp/fly-dump.pgdump
--no-owner --no-acl drop role and permission information that only made sense on Fly’s Postgres, so the restore doesn’t fail looking for a role that doesn’t exist here. You should see pg_restore finish with, at most, a few warnings about objects it skipped for the same reason, not a hard error.
If instead you’re on Fly’s newer Managed Postgres, the proxy command is fly mpg proxy YOUR_CLUSTER_ID --local-port 15432 and the connection details (user, database name) come from fly mpg status rather than being postgres/your app’s name by default; the pg_dump/pg_restore commands themselves are identical once you’re through the proxy.
Step 5: Bring the stack up and verify
docker compose up -d --build
docker compose logs -f app
You should see your app’s own normal startup log lines, the same ones you’d see in fly logs, ending in it listening on the port from .env. Confirm the data came across:
docker compose exec db psql -U app -d app -c "SELECT count(*) FROM your_main_table;"
That count should match what you saw on Fly before the dump, give or take rows written in between.
Step 6: Write the Caddyfile and cut over DNS
app.example.com {
reverse_proxy app:8080
}
Bring Caddy up and confirm the certificate issues (docker compose logs caddy) before you touch DNS. Once https://app.example.com answers correctly against the new server on a temporary subdomain or by editing your local /etc/hosts to test, update the real A record to point at the new server’s IP. Fly keeps serving the app’s own subdomain the whole time, since you haven’t touched anything there, so there’s no gap where neither server answers. Once you’ve confirmed traffic is flowing to the new server, scale the Fly app to zero machines and, once you’re confident you won’t need it, delete it.
Step 7: Backups and upgrades
Back up Postgres the same way you dumped it off Fly, on a schedule this time:
#!/usr/bin/env bash
set -euo pipefail
STAMP=$(date +%Y%m%d-%H%M%S)
mkdir -p backups
docker compose exec -T db pg_dump -U app -Fc app > "backups/app-db-$STAMP.pgdump"
find backups -name '*.pgdump' -mtime +14 -delete
Copy backups/ off the server on the same cron job, to another machine or object storage. To upgrade the app itself, rebuild and recreate just that container:
docker compose build app
docker compose up -d app
The shortcut: do it with Railyard
Railyard’s Fly importer handles the parts of this that don’t involve your own data: from Applications → New app → Import from Heroku / Render / Fly, choose Fly, paste a token from fly tokens create org, and pick the app. It pulls the exact image Fly is running (using your token as the registry password, so nothing rebuilds from source), the environment variables and internal port from the machine config, and the names of your secrets, you re-enter their values since Fly never exposes them to anything, including this importer. Other process groups beyond the main one aren’t copied yet, add them as workers afterward, and any volumes start empty, so Step 4 above (or its managed-Postgres variant) is still how the actual data moves over; the importer itself doesn’t touch Postgres at all.
https://app.railyard.run/deploy?repo=https://github.com/you/your-app
works too, for the plain Git-push path once the importer’s handled the Fly-specific pieces or if you’d rather skip it entirely. See Import from another platform for the full picture of what each source platform brings over, Migrating from Fly.io for the field-by-field mapping between Fly’s concepts and Railyard’s, and Hosting costs if you’re also trying to work out what the new server should actually cost you versus what Fly was billing.
Troubleshooting
pg_restore fails on a role or extension it can’t find. This is almost always a Fly-specific extension or a role the dump references that doesn’t exist on the destination. --no-owner --no-acl handles most of it; for a genuinely missing extension, install it in the destination Postgres image or strip the CREATE EXTENSION line from the dump before restoring.
The app boots but immediately 502s behind Caddy. Check that PORT in .env matches both what your app actually binds to and what the Caddyfile’s reverse_proxy line points at. A mismatch here is the single most common cause, since Fly and a generic Compose setup don’t always agree on how the port gets injected.
Certificate never issues. dig +short app.example.com has to resolve to the new server first. If you’re testing before the real cutover, use a staging subdomain that already points here rather than waiting on the production record.
Secrets missing after import, or the app crashes on a config value. Fly’s own API never returns secret values, only names, so this is expected: re-enter each one from wherever you originally stored it, not from the import.
Out of memory during pg_restore on a small server. A 2 GB droplet can struggle restoring a large dump alongside a running app. Stop the app container during the restore (docker compose stop app) to free the memory, then start it again once pg_restore finishes.
FAQ
Do I need to migrate everything at once? No. Bring the database and the main process over first, verify it, then add any secondary process groups as separate worker services once you’ve confirmed the primary app works.
What happens to the subdomain Fly auto-assigns each app? Nothing, until you delete the Fly app. It keeps answering right up until you scale the app to zero, which is what makes the zero-downtime cutover possible: both the old and new addresses work simultaneously while DNS propagates.
Does this work for apps using Fly’s multi-region deployment? This guide moves you to one server in one region. If you genuinely need multiple regions, run this setup once per region behind your own load balancing, there’s no single-command equivalent to Fly’s automatic region fanout here.
Is Fly’s Managed Postgres different enough to need its own steps? Only the proxy command and the default username, fly mpg proxy instead of fly proxy, and credentials from fly mpg status instead of the app name. pg_dump and pg_restore work exactly the same once you’re connected.
Either path gets your app and its data off Fly’s billing model and onto a server you can see the actual spec and the actual bill for, the manual steps if you want full control over the cutover, the importer if you’d rather let it pull the easy 80 percent for you.
Why people pick Railyard for this
- Your own cloud account. Deploy to DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, or any Linux server over SSH, billed directly by your provider with no markup.
- One flat price per team. Free is free forever: one server plus a database server, three apps, no card. Pro is $19 a month for up to five servers and unlimited apps. Team is $49 a month with unlimited servers. No per-app, per-deploy, per-seat or per-gigabyte charges.
- One-click templates. Deploy open-source apps, like databases, analytics and dev tools, to your own server in one click.
- Managed databases. Postgres, MySQL and Redis with scheduled, restore-verified backups and point-in-time recovery.
- Push-to-deploy. Deploy from GitHub, GitLab or the CLI, with automatic HTTPS and zero-downtime releases.