Migrate from Fly.io
Last updated: June 23, 2026
Fly.io is good at one thing that makes this migration unusually clean: it runs your app as containers. Your fly.toml and your Dockerfile already describe almost the entire deployment — the image, the ports it serves, the volumes it mounts, the processes it runs. That's not Fly-specific magic you have to reverse-engineer. It's a container spec, and a container spec runs anywhere Docker does.
So the reframe here is simpler than most migrations: your Fly app is already a container — you're just choosing where it lands. Your AI assistant reads your fly.toml and Dockerfile, sizes a server from your Machine specs, and brings the same container up on a VM you own with Docker. For a genuinely multi-service app, the same containers can land on managed Kubernetes instead.
This recipe hands that translation to your assistant. With the American Cloud MCP server connected, it reads your Fly config, provisions a VM, runs your container on it, migrates your volume contents and your Postgres data, points your domain at the new server, and turns on HTTPS — all from prompts you paste in. The single-region VM that results is usually simpler and cheaper than a fleet of metered Machines.
Why Claude Code for this
Any MCP client can create the infrastructure. But migrating a Fly app also means running commands on the server — SSH in, install Docker, run your container, restore a database dump. Claude Code combines the American Cloud tools with your terminal, so one session can provision the VM, read your local repo and fly.toml, and deploy to the server without switching tools. That's the setup this recipe assumes. Cursor and the other clients work too — you'll just run the SSH and flyctl steps yourself when the assistant tells you to.
Provisioning and migrating are write operations. You'll need a read-write
API key from
console.americancloud.com/api-keys
and the --allow-writes flag on the MCP server. See the
overview for setup and safety details. Start read-only
while you take inventory and plan, then switch the key when you're ready to
build.
How a Fly app maps to a VM
Because Fly already runs containers, most of the mapping is one-to-one. Your assistant works from this:
| On Fly.io | On your American Cloud VM |
|---|---|
| Fly Machine (your running container) | The same container, on a VM with Docker — see the Docker Compose recipe |
fly.toml [[services]] / port config | nginx as a reverse proxy plus Docker port mappings |
| Fly volume | A block storage volume mounted on the VM (or the VM's own disk for smaller data) |
| Fly Postgres | PostgreSQL installed on the VM, migrated with pg_dump / pg_restore |
fly secrets | A server-side environment file the container reads (variable names only in chat) |
| Fly certificates + custom domains | DNS records via the hosted DNS tools plus a Let's Encrypt certificate from certbot |
| Multi-region / anycast IPs | One region you choose, near your users — scaled with a load balancer when you need more than one VM |
fly deploy | A redeploy prompt or a deploy script you run from the repo |
For most apps this is a near-mechanical translation: the same image, the same env vars, the same exposed port — now on a machine you size once and own outright, instead of a per-Machine bill.
The one shape that changes: edge and anycast
Fly's signature feature is its edge model — your app runs in multiple regions behind an anycast IP, and requests land at the nearest Machine. On a VM, that becomes a conventional single-region deployment: you pick one region close to your users, and traffic goes there.
For the large majority of apps — serving one country, one continent, or a team — that's a feature, not a loss. One region you chose means predictable latency, predictable behavior, and no per-Machine bill multiplying across edges. You can still scale horizontally when you need to: put a load balancer in front of several VMs in the same region.
If global low-latency is genuinely load-bearing for your app — you measured it, users on other continents feel it — then plan that deliberately with your assistant: pick the regions that matter and stand up a VM in each. Just make it a decision, not a default you inherited from the platform.
When to use Kubernetes instead
If your Fly app is really one container with a database behind it, a single VM is the right home — keep reading. But if fly.toml defines several processes that scale independently, or you're already running multiple Machine groups that talk to each other, that's a multi-service app, and managed Kubernetes is the better landing zone. Your assistant can build the same container images into a Kubernetes deployment instead of a VM. The inventory phase below tells you which case you're in.
Before you start
- Your app in a local git repo, with its
fly.tomlandDockerfile. flyctlinstalled and logged in, so the assistant (or you) can read volume and secret metadata and run the final database dump. The names of your Fly secrets are enough — you don't need to expose their values.- A domain you control, with the ability to point its DNS or nameservers at American Cloud.
- The MCP server connected to your assistant with a read-write key and
--allow-writes.
Phase 1 — inventory your app (read-only)
Before anything is created or billed, have your assistant build a full picture of what it's migrating. This phase is read-only on both sides — it reads your repo and lists American Cloud options without touching your Fly app or your account.
Open your project in Claude Code and paste:
This is a Fly.io app I want to migrate to a server I own. Take inventory
first — don't create anything yet:
1. Read fly.toml and the Dockerfile. Tell me: the image being built, the
internal port the app listens on, every [[services]] / port mapping, the
[processes] section if there is one, and any [mounts] (Fly volumes) with
their mount paths.
2. Tell me whether this is a single-service app or a multi-service one — does
it run more than one process group that scales independently?
3. If flyctl is available, list my Fly volumes (and sizes) and the NAMES of my
Fly secrets. Do NOT print secret values — only the names. Tell me whether a
Postgres app is attached.
4. Note which region(s) this app currently runs in and whether it uses more
than one.
Then summarize it as a migration target: single VM or Kubernetes, what to run
the container on, what data to move, and which secret names I'll need to set on
the server.What your assistant will do
This phase is your assistant reading files — your local repo, your Fly metadata via flyctl, and your American Cloud options through the MCP server:
- Read
fly.tomland theDockerfile. Together they define the deployment: the image, the internal port (internal_portunder[[services]]or the modern[http_service]), the public port mapping, the process groups, and the volume mounts. This is the spec the assistant recreates. - Decide single VM vs. Kubernetes. One process group serving web traffic with a database behind it is a single-VM app. Several independently scaled process groups is a multi-service app that belongs on Kubernetes.
- List volumes and secret names.
flyctl volumes listshows what persistent data exists and how big it is — that sizes the block storage you'll attach.flyctl secrets listshows the names of your secrets (Fly never reveals the values), giving the assistant the keys for the server-side env file without ever seeing the secrets themselves. - Check for attached Postgres. Fly Postgres runs as its own app; if one is attached, it's a database to dump and restore, and the env file gains a
DATABASE_URL. - Note the region footprint. One region is a straight move. Multiple regions is the cue to have the deliberate single-region-vs-multi-VM conversation from above before building.
Never paste secret values into the chat. Your assistant only needs the names of your Fly secrets to build the env file's structure. Put the real values — database passwords, API keys, signing secrets — directly into the server's env file over SSH, where they stay on the box you own. Not in the conversation, not in the repo.
Phase 2 — plan and price (read-only)
With the inventory in hand, ask for a sized plan and a cost estimate. This is still read-only — cost estimates create nothing.
Based on that inventory, plan the American Cloud side. List the available
regions and pick one near where my users are. List the current Ubuntu images
and the VM packages. Recommend ONE VM size that comfortably runs my container
plus PostgreSQL, with headroom for my volume and database sizes. Show me the
monthly cost estimate before I approve anything, so I can compare it to my
Fly bill.What your assistant will do:
- Call
list_regionsand pick one close to your users (replacing Fly's multi-region edge with a single deliberate region), thenlist_images(filtered to Ubuntu) for a current LTS image andlist_vm_packagesfor a compute tier. It sizes from your Fly Machine specs — Machines are deliberately small and metered, so add headroom for running the container and the database on one box. - Call
get_cost_estimate_vmwith that exact region, package, size, and image, and show you the hourly and monthly numbers before creating anything. If you're attaching a block storage volume for your data, it can also callget_cost_estimate_block_storage. Nothing is billed yet. - Hand you a side-by-side: one owned VM versus your current Fly Machines, volumes, and Postgres app. You bring your Fly bill; the assistant brings the American Cloud estimate.
Because one VM runs the container and the database together, you size a single server you can grow later with scale_vm, instead of paying per Machine across regions.
Phase 3 — provision and deploy
Now the writes begin. This follows the same VM-with-Docker flow as the Docker Compose recipe — paste this after you've approved the plan and cost from Phase 2, and read each step's output before approving the next.
Go ahead and build the new home for this app. Walk through it step by step
and wait for my confirmation on anything that costs money:
1. Check whether I have an SSH key registered; if not, create one and tell me
where the private key is saved.
2. Create the Ubuntu VM we sized, on an isolated network, with that SSH key,
and open inbound ports 22, 80, and 443.
3. If my app has a Fly volume, create a block storage volume of the right size,
attach it to the VM, and mount it at the same path the container expects.
4. Over SSH, install Docker Engine and the compose plugin. Build my Dockerfile
(or pull my image) on the VM.
5. Create the app's environment file from the Fly secret NAMES we found, with
placeholder values — I'll fill in the real secrets myself afterward. If
Postgres is attached, install PostgreSQL on the VM, keep it on localhost,
and point DATABASE_URL at it.
6. Run the container with the same port mapping and volume mount fly.toml
declares. Put nginx in front of it as a reverse proxy on port 80.
Don't start the app against real data yet — we'll migrate the volume and the
database next.What your assistant will do:
- Sort out the SSH key. It calls
list_ssh_keys; if nothing fits,create_ssh_keygenerates a pair — the private key is returned once and never stored, so the assistant saves it locally and sets the right permissions. It needs this key to install on the VM and to SSH in afterward. - Create the server. On your go-ahead,
create_vmprovisions the Ubuntu VM. The same call carriesnetworkAccess.inboundPortsto open 22, 80, and 443, andkeypairsto install your key. Opening ports throughcreate_vmsets up the firewall rule and the port forwarding together, so the ports are actually reachable — a firewall rule on its own would not be. The VM provisions asynchronously, so the assistant pollsget_vmuntil its status reachesSTARTEDwith a public IP. - Attach storage for your volume. If your
fly.tomldeclares a[mounts]volume, the assistant callscreate_block_storage_volumesized to match,attach_block_storage_volumeto connect it to the VM, then formats and mounts it at the path your container expects. Smaller data can simply live on the VM's own disk instead. - Install Docker and build the image. Over SSH it installs Docker Engine and the compose plugin, then builds your
Dockerfileon the VM (or pulls the image you already publish). This is the same container Fly was running. - Lay down the environment file and database. It writes one env file keyed by every Fly secret name from Phase 1, with placeholders you fill in over SSH. If Postgres is attached, it installs PostgreSQL bound to
localhostso it's never exposed to the internet, and setsDATABASE_URLto point at it. - Run the container. It starts your container with the same published port and volume mount your
fly.tomldeclares, and puts nginx in front as a reverse proxy on port 80.
Tell the assistant to explain each step before it runs it if you want to follow along — "narrate what you're about to do and why." Destructive operations are flagged regardless, and clients that support confirmations prompt you before anything irreversible.
Phase 4 — migrate the data
This is the only phase with real downtime, so it's deliberately last and deliberately careful. There are two kinds of data to move: the contents of your Fly volume, and your Postgres database. The goal is a clean cutover — freeze writes on Fly, copy everything, restore on the VM, and verify nothing was lost.
Now migrate the data with minimal downtime:
1. If there's a Fly volume, rsync its contents from the Fly Machine to the
mounted block storage volume on the new VM.
2. For the database, stop the Fly app from serving so nothing writes during the
copy: scale the Machines to zero (or suspend the app).
3. Take a final pg_dump of the Fly Postgres database in custom format.
4. Copy the dump to the VM and pg_restore it into the local PostgreSQL.
5. Run any database migration command my app needs against the restored data.
6. Verify the migration: compare row counts on the main tables between the Fly
database and the restored one, and flag any mismatch.
7. Start the container and confirm the app responds locally on the VM (curl the
health endpoint through nginx).
Report the row-count comparison and the local health check before we touch DNS.What your assistant will do:
- Copy the volume contents. It
rsyncs the files from your Fly volume to the block storage volume mounted on the VM, so uploaded content and any on-disk state come across intact. - Stop the Fly app from serving. Scaling the Machines to zero (or suspending the app) freezes writes so the database dump is a consistent point-in-time snapshot. Fly Postgres is unmanaged — it's a database you run, not a managed service — so a
pg_dump/pg_restoreis exactly the right tool. - Take the final dump. A
pg_dumpin custom format captures schema and data. - Restore on the VM. It copies the dump over and runs
pg_restoreinto the local PostgreSQL from Phase 3. - Run migrations. Your app's migration command runs against the restored database so the schema matches the code being deployed.
- Verify counts. It compares row counts on your key tables between source and target and surfaces any difference — your proof the data arrived intact before you commit to the cutover.
- Smoke-test locally. It starts the container and curls the app through nginx on the VM itself, confirming the stack works end to end before any public traffic hits it.
Once you take that final dump, the Fly app stays scaled to zero (or suspended) until you decommission it. Bringing it back up during DNS propagation means it serves the old database again — and any writes that land there are lost when you tear it down. Never serve from the old database during the cutover.
Phase 5 — DNS cutover and HTTPS
With the app verified on the VM, point your domain at it and switch on TLS.
Cut my domain {your-domain.com} over to the new VM:
1. Check whether the domain is already a hosted DNS zone here; if not, create
it and tell me the nameservers to set at my registrar.
2. Lower the TTL first at whatever DNS host currently serves the domain, then
point the A record at the VM's public IP with a short TTL.
3. Once DNS resolves to the VM, provision a Let's Encrypt certificate with
certbot and switch nginx to HTTPS with auto-renewal. Keep the Fly app scaled
to zero — anyone still on cached DNS should fail over, not write to the old
database.
Tell me when https://{your-domain.com} is live and served from the new server.What your assistant will do:
- Call
list_dns_zones; if your domain isn't hosted here,create_dns_zoneadds it and the assistant shows you the American Cloud nameservers to set at your registrar. A nameserver move has its own propagation window, separate from any record TTL. - Call
create_dns_record(orupdate_dns_record) to point theArecord at the VM's public IP. Lower the TTL first, at whichever DNS host currently serves the domain, so caches expire quickly — then give the new record a short TTL so you can react fast if anything's off. - Once DNS resolves to the VM, run certbot for a Let's Encrypt certificate, reconfigure nginx for port 443, and enable automatic renewal. This replaces the certificate Fly was managing for your custom domain.
The Fly app stays scaled to zero from the final dump until you decommission it. While DNS propagates, some visitors still resolve to Fly's anycast IP — with the Machines at zero they fail over rather than silently reading and writing the old database. Bringing it back during propagation is how migrations lose data.
DNS changes take time to propagate — from a few minutes to a couple of hours, depending on your registrar and the record's TTL. Ask your assistant to "check what {your-domain.com} currently resolves to and tell me when it points at the VM's IP." certbot also needs DNS to resolve to the VM before it can issue the certificate.
Phase 6 — verify, then decommission
Run on the new server for a day or two before you delete anything on Fly. Keep the Fly app around (scaled to zero) — it's your rollback if something surfaces under real traffic.
The app's been live on the VM for a couple of days and looks healthy. Walk me
through decommissioning Fly safely: confirm the VM is the only thing serving
the domain, remind me to keep a final database dump and volume copy offline,
and give me the flyctl commands to destroy the Machines, the Postgres app, and
release the Fly IPs once I'm sure.The assistant can confirm the American Cloud side — the VM is STARTED, the container is running, the certificate is valid — but the Fly teardown stays in your hands: keep one last database dump and volume copy offline, then run flyctl yourself to destroy the Machines, destroy the attached Postgres app, and release the Fly IPs when you're confident. From here, every future deploy is a build-and-restart on a server you own.
Follow-up: replace fly deploy
fly deploy was your one command to ship. Recreate that ergonomics on the VM:
Set up a deploy script in this repo that replaces "fly deploy": push the latest
committed code over SSH, rebuild the Docker image on the VM, run my database
migration command, and restart the container. Document the one command I run
from now on.After this, shipping a change is "run the deploy script" — or just "deploy the latest" and the assistant runs it.
Troubleshooting
The container won't start after migration. Ask your assistant to "show me the container logs on the VM and tell me what's failing." The usual culprit is a missing or placeholder value in the environment file — confirm every Fly secret name from Phase 1 has its real value set on the server.
The app can't reach the database. Have the assistant confirm PostgreSQL is running and that DATABASE_URL in the env file points at localhost with the right database and user. Since Postgres listens only on localhost, nothing on the firewall needs to change — connections stay on the box.
The site is unreachable on 80 or 443. Ask your assistant to "list the firewall rules on the VM's public IP and confirm 80 and 443 are open." A port needs both a firewall rule (create_firewall_rule) and forwarding (create_port_forwarding_rule), or static NAT (enable_static_nat), to be reachable — list_firewall_rules and list_port_forwarding_rules show what's actually in place. Have it also confirm the VM is STARTED with get_vm and that nginx and the container are both running.
The mounted volume is empty or read-only. Ask the assistant to "confirm the block storage volume is attached, formatted, and mounted at the path the container expects, and that the container's volume mount points at it." A Fly [mounts] path that doesn't match the VM mount point is the common cause.
Row counts don't match in Phase 4. Don't cut DNS over. Ask the assistant to re-run the dump and restore — the most common cause is a write that slipped in before the Machines were scaled to zero. Re-freezing and re-dumping gives a clean snapshot.
Latency feels worse for some users. That's the edge-to-single-region change showing up. If it's real and load-bearing, ask your assistant to "plan a second VM in a region closer to those users, behind a load balancer" — see scaling with a load balancer. For most apps the single region is fine; measure before adding machines.
Next steps
- Deploy a Docker Compose app — the VM-with-Docker landing zone this recipe builds on, in full
- Run on Kubernetes — the right home for a genuinely multi-service Fly app
- Scale out with a load balancer — put several VMs behind one IP when one region needs more than one machine
- Backups with your AI assistant — snapshots and offsite dumps for your volume and database
- Teach your AI agent to deploy here — add one file to your repo and your agent knows this whole playbook