fly.toml becomes a process, release_command becomes a job, the volume becomes a disk, and the secrets and [env] entries become environment variables. A Sevalla PostgreSQL database replaces Fly Managed Postgres or Fly Postgres. You recreate the setup while the Fly.io app keeps running, copy the data during a short maintenance window, then point the domains at Sevalla.
At a glance
Environment variables
[env] entries to builds, so make them available during runtime only. With the Dockerfile build strategy, every variable that’s available during build is passed to the build as a build argument.
How Fly.io maps to Sevalla
Before you begin
flyctl, signed in to the organization that owns the app.- The app’s code in a repository on GitHub, GitLab or Bitbucket. Sevalla doesn’t deploy from a local folder like
fly deploydoes. See Git providers. - A Sevalla company with a payment method.
- The PostgreSQL client tools, version 17 or newer: the dump leaves out Fly Managed Postgres extensions with
pg_dump --exclude-extension, which version 17 added. See Import and export PostgreSQL. - A maintenance window: the Fly.io app takes no requests while you copy the data.
Take stock of the Fly.io app
Collect what you’ll recreate, with your app’s name in place ofstorefront:
config showprints the deployedfly.tomlas JSON: the[processes], therelease_command, the[http_service]with its checks, the[[mounts]]and the[env]entries.statusandscale showlist the Machines of each process group, their size and their regions. Fly.io’sfraregion is in Frankfurt andiadin Ashburn, like the Sevalla locations Frankfurt, Germany and Ashburn, USA. See Locations.volumes listshows each volume’s size and the Machine it’s attached to.secrets listshows each secret’s name and a digest, never its value. You read the values in Move the secrets and environment variables.mpg listshows the Managed Postgres clusters and their IDs; for unmanaged Fly Postgres, runfly postgres list. Create the Sevalla database on the same PostgreSQL major version or a newer one: Fly Managed Postgres offers 16 and 17, and an unmanaged Fly Postgres cluster created today runs 18.certs listshows the custom domains.
Create the database
In the sidebar, click Databases, then click Create database. Under Database, choose PostgreSQL and a version at least as new as Fly.io’s. Enterorders-db as the Display name, choose the Data center closest to the Fly.io app’s primary region and a Size with room for the data, then click Create database. See Create a database.
Create the application
Create it from the repository
Build from the Dockerfile
Dockerfile in your repository, and Sevalla can build the same file. On the Settings tab, in the Build strategy section, click Update build strategy. Click Dockerfile, keep the Dockerfile path Dockerfile and the Docker context ., then click Update build strategy. If fly.toml sets [build] dockerfile, enter that path instead. See Dockerfile.linux/amd64 and passes no --target, so the image is the last stage, as with a Fly.io deploy without build-target. To drop the Dockerfile later, see Build with Ingot instead.
Connect the database
Click the Networking tab. Under Connected services, click Add internal connection. Chooseorders-db as the Service and turn on Add environment variables to the application. Change the key DB_URL to DATABASE_URL, the name that fly mpg attach and fly postgres attach use, clear Available during build, and click Add internal connection. See Connect an application.
Move the secrets and environment variables
Read the secret values
fly secrets list shows a digest of each secret, not its value: Fly.io makes values available only to the app. Take them from where you keep them, such as a password manager, or read each one from a running Machine:DATABASE_URL: the internal connection replaces it.Write a .env file
[env] entry from fly.toml in a file as KEY=value. Replace the values that point at Fly.io: a .internal or .flycast address of the app’s own web process becomes http://<internal hostname>:8080, with the hostname from the Internal hostnames section of the Networking tab, and a fly.dev domain becomes the application’s sevalla.app domain under its name in the page header. Don’t commit the file.Import the file
PORT: Sevalla sets it to the web process’s port. Fly.io’s own variables, such as FLY_APP_NAME, FLY_REGION and PRIMARY_REGION, don’t exist on Sevalla: remove code that depends on them. See Environment variables.
Recreate the process groups
Update the web process
/healthz, as the Path, then the Initial delay, Period and Timeout: 10, 30 and 5 for grace_period = "10s", interval = "30s" and timeout = "5s". Click Update process.Create the background workers and the job
release_command. Choose Background worker or Job, enter a Name and the Custom start command, set the Start policy to Before deployment for the job, choose a Resource, and click Create process.Create the disk
destination, such as /data, as the Path, pick a Size (10 GB is the smallest), and click Create disk. See Persistent storage.internal_port instead of reading PORT, set the web process’s port to that number on the Networking tab. See Web process port.
Fly.io sends SIGINT when it stops a Machine, unless kill_signal says otherwise. Sevalla sends SIGTERM: make sure your servers and workers shut down cleanly on it. See Processes.
Copy the PostgreSQL data
Stop writes on Fly.io
fly ssh still reaches them. Take the Machine IDs from fly status:fly machine uncordon <machine id> and scale the workers back up.Open a proxy to the Fly.io database
fly mpg list; the proxy listens on localhost:16380:localhost:15432:Dump the database
DATABASE_URL, which fly ssh console --app storefront --command "printenv DATABASE_URL" prints, with localhost and the proxy’s port as the host. For Fly Managed Postgres, the user is fly-user and the database fly-db:pg_stat_monitor and pgaudit extensions in every database. A Sevalla database user can’t create them, so the dump leaves them out. For unmanaged Fly Postgres, dump through port 15432, without the two options:Open the Sevalla database to your computer
orders-db. Click the Networking tab, click Enable under External connection, then click Enable in the dialog. Copy the External hostname, External port and password. See Enable the external connection.Restore the dump
--clean --if-exists drops the tables of an earlier attempt first. Use the user and database name of orders-db, from its Overview tab.sevalla.app domain. The real cutover then replaces the rehearsal’s data. With the data in place before the first deployment, the release job runs its migrations against it, like release_command does on Fly.io.
Deploy and verify
Deploy
Check the deployment
Building and pushing docker image using Dockerfile... and the job’s result, such as release has been successful.With the API, Get deployment (getDeploymentV3) returns the status, success for Deployed, and Get application deployment logs (getApplicationDeploymentLogsV3) finds the job’s result. APP_ID and DEPLOYMENT_ID come from Deploy with the API, where the job is named release. The messages carry terminal color codes, which gsub removes:Compare the data
status and processed_at on the orders it processes.PGTZ=UTC makes both servers print times the same way. These are the example’s values: for your app, what counts is that both databases print the same lines.Test the application
getApplicationRuntimeLogsV3) with search finds a line that a worker logs. The example worker logs each order it processes and each time it reaches the web process over the internal hostname:sevalla apps logs runtime "$APP_ID" --search reached. MCP: search_runtime_logs.Turn on auto-deploy
fly deploy in your CI did, click the Settings tab, then click Update auto-deploy in the Auto-deploy section. Turn on Auto-deploy and click Update auto-deploy. Remove the fly deploy step from your CI. See Deployments.Copy the volume files
The disk is mounted from the first deployment on, so copy the volume’s files once the application runs.fly ssh needs the Fly.io Machine with the volume to be running. With auto_stop_machines on, Fly Proxy stops it after a few minutes without requests, cordoned or not: start it again with fly machine start <machine id> --app storefront, and it stays cordoned.
Archive the volume on Fly.io
destination, here /data, into an archive outside it, and download it:--exclude=lost+found leaves out the folder that every volume’s file system has. fly ssh sftp get doesn’t overwrite a local file: delete an earlier volume.tar.gz first.List the files with their checksums
Upload the archive
sh in the web process. If the disk is on a background worker, click Web terminal on that process in the Overview tab instead. Click Files > Upload files…, choose volume.tar.gz, set the Destination to the disk’s path, /data, and click Upload. Each file can be up to 100 MB. For a larger archive, split it first with split -b 90m volume.tar.gz volume.tar.gz.part-, upload the parts one at a time, and join them in the terminal with cat /data/volume.tar.gz.part-* > /data/volume.tar.gz && rm /data/volume.tar.gz.part-*. See Copy files to and from a process.Unpack it
fly-files.txt line for line.execProcessCommandV3), and compare the list with fly-files.txt. WEB_ID comes from Deploy with the API:
sevalla apps processes exec "$WEB_ID" --app-id "$APP_ID" -- sh -c 'cd /data && find . -type f -exec sha256sum {} + | sort -k 2' | jq -j .stdout, which prints the same JSON. MCP: sevalla_api_update with execProcessCommandV3.
Build with Ingot instead
Sevalla’s default build strategy, Ingot, detects Node.js, Python, PHP, Ruby, Go, Rust, Java, .NET, Elixir and more, and writes the Dockerfile for each build, so you don’t maintain one. It reads the language version from your project files, such asengines.node in package.json, not from an ARG NODE_VERSION in your Dockerfile, and the processes keep their start commands. To try it, click the Settings tab, then click Update build strategy in the Build strategy section. Click Ingot, click Update build strategy, and deploy. If the deployment or the checks in Deploy and verify fail, switch back to Dockerfile. Ingot doesn’t read your Dockerfile, but it adds the patterns of .dockerignore to its own, so you can keep both files while you compare.
Run in more than one location
A Fly.io app with Machines in more than one region becomes one Sevalla application per location, with a load balancer in front:- Deploy the application in the first location as this page describes.
- Copy it to each other location with Clone application (
cloneApplicationV3) and the location’scluster_id, or withsevalla apps clone <app id> --display-name "storefront (Ashburn)" --cluster <cluster id>. The dashboard has no clone action: there, create the application again as this page describes. A clone gets the processes, the disk settings with an empty disk, the environment variables, the build settings and the auto-deploy setting, and isn’t deployed. - In each copy, change the variables that point at the first location:
DATABASE_URLbecomes the database’s external connection URL, because internal connections work only within one location, and internal hostnames become the copy’s own. Then deploy it. - Create a load balancer with Geo routing and add each application as an App destination. Visitors reach the closest one.
- Add the custom domains to the load balancer instead of the applications, as in Move the custom domains.
Move the custom domains
The switch has no downtime if you add Sevalla’s verification records while the domain still points at Fly.io, and change the records that point it only once the certificate is ready.Add each domain
shop.example.com, and click Add custom domain. Its DNS records sheet opens.Add the verification records
_cf-custom-hostname and _acme-challenge records that the sheet shows. Keep the A and AAAA records, or the CNAME, that point the domain at Fly.io: Fly.io keeps serving it. If _acme-challenge already has a CNAME for Fly.io’s certificate, replace it with Sevalla’s record: Fly.io’s current certificate stays in use. When the domain’s status changes to Pointing, its certificate is ready. See Verify the domain.Point it at Sevalla
A and AAAA records that point at Fly.io’s addresses, or the CNAME to storefront.fly.dev, and add the pointing record that the sheet shows. Delete the AAAA record too: visitors on IPv6 would otherwise still reach Fly.io. The status changes to Active when Sevalla sees the record. See Point the domain.Shut down the Fly.io app
When the domains are Active and traffic reaches Sevalla:- Disable the external connection you opened: on the database’s Networking tab, click Disable under External connection, then click Disable in the dialog.
- Remove the domains from Fly.io with
fly certs remove shop.example.com --app storefront. - Keep
storefront.dumpandvolume.tar.gzuntil you no longer need them. - Delete the app with
fly apps destroy storefront, which deletes its Machines and volumes. Delete a Managed Postgres cluster withfly mpg destroy <cluster id>, and an unmanaged Fly Postgres app withfly apps destroy storefront-db. You can’t undo these.
Troubleshoot
Deploy with the API
This sequence does what Create the database to Deploy and verify do in the dashboard, with the Sevalla API and the Dockerfile build strategy. It reads the app’s settings withfly config show, so it runs from any folder: it creates the database and the application, adds the secrets and [env] entries, turns each process group into a process, attaches the disk, restores the dump, and deploys. A script or an AI agent runs it as is, in one shell. Each step names the CLI command and the Sevalla MCP server tool that do the same. You need:
- An API key with the Company developer role, in
SEVALLA_API_KEY. - The repository on GitHub, and a GitHub account with access to it connected to Sevalla. The API has no call that reports the connection: without access, the step that creates the application fails with
400andCannot access repository https://github.com/acme/storefront: Repository not found or you don't have access., followed by where to connect a Git account (My account in the dashboard) and, for GitHub, to give Sevalla’s GitHub App access to the repository. flyctl, signed in, pluscurl,jq,openssland the PostgreSQL client tools.storefront.dumpin the current folder, from Copy the PostgreSQL data.
DATABASE_URL, and its [env] entries:
secrets.jsonl holds the secret values: don’t commit it. fly ssh console needs a running Machine; < /dev/null keeps it from reading the list of names.
Choose the location
listClustersV3) returns every location with its region ID in location. Save the one closest to the Fly.io app’s primary region, here eu-frankfurt-1 (Frankfurt, Germany) for Fly.io’s fra:sevalla resources clusters. MCP: get_platform_options.Look up the sizes
listProcessResourceTypesV3), List disk resource types (listDiskResourceTypesV3) and List database resource types (listDatabaseResourceTypesV3). Save H1 for the background workers and the job, the 10 GB disk and DB1, the smallest database. The web process keeps S1, which can have custom domains:sevalla resources process-types, sevalla resources disk-types and sevalla resources db-types. MCP: get_platform_options for the process and database sizes, and sevalla_api_read with listDiskResourceTypesV3.Look up the PostgreSQL version
listDatabaseVersionsV3) returns the versions the location offers, oldest first. Save the newest:sevalla resources db-versions --cluster "$CLUSTER_ID". MCP: not needed, create_database takes the newest version by default.Create the database
createDatabaseV3). You choose the password, and the API never returns it, so keep it for the connection URL and the restore:sevalla databases create --name orders-db --type postgresql --db-version "$PG_VERSION" --cluster "$CLUSTER_ID" --resource-type "$DB_SIZE_ID" --db-name storefront --db-user storefront --db-password "$PG_PASSWORD". MCP: create_database.Create the application
createApplicationV3) creates the application with a web process at S1, and doesn’t deploy it:sevalla apps create --name storefront --cluster "$CLUSTER_ID" --source privateGit --git-type github --repository "$REPO" --branch main. MCP: create_application with deploy: false.Build from the Dockerfile
updateApplicationV3) switches to the Dockerfile build strategy:sevalla apps update "$APP_ID" --data '{"build_type": "dockerfile", "dockerfile_path": "Dockerfile", "docker_context": "."}'. MCP: sevalla_api_update with updateApplicationV3 and the same body.Wait for the database and connect it
getDatabaseV3) shows status ready when the database is up, with its internal hostname and port. Then Create internal connection (createApplicationInternalConnectionV3):DATABASE_URL. CLI: sevalla databases get "$PG_ID" and sevalla apps internal-connections create "$APP_ID" --target-id "$PG_ID" --target-type database. MCP: get_database, and sevalla_api_create with createApplicationInternalConnectionV3 and the same body.Replace the Fly.io addresses
listProcessesV3) returns the web process with its internal_hostname. Print it, and the entries that still point at Fly.io:env.jsonl and secrets.jsonl, change each printed value: an address of the app’s own web process becomes http://<internal hostname>:8080, and a fly.dev domain becomes the application’s sevalla.app domain. For the example’s APP_INTERNAL_URL:sevalla apps processes list --app-id "$APP_ID". MCP: sevalla_api_read with listProcessesV3.Add the environment variables
createEnvironmentVariableV3), once per variable, available during runtime only: DATABASE_URL in the format the internal connection uses, then every secret and [env] entry:sevalla apps env-vars create makes every variable available during build too, which passes it to the Dockerfile build: use the API or MCP for this step. MCP: set_environment_variables with buildtime: false on each variable.Set up the web process
updateProcessV3) sets the start command of the group that [http_service] serves, after exec, and the readiness probe. Use the path, grace_period, interval and timeout of your [[http_service.checks]], in seconds; always send timeoutSeconds, which is 120 when left out:sevalla apps processes update "$WEB_ID" --app-id "$APP_ID" --command "exec node server.js" sets the command; the CLI can’t set probes. MCP: sevalla_api_update with updateProcessV3 and the same body.Create the background workers and the job
createProcessV3): a background worker for every other process group, its command after exec, then a job with the start policy beforeDeployment for release_command:fly.toml has no release_command. CLI: sevalla apps processes create "$APP_ID" --name worker --type worker --resource-type "$H1_ID" --command "exec node worker.js", and for the job --type job --job-start-policy beforeDeployment. MCP: sevalla_api_create with createProcessV3 and the same body.Attach the disk
updateProcessDiskV3) attaches the disk at the volume’s destination. This attaches it to the web process; for a volume mounted for another group, use that process’s ID:sevalla apps processes disk update "$WEB_ID" --app-id "$APP_ID" --resource-type "$DISK_ID" --mount-path /data. MCP: sevalla_api_update with updateProcessDiskV3 and the same body.Restore the data
toggleDatabaseExternalConnectionV3) opens the database to your computer. Wait until Get database returns its external_hostname, then restore the dump from Copy the PostgreSQL data:External connection toggle started. For a minute or two after the hostname appears, connections can fail with server closed the connection unexpectedly, so the loop runs pg_restore again until it succeeds; --clean --if-exists makes each attempt start from the dump. CLI: sevalla databases connections external-toggle "$PG_ID". MCP: sevalla_api_create with toggleDatabaseExternalConnectionV3.Deploy
triggerDeploymentV3) deploys the latest commit of the branch:sevalla apps deployments trigger "$APP_ID". MCP: trigger_deployment.Wait for the deployment
getDeploymentV3) reports the status, which ends as success, failed, cancelled or skipped. The loop keeps polling through a failed request, for up to 30 minutes:sevalla apps deployments get "$DEPLOYMENT_ID" --app-id "$APP_ID". MCP: get_deployment.Turn on auto-deploy
updateApplicationV3):sevalla apps update "$APP_ID" --data '{"auto_deploy": true}'. MCP: sevalla_api_update with updateApplicationV3 and the same body.{"build_type": "ingot"} and deploy again. Add application domain (addApplicationDomainV3) adds each custom domain; Get application domain (getApplicationDomainV3) then returns the records to add in dns_records, under verification, ssl and origin. CLI: sevalla apps domains add "$APP_ID" --name shop.example.com. MCP: add_domain.