Migrate to Zenifra

Migrate from Fly.io to Zenifra

This guide shows how to translate each part of a Fly.io app to the Zenifra console. On Fly.io, almost everything lives in fly.toml and the fly CLI, so the guide follows that file section by section. For the general plan, with data copy and DNS switch, see Migrate to Zenifra.

Keep the Fly.io app running while you deploy and validate the application on Zenifra. Only destroy the Machines after switching DNS.

How the concepts map

On Fly.ioOn Zenifra
AppHTTP project
Machines of the web processDesired Instances or auto-scaling
Groups in [processes]One project per group
Dockerfile buildOCI image or Git repository with a runtime
internal_portPort
[env] and secretsEnvironment Variables
release_commandBeginning of the Start Command or a manual step
HTTP checksHealthcheck (from the Premium plan up)
Volumes in [[mounts]]Persistent storage
Scheduled MachinesScheduled jobs
Fly Postgres or Managed PostgresManaged PostgreSQL
Upstash RedisManaged cache or Key-Value
TigrisArmazém

One important difference: on Fly.io, auto_stop_machines can stop Machines without traffic. On Zenifra, the application stays up. With Hourly billing, you pay for the time it runs; see Which HTTP plan to choose.

1. Read fly.toml

Open fly.toml at the repository root and write down each section:

fly.toml
app = "my-app"
primary_region = "gru"

[build]
  dockerfile = "Dockerfile"

[env]
  NODE_ENV = "production"

[deploy]
  release_command = "npx prisma migrate deploy"

[processes]
  web = "node server.js"
  worker = "node worker.js"

[http_service]
  internal_port = 8080
  processes = ["web"]
  min_machines_running = 1

  [[http_service.checks]]
    path = "/health"
    interval = "15s"

[[mounts]]
  source = "data"
  destination = "/data"
fly.toml sectionIn the Zenifra console
[build] dockerfile, image, or builderChoice of source; see step 2
[env]Environment Variables
[deploy] release_commandSee Release command
[processes]One project per group; the command goes in the Start Command
[http_service] internal_portPort
[http_service] min_machines_running and fly scale countDesired Instances
[[http_service.checks]] pathCheck path, under Edit › Application healthcheck
[[mounts]] destinationDirectory to Persist
[[vm]] memory and cpusChoice of plan
[[statics]]Serve the files from the application itself
primary_regionNot applicable: Zenifra runs in Brazil

Also check with the CLI what is not in the file:

fly scale show -a my-app      # number and size of Machines
fly secrets list -a my-app    # secret names
fly certs list -a my-app      # domains with certificates
fly volumes list -a my-app    # volumes

2. Choose the project source

Fly.io builds almost every app from a Dockerfile, often generated by fly launch. On Zenifra you have two paths:

Option A: Git repository with a runtime

If the app is Node.js, Python, Bun, Go, or Java, you can set the Dockerfile aside and deploy from the repository. Zenifra generates the build environment on its own and ignores the Dockerfile.

In the console, click + Create project, choose HTTP Application and the plan. Under Advanced Settings, choose Git repository as the Project Source, the Git provider GitHub (or Forgejo), the repository, and the branch. Then fill in:

Where it comes fromIn the Zenifra console
Language and version in the Dockerfile FROM, such as node:22Runtime and Version
App folder in the repositoryRoot directory
Build RUN command in the Dockerfile, such as npm run buildBuild Command
web group in [processes] or the Dockerfile CMDStart Command
internal_portPort

See each language's commands in Runtimes.

Option B: Docker image

To keep the same Dockerfile, or for languages such as PHP, Ruby, Elixir, and .NET, deploy as a Docker Image: build the image, push it to a registry, such as GitHub Container Registry or Docker Hub, and enter the image in the project.

docker build --platform linux/amd64 -t ghcr.io/my-org/my-app:1.0.0 .
docker push ghcr.io/my-org/my-app:1.0.0

The image must include linux/amd64. Under Project Source, choose Docker Image, enter the image and, for a private registry, the credentials. See Deploy via OCI image and Private registry. The GitHub Action builds and deploys on every push.

3. Recover variables and secrets

The variables in the [env] section are in fly.toml: copy them directly.

Secrets cannot be read with the CLI: fly secrets list only shows each one's name and a digest. Get the values from their source, such as the team's password manager, your CI, or the .env used with fly secrets import. If you have no other copy, the secrets are in the environment variables of a running Machine, which you can check through fly ssh console, taking care not to leave the values in your terminal history.

When reviewing the variables:

  • DATABASE_URL and REDIS_URL point to Fly.io databases. Replace them with the database URLs on Zenifra.
  • Tigris credentials (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_ENDPOINT_URL_S3, and BUCKET_NAME) change if you move the files to Armazém.
  • Fly.io variables, such as FLY_APP_NAME, FLY_REGION, FLY_MACHINE_ID, and PRIMARY_REGION, do not exist on Zenifra. If the code uses one, create a variable with the equivalent value.
  • If the code reads PORT, create PORT with the same value as the Port field.

4. Adjust what differs between the platforms

Release command

On Fly.io, release_command runs once per deploy, on a temporary Machine, before the app is updated. On Zenifra, put the command at the beginning of the Start Command, such as npx prisma migrate deploy && node server.js. It then runs every time an instance starts, so it must be idempotent. For long migrations or multiple instances, run the migration manually before deploying.

Processes

Each [processes] group becomes a project on Zenifra, with the same repository or image and the group's command in the Start Command. Groups that receive no traffic, such as workers, use Exposure Private - no public route.

Private networking

On Fly.io, apps talk to each other through my-app.internal or my-app.flycast. On Zenifra, replace those addresses with the target project's URL, *.clients.zenifra.com or its domain, and protect internal routes with authentication. Zenifra databases and managed services already have their own connection URLs.

Scheduled tasks

Machines created with fly machine run --schedule (hourly, daily, weekly, or monthly) and processes with cron inside the container become scheduled jobs. On Zenifra, the Cron expression sets the exact time, in UTC, such as 0 6 * * * for every day at 06:00 UTC.

Static files

The [[statics]] section made Fly.io serve a folder from the image directly. On Zenifra, serve those files from the application itself, for example with express.static in Node.js or WhiteNoise in Django.

5. Migrate databases, volumes, and files

Postgres

Create the managed PostgreSQL on Zenifra before the application. To copy the data, open a tunnel to the Fly.io database and run pg_dump on your machine.

Managed Postgres: in one terminal, open the proxy. By default, it listens on localhost:16380:

fly mpg proxy CLUSTER_ID

Fly Postgres (Postgres app): open the proxy on a free port:

fly proxy 15432:5432 -a my-app-db

In another terminal, use the user, password, and database from DATABASE_URL, replacing the host and port with the proxy's:

pg_dump --format=custom --no-owner --no-acl \
  "postgresql://user:password@localhost:15432/database" > dump.pgdump

pg_restore --no-owner --no-acl --jobs=4 \
  --dbname="postgresql://user:password@host:port/database?sslmode=verify-full&sslrootcert=system" \
  dump.pgdump

With Managed Postgres, use port 16380 instead of 15432. See Migrate the database to validate the copy.

Redis

Create a managed cache or a Key-Value database and replace REDIS_URL. Cache and session data are usually rebuilt by the application. If Redis holds queues, let the Fly.io workers drain them before the switch.

Volumes

On Fly.io, each volume belongs to one Machine and is not replicated. To copy the files to your machine:

fly ssh sftp get -R -a my-app /data ./volume-data

If the app has more than one Machine with a volume, repeat for each one with -s to pick the Machine, because each volume has different data.

On Zenifra, enable persistent storage when you create the project, with the same path in Directory to Persist, and send the files through a temporary password-protected import route. This path cannot be changed later. Persistent storage data is replicated.

Tigris

Zenifra's Armazém is S3-compatible, like Tigris. Download the bucket's files with the tool you already use and upload them to a bucket in Armazém with the AWS CLI or the S3 SDK. Then replace the endpoint, keys, and bucket name in the project variables. See Connect applications.

6. Validate and switch DNS

  1. Open the project's *.clients.zenifra.com URL and test login, main flows, e-mails, workers, and webhooks.
  2. Check Build logs and the application logs for errors.
  3. Follow the switch-day checklist: lower the DNS TTL, run the final data copy, add the domain on Zenifra, and update DNS.
  4. Once DNS points to Zenifra, remove the domain's certificates on Fly.io with fly certs remove.
  5. Keep the Fly.io app for a few days for rollback. Then destroy the app, volumes, and databases to stop billing.

Common issues

SymptomLikely causeFix
Public URL does not respondPort differs from internal_port, or the app listens on localhostUse the same port and listen on 0.0.0.0
Image does not startImage built only for ARMBuild with --platform linux/amd64
Error connecting to *.internalFly.io's private network does not exist on ZenifraUse the project or database URL on Zenifra
Empty variableA Fly.io secret was not copiedRecover the value at its source and add it to the project
Tables do not existrelease_command does not run on ZenifraRun the migrations; see Release command
Worker stopped[processes] group without a projectCreate a private project for each group

Need help?

Write to [email protected] with your fly.toml, or reach us on WhatsApp at +55 (11) 5199-3013.

Last updated on

On this page