> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sevalla.com/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> These are the docs for Sevalla, a platform for deploying applications, databases, static sites, object storage and load balancers. The dashboard is at https://app.sevalla.com.
> Bold text in steps is an exact dashboard label.
> To act on a Sevalla account, prefer the Sevalla MCP server (https://mcp.sevalla.com/mcp, see https://docs.sevalla.com/ai-agents/overview.md), then the Sevalla CLI (https://docs.sevalla.com/developer-tools/cli.md), then the REST API (https://api-docs.sevalla.com, OpenAPI spec at https://api.sevalla.com/v3/openapi.json).
> Every page index is at https://docs.sevalla.com/llms.txt.

# Migrate from Fly.io

> Move a Fly.io app to Sevalla: how process groups, release_command, health checks, volumes, secrets, private networking and Postgres map, then copy the data and move the domains.

A Fly.io app moves to Sevalla as one application: each process group in `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

| | |
| - | - |
| Source | The app's Git repository on GitHub, GitLab or Bitbucket, with the `Dockerfile` that Fly.io builds |
| Build | [Dockerfile](https://docs.sevalla.com/applications/builds/dockerfile.md), the same file Fly.io builds, or [Ingot](https://docs.sevalla.com/applications/builds/ingot.md) without one |
| Start command | The `[processes]` entry of the group that `[http_service]` serves, after `exec`, such as `exec node server.js` |
| Port | The `PORT` environment variable, `8080` by default, in place of `internal_port` |
| Processes | Web process for the group that serves HTTP; a **Background worker** for each other process group, its command after `exec`; a **Job** with the start policy **Before deployment** for `release_command` |
| Services | A PostgreSQL database for Fly Managed Postgres or Fly Postgres, over an internal connection |
| Disk | One per volume, on the process of the group in `[[mounts]]`, at the volume's `destination` |
| Health check | The `[[http_service.checks]]` path as the web process's readiness probe |
| Size | **S1** or larger for the web process if you move a custom domain, because **H1** can't have one |

### Environment variables

| Variable | Required | Available during | Secret | Value |
| - | - | - | - | - |
| `DATABASE_URL` | With Postgres | Runtime | Yes | `DB_URL` from the internal connection to `orders-db`, renamed: `postgres://<user>:<password>@<host>:<port>/<database>` |
| The app's secrets | As on Fly.io | Runtime | Yes | Read from a running Machine with `fly ssh console`, because `fly secrets list` doesn't show values |
| The `[env]` entries | As on Fly.io | Runtime | No | From `fly.toml`, with Fly.io addresses replaced by Sevalla ones |
| `PORT` | No | Runtime | No | Set by Sevalla to the web process port. Don't add it |

Fly.io doesn't pass secrets or `[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

| Fly.io | Sevalla | Notes |
| - | - | - |
| App | [Application](https://docs.sevalla.com/applications/overview.md) | Built from a Git repository or a [Docker image](https://docs.sevalla.com/applications/docker-images.md) |
| Machine | Instance of a [process](https://docs.sevalla.com/applications/processes.md) | Each process runs one or more instances |
| Process group (`[processes]`) | Process | The group that `[http_service]` serves is the web process; every other group is a background worker |
| `release_command` | [Job](https://docs.sevalla.com/applications/processes.md#jobs) with the start policy **Before deployment** | Runs after the build, before the rollout, in the new image and without the disk. If it fails, the deployment fails and the previous version keeps running. It stops after 10 minutes unless you set a **Max run time** (Fly.io's default is 5 minutes) |
| `[http_service]`, `internal_port` | Web process and its [port](https://docs.sevalla.com/applications/networking.md#web-process-port) | Sevalla sends traffic to the port in `PORT`, `8080` by default |
| `[[services]]` with TCP ports | [TCP proxy](https://docs.sevalla.com/applications/networking.md#tcp-proxies) or [private port](https://docs.sevalla.com/applications/networking.md#private-ports) | |
| `[[http_service.checks]]`, `[[services.http_checks]]` | [Readiness probe](https://docs.sevalla.com/applications/processes.md#health-checks) of the web process | `grace_period`, `interval` and `timeout` become **Initial delay**, **Period** and **Timeout** |
| `[[services.tcp_checks]]`, the top-level `[checks]` | No equivalent | Probes are HTTP `GET` requests to the web process. Add an HTTP path that answers `200` |
| `auto_stop_machines`, `auto_start_machines` | [Hibernation](https://docs.sevalla.com/applications/scaling.md#hibernation) | For the whole application, after a set time without HTTP requests |
| `fly scale count`, `min_machines_running` | [Instances and horizontal autoscaling](https://docs.sevalla.com/applications/scaling.md) | Set per web process and background worker |
| `[[vm]]` sizes | [Process sizes](https://docs.sevalla.com/applications/scaling.md#instance-sizes) | A Machine with 256 MB of memory fits in **H1** (0.3 vCPU, 0.3 GB) |
| Volume, `[[mounts]]` | [Disk](https://docs.sevalla.com/applications/disks.md) | One per web process or background worker, from 10 GB. A process with a disk runs 1 instance |
| Volume snapshots | [Disk backups](https://docs.sevalla.com/applications/disks.md#backups) | Daily, kept for 7 days, restored by support |
| Secrets (`fly secrets`) | [Environment variables](https://docs.sevalla.com/applications/environment-variables.md) | Available during runtime, build time or both |
| `[env]` | Environment variables | |
| `[build.args]` | Environment variables available during build | Passed to Dockerfile builds as build arguments |
| Dockerfile builds, `[build] dockerfile` | [Dockerfile build strategy](https://docs.sevalla.com/applications/builds/dockerfile.md) | The **Dockerfile path** and **Docker context** |
| `[build] build-target` | No equivalent | Sevalla builds the Dockerfile's last stage. Move the stage you deploy to the end |
| `[build] image` | [Docker image](https://docs.sevalla.com/applications/docker-images.md) source | |
| `[build] builder` (buildpacks) | [Ingot](https://docs.sevalla.com/applications/builds/ingot.md), or [Buildpacks](https://docs.sevalla.com/applications/builds/buildpacks.md) with `heroku/builder:24` | Paketo builders aren't available |
| `.internal` addresses | [Internal hostname](https://docs.sevalla.com/applications/networking.md#internal-hostnames) | `<group>.process.<app>.internal` becomes `<name>-<process>.<name>.svc.cluster.local`. Another application needs an [internal connection](https://docs.sevalla.com/applications/networking.md#internal-connections) |
| Flycast (`<app>.flycast`) | Internal hostname and internal connection | To keep an application private, [turn its HTTP traffic off](https://docs.sevalla.com/applications/networking.md#turn-http-traffic-off) |
| Fly Managed Postgres, Fly Postgres | [PostgreSQL database](https://docs.sevalla.com/databases/overview.md) | |
| Upstash for Redis (`fly redis`) | Redis or Valkey database | See [Import and export Redis](https://docs.sevalla.com/databases/import-export/redis.md) |
| Tigris (`fly storage`) | [Object storage](https://docs.sevalla.com/object-storage/overview.md) | S3-compatible buckets |
| `primary_region` | [Location](https://docs.sevalla.com/platform/locations.md) | An application runs in one location |
| Machines in more than one region | One application per location, behind a [load balancer](https://docs.sevalla.com/load-balancers/overview.md) | See [Run in more than one location](#run-in-more-than-one-location) |
| Scheduled Machines (`--schedule`), supercronic | [Cron job](https://docs.sevalla.com/applications/processes.md#cron-jobs) | Any cron schedule, in UTC unless you set a time zone |
| `fly certs` | [Custom domains](https://docs.sevalla.com/domains/overview.md) | Sevalla issues and renews the SSL certificates |
| Dedicated IPv4 and IPv6 addresses | No equivalent | Domains point at Cloudflare with an `A` or `CNAME` record that Sevalla shows |
| `[[statics]]` | No equivalent | The application serves the files. [CDN and edge caching](https://docs.sevalla.com/applications/caching.md) cache them on Cloudflare |
| Deployment strategies (`rolling`, `bluegreen`, `canary`, `immediate`) | [Zero-downtime rollouts](https://docs.sevalla.com/applications/deployments.md#zero-downtime-rollouts) | Not configurable. A process with a disk stops before its new instance starts |
| `kill_signal`, `kill_timeout` | No equivalent | Sevalla sends `SIGTERM` 5 seconds after the shutdown begins (Fly.io's default is `SIGINT`) and stops the instance 30 seconds after it began |
| `fly ssh console` | [Web terminal](https://docs.sevalla.com/applications/web-terminal.md) | Or [Run a command in a process](https://api-docs.sevalla.com/v3/applications/run-a-command-in-a-process) with the API |
| `fly ssh sftp` | The web terminal's **Files** menu | Up to 100 MB per file |
| `fly proxy`, WireGuard | A database's [external connection](https://docs.sevalla.com/databases/connect.md#enable-the-external-connection) | Applications have no private network access from your computer |
| `fly deploy` | [Deployments](https://docs.sevalla.com/applications/deployments.md) | From the repository, on every push with auto-deploy. Sevalla doesn't build from a local folder |
| `fly logs` | [Logs](https://docs.sevalla.com/applications/logs.md) | |
| Organizations and members | [Companies](https://docs.sevalla.com/company/overview.md) and [users and roles](https://docs.sevalla.com/company/users-and-roles.md) | |
| Machines API and flyctl | [Sevalla API](https://docs.sevalla.com/developer-tools/api.md) and [CLI](https://docs.sevalla.com/developer-tools/cli.md) | |

## 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 deploy` does. See [Git providers](https://docs.sevalla.com/git/overview.md).
* 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](https://docs.sevalla.com/databases/import-export/postgresql.md#before-you-begin).
* 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 of `storefront`:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
fly config show --app storefront
fly status --app storefront
fly scale show --app storefront
fly volumes list --app storefront
fly secrets list --app storefront
fly mpg list
fly certs list --app storefront
```

* `config show` prints the deployed `fly.toml` as JSON: the `[processes]`, the `release_command`, the `[http_service]` with its checks, the `[[mounts]]` and the `[env]` entries.
* `status` and `scale show` list the Machines of each process group, their size and their regions. Fly.io's `fra` region is in Frankfurt and `iad` in Ashburn, like the Sevalla locations **Frankfurt, Germany** and **Ashburn, USA**. See [Locations](https://docs.sevalla.com/platform/locations.md).
* `volumes list` shows each volume's size and the Machine it's attached to.
* `secrets list` shows each secret's name and a digest, never its value. You read the values in [Move the secrets and environment variables](#move-the-secrets-and-environment-variables).
* `mpg list` shows the Managed Postgres clusters and their IDs; for unmanaged Fly Postgres, run `fly 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 list` shows 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. Enter `orders-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](https://docs.sevalla.com/databases/create.md).

## Create the application

**1. Create it from the repository**

In the sidebar, click **Applications**, then click **Create** > **Application**. Under **Source**, click **Private repository**, then choose the **Git provider**, the **Repository** and the **Branch**. Under **Location**, choose the same **Data center** as the database, choose a **Size**, and click **Create application**. The application opens on its **Overview** tab with the status **Not deployed yet**: creating an application doesn't deploy it. See [Create an application](https://docs.sevalla.com/applications/create.md).

**2. Build from the Dockerfile**

Fly.io builds the `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](https://docs.sevalla.com/applications/builds/dockerfile.md).

A web process on **H1** can't have custom domains. If you'll move a domain, choose a larger size, such as **S1**.

Sevalla builds the Dockerfile for `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](#build-with-ingot-instead).

## Connect the database

Click the **Networking** tab. Under **Connected services**, click **Add internal connection**. Choose `orders-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](https://docs.sevalla.com/databases/connect.md#connect-an-application).

## Move the secrets and environment variables

**1. 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:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
fly ssh console --app storefront --command "printenv ADMIN_TOKEN"
```

Skip `DATABASE_URL`: the internal connection replaces it.

**2. Write a .env file**

Put each secret and each `[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.

**3. Import the file**

Click the **Environment variables** tab, then click **Add environment variables**. Click **Import .env** and choose the file. Keep **Available during runtime** selected, clear **Available during buildtime**, and click **Add environment variables**.

Don't add `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](https://docs.sevalla.com/applications/environment-variables.md).

## Recreate the process groups

| `fly.toml` | Sevalla process | What to set |
| - | - | - |
| The `[processes]` group in `[http_service]`, such as `app = "node server.js"` | The existing **Web** process | The command after `exec` as the **Custom start command**: `exec node server.js`. `exec` makes the command the instance's main process, so it gets `SIGTERM` when the instance stops |
| Every other `[processes]` group, such as `worker = "node worker.js"` | A **Background worker** for each group | The command after `exec` as the **Custom start command**: `exec node worker.js` |
| `release_command = "node migrate.js"` | A **Job** with the **Start policy** **Before deployment** | The command as the **Custom start command** |
| `[[http_service.checks]]` | The web process's **Readiness probe** | The `path`, with `grace_period` as **Initial delay**, `interval` as **Period** and `timeout` as **Timeout**, in seconds |
| `[[mounts]]` | A disk on the process of the group in `processes` | The `destination` as the **Path** |

**1. Update the web process**

On the **Processes** tab, click **⋯** on the web process, then click **Update process**. Enter the **Custom start command**. Under **Health checks**, turn on **Readiness probe** and enter the check's path, such as `/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**.

**2. Create the background workers and the job**

Click **Create process** for each other group and for `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**.

**3. Create the disk**

Click the **Disks** tab, then click **Create disk**. In **Process**, pick the process of the group the volume is mounted for. Enter the volume's `destination`, such as `/data`, as the **Path**, pick a **Size** (10 GB is the smallest), and click **Create disk**. See [Persistent storage](https://docs.sevalla.com/applications/disks.md).

If your server listens on `internal_port` instead of reading `PORT`, set the web process's port to that number on the **Networking** tab. See [Web process port](https://docs.sevalla.com/applications/networking.md#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](https://docs.sevalla.com/applications/processes.md).

## Copy the PostgreSQL data

**1. Stop writes on Fly.io**

Stop the background workers, then cordon the web Machines: Fly Proxy stops sending them requests, and `fly ssh` still reaches them. Take the Machine IDs from `fly status`:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
fly scale count worker=0 --app storefront --yes
fly machine cordon <machine id> --app storefront
```

Fly.io has no maintenance page: visitors get no response until you point the domain at Sevalla. To undo, run `fly machine uncordon <machine id>` and scale the workers back up.

**2. Open a proxy to the Fly.io database**

In a second terminal, keep a proxy running. For Fly Managed Postgres, use the cluster ID from `fly mpg list`; the proxy listens on `localhost:16380`:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
fly mpg proxy <cluster id>
```

For unmanaged Fly Postgres, proxy to the Postgres app on `localhost:15432`:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
fly proxy 15432:5432 --app storefront-db
```

**3. Dump the database**

Use the user, password and database from the app's `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`:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
pg_dump "postgresql://fly-user:<password>@localhost:16380/fly-db" \
  --format=custom --no-owner --no-privileges \
  --exclude-extension=pg_stat_monitor --exclude-extension=pgaudit \
  --file=storefront.dump
```

Fly Managed Postgres installs the `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:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
pg_dump "postgres://<user>:<password>@localhost:15432/<database>" \
  --format=custom --no-owner --no-privileges \
  --file=storefront.dump
```

**4. Open the Sevalla database to your computer**

In the sidebar, click **Databases**, then click `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](https://docs.sevalla.com/databases/connect.md#enable-the-external-connection).

**5. Restore the dump**

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
pg_restore --host=<external hostname> --port=<external port> \
  --username=storefront --dbname=storefront \
  --no-owner --no-privileges --clean --if-exists --jobs=4 \
  storefront.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.

To rehearse, run the dump and restore once without stopping writes, deploy, and test on the `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

**1. Deploy**

On the application's **Overview** tab, click **Deploy now** under **Recent deployments**, check the **Branch**, and click **Deploy**.

**2. Check the deployment**

On the **Deployments** tab, wait for the status **Deployed**. The build log shows `Building and pushing docker image using Dockerfile...` and the job's result, such as `release has been successful`.

With the API, [Get deployment](https://api-docs.sevalla.com/v3/applications/get-deployment) (`getDeploymentV3`) returns the status, `success` for **Deployed**, and [Get application deployment logs](https://api-docs.sevalla.com/v3/applications/get-application-deployment-logs) (`getApplicationDeploymentLogsV3`) finds the job's result. `APP_ID` and `DEPLOYMENT_ID` come from [Deploy with the API](#deploy-with-the-api), where the job is named `release`. The messages carry terminal color codes, which `gsub` removes:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s "$API/applications/$APP_ID/deployments/$DEPLOYMENT_ID" -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r .status
curl -s "$API/applications/$APP_ID/deployments/$DEPLOYMENT_ID/logs?search=has%20been%20successful" \
  -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r '.[].message | gsub("\u001b\\[[0-9;]*m"; "")'
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
success
✅ release has been successful
```

**3. Compare the data**

Run the same query against the Fly.io database, through the proxy, and the Sevalla one, through its external connection. Each table's row count and checksum must match. A worker that's already running on Sevalla can change rows the moment the data arrives, so leave out the columns it writes: the example's worker sets `status` and `processed_at` on the orders it processes.

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
QUERY="SELECT 'products', count(*), md5(string_agg(p::text, ',' ORDER BY p.id)) FROM products p
  UNION ALL SELECT 'orders', count(*), md5(string_agg(concat_ws('|', o.id, o.product_id, o.quantity, o.created_at), ',' ORDER BY o.id)) FROM orders o"
PGTZ=UTC psql "postgresql://fly-user:<password>@localhost:16380/fly-db" -At -c "$QUERY"
PGTZ=UTC psql "postgresql://storefront:<password>@<external hostname>:<external port>/storefront" -At -c "$QUERY"
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
products|25|f327097176c7b8b61a14ff3583f6e64b
orders|55|355d2b592a87315c626715cef03ab2f1
products|25|f327097176c7b8b61a14ff3583f6e64b
orders|55|355d2b592a87315c626715cef03ab2f1
```

`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.

**4. Test the application**

Click the domain under the application's name and test the critical paths: sign-in, a write to the database, a background task. On the **Processes** tab, the web process and the background workers show **Running**.

With the API, [Get application runtime logs](https://api-docs.sevalla.com/v3/applications/get-application-runtime-logs) (`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:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s "$API/applications/$APP_ID/runtime-logs?search=reached" -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r '.[0].message'
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
worker: reached http://storefront-x7k2m-web.storefront-x7k2m.svc.cluster.local:8080/healthz: 200 ok
```

CLI: `sevalla apps logs runtime "$APP_ID" --search reached`. MCP: `search_runtime_logs`.

**5. Turn on auto-deploy**

To deploy every push to the branch automatically, as a `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](https://docs.sevalla.com/applications/deployments.md#deploy-automatically-on-push).

## 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.

**1. Archive the volume on Fly.io**

Pack the volume's `destination`, here `/data`, into an archive outside it, and download it:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
fly ssh console --app storefront --process-group app --command "tar -czf /tmp/volume.tar.gz --exclude=lost+found -C /data ."
fly ssh sftp get /tmp/volume.tar.gz volume.tar.gz --app storefront --process-group app
```

`--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.

**2. List the files with their checksums**

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
fly ssh console --app storefront --process-group app --command "sh -c 'cd /data && find . -type f -exec sha256sum {} + | sort -k 2'" > fly-files.txt
```

**3. Upload the archive**

On the application's **Web terminal** tab, wait for the status **Live**: the first tab opens `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](https://docs.sevalla.com/applications/web-terminal.md#copy-files-to-and-from-a-process).

**4. Unpack it**

In the terminal:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
tar -xzf /data/volume.tar.gz -C /data && rm /data/volume.tar.gz
cd /data && find . -type f -exec sha256sum {} + | sort -k 2
```

The list must match `fly-files.txt` line for line.

A script or an AI agent can run the two commands of the last step with [Run a command in a process](https://api-docs.sevalla.com/v3/applications/run-a-command-in-a-process) (`execProcessCommandV3`), and compare the list with `fly-files.txt`. `WEB_ID` comes from [Deploy with the API](#deploy-with-the-api):

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s -X POST "$API/applications/$APP_ID/processes/$WEB_ID/exec" \
  -H "Authorization: Bearer $SEVALLA_API_KEY" -H "Content-Type: application/json" \
  -d '{"command": ["sh", "-c", "cd /data && find . -type f -exec sha256sum {} + | sort -k 2"]}' \
  | jq -j .stdout | diff fly-files.txt - && echo "Files match"
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
Files match
```

CLI: `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](https://docs.sevalla.com/applications/builds/ingot.md), 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 as `engines.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](#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](https://docs.sevalla.com/load-balancers/overview.md) in front:

1. Deploy the application in the first location as this page describes.
2. Copy it to each other location with [Clone application](https://api-docs.sevalla.com/v3/applications/clone-application) (`cloneApplicationV3`) and the location's `cluster_id`, or with `sevalla 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.
3. In each copy, change the variables that point at the first location: `DATABASE_URL` becomes 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.
4. [Create a load balancer](https://docs.sevalla.com/load-balancers/create.md) with **Geo** routing and add each application as an **App** destination. Visitors reach the closest one.
5. Add the custom domains to the load balancer instead of the applications, as in [Move the custom domains](#move-the-custom-domains).

A disk belongs to one application, so files on it aren't shared between locations: keep files that every location serves in [object storage](https://docs.sevalla.com/object-storage/overview.md).

## 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.

**1. Add each domain**

On the application's **Domains** tab, click **Add custom domain**, enter the domain in **Domain name**, for example `shop.example.com`, and click **Add custom domain**. Its **DNS records** sheet opens.

**2. Add the verification records**

At your DNS provider, add the `_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](https://docs.sevalla.com/domains/overview.md#verify-the-domain).

**3. Point it at Sevalla**

Delete the `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](https://docs.sevalla.com/domains/overview.md#point-the-domain).

Lower the TTL of the records that point at Fly.io to a few minutes a day before the switch, so resolvers pick up the change quickly.

## Shut down the Fly.io app

When the domains are **Active** and traffic reaches Sevalla:

1. Disable the external connection you opened: on the database's **Networking** tab, click **Disable** under **External connection**, then click **Disable** in the dialog.
2. Remove the domains from Fly.io with `fly certs remove shop.example.com --app storefront`.
3. Keep `storefront.dump` and `volume.tar.gz` until you no longer need them.
4. Delete the app with `fly apps destroy storefront`, which deletes its Machines and volumes. Delete a Managed Postgres cluster with `fly mpg destroy <cluster id>`, and an unmanaged Fly Postgres app with `fly apps destroy storefront-db`. You can't undo these.

## Troubleshoot

| Symptom | Cause | Fix |
| - | - | - |
| `fly ssh console` fails with `Error: app storefront has no VMs in process group app` or `Error: app storefront has no started VMs.` | The Machine is stopped, for example by `auto_stop_machines`, or the group is scaled to 0 | Start it with `fly machine start <machine id> --app storefront`, then run the command again |
| `pg_restore` reports `ERROR:  extension "pg_stat_monitor" is not available` and `ERROR:  permission denied to create extension "pgaudit"` | The dump includes the extensions that Fly Managed Postgres installs | Dump again with `--exclude-extension=pg_stat_monitor --exclude-extension=pgaudit`. The tables and rows were restored anyway |
| `pg_dump` fails with `unrecognized option '--exclude-extension=pg_stat_monitor'` | The PostgreSQL client tools are older than version 17 | Install version 17 or newer |
| `pg_restore` fails with `server closed the connection unexpectedly` right after you enable the external connection | The external connection isn't stable yet | Wait a minute or two, then run `pg_restore` again with `--clean --if-exists` |
| `fly ssh sftp get` fails with ``file volume.tar.gz is already there. `fly ssh` doesn't override existing files for safety.`` | The archive from an earlier attempt is still in the folder | Delete or rename the local file, then download again |
| Requests to another process fail with `getaddrinfo ENOTFOUND app.process.storefront.internal` (Node.js) | An environment variable or the code still holds a Fly.io `.internal` or `.flycast` address, which doesn't resolve on Sevalla | Replace it with the process's internal hostname and port `8080`, then deploy |
| The build log shows `release has failed` and the deployment fails | The release job exited with an error, for example because `DATABASE_URL` is missing | On the **Logs** tab, choose the `release` process to read its output. Check the internal connection and the variable's key, then deploy |

## Deploy with the API

This sequence does what [Create the database](#create-the-database) to [Deploy and verify](#deploy-and-verify) do in the dashboard, with the [Sevalla API](https://docs.sevalla.com/developer-tools/api.md) and the Dockerfile build strategy. It reads the app's settings with `fly 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](https://docs.sevalla.com/developer-tools/cli.md) command and the [Sevalla MCP server](https://docs.sevalla.com/ai-agents/overview.md) tool that do the same. You need:

* An [API key](https://docs.sevalla.com/developer-tools/api-keys.md) 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 `400` and `Cannot 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, plus `curl`, `jq`, `openssl` and the PostgreSQL client tools.
* `storefront.dump` in the current folder, from [Copy the PostgreSQL data](#copy-the-postgresql-data).

Export the app's settings, its secrets except `DATABASE_URL`, and its `[env]` entries:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
API=https://api.sevalla.com/v3
REPO=https://github.com/acme/storefront
FLY_APP=storefront
fly config show --app "$FLY_APP" > fly-config.json
fly secrets list --app "$FLY_APP" --json | jq -r '.[].name | select(. != "DATABASE_URL")' > secret-names.txt
while IFS= read -r name; do
  jq -nc --arg key "$name" --arg value "$(fly ssh console --app "$FLY_APP" --command "printenv $name" < /dev/null 2> /dev/null)" '{key: $key, value: $value}'
done < secret-names.txt > secrets.jsonl
jq -c '.env // {} | to_entries[] | {key, value}' fly-config.json > env.jsonl
```

`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.

**1. Choose the location**

[List clusters](https://api-docs.sevalla.com/v3/resources/list-clusters) (`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`:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
CLUSTER_ID=$(curl -s "$API/resources/clusters" -H "Authorization: Bearer $SEVALLA_API_KEY" \
  | jq -r '.[] | select(.location == "eu-frankfurt-1") | .id')
```

CLI: `sevalla resources clusters`. MCP: `get_platform_options`.

**2. Look up the sizes**

[List process resource types](https://api-docs.sevalla.com/v3/resources/list-process-resource-types) (`listProcessResourceTypesV3`), [List disk resource types](https://api-docs.sevalla.com/v3/resources/list-disk-resource-types) (`listDiskResourceTypesV3`) and [List database resource types](https://api-docs.sevalla.com/v3/resources/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:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
H1_ID=$(curl -s "$API/resources/process-resource-types" -H "Authorization: Bearer $SEVALLA_API_KEY" \
  | jq -r '.[] | select(.name == "h1") | .id')
DISK_ID=$(curl -s "$API/resources/disk-resource-types" -H "Authorization: Bearer $SEVALLA_API_KEY" \
  | jq -r '.[] | select(.size_gib == 10) | .id')
DB_SIZE_ID=$(curl -s "$API/resources/database-resource-types" -H "Authorization: Bearer $SEVALLA_API_KEY" \
  | jq -r '.[] | select(.name == "db1") | .id')
```

CLI: `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`.

**3. Look up the PostgreSQL version**

[List database versions](https://api-docs.sevalla.com/v3/resources/list-database-versions) (`listDatabaseVersionsV3`) returns the versions the location offers, oldest first. Save the newest:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
PG_VERSION=$(curl -s "$API/resources/database-versions?cluster_id=$CLUSTER_ID" -H "Authorization: Bearer $SEVALLA_API_KEY" \
  | jq -r '.[] | select(.type == "postgresql") | .versions[-1]')
```

CLI: `sevalla resources db-versions --cluster "$CLUSTER_ID"`. MCP: not needed, `create_database` takes the newest version by default.

**4. Create the database**

[Create database](https://api-docs.sevalla.com/v3/databases/create-database) (`createDatabaseV3`). You choose the password, and the API never returns it, so keep it for the connection URL and the restore:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
PG_PASSWORD=$(openssl rand -hex 16)
PG_ID=$(curl -s -X POST "$API/databases" \
  -H "Authorization: Bearer $SEVALLA_API_KEY" -H "Content-Type: application/json" \
  -d "$(jq -n --arg cluster "$CLUSTER_ID" --arg size "$DB_SIZE_ID" --arg version "$PG_VERSION" --arg password "$PG_PASSWORD" \
    '{display_name: "orders-db", type: "postgresql", version: $version, cluster_id: $cluster,
      resource_type_id: $size, db_name: "storefront", db_user: "storefront", db_password: $password}')" \
  | jq -r .id)
```

CLI: `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`.

**5. Create the application**

[Create application](https://api-docs.sevalla.com/v3/applications/create-application) (`createApplicationV3`) creates the application with a web process at **S1**, and doesn't deploy it:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
APP_ID=$(curl -s -X POST "$API/applications" \
  -H "Authorization: Bearer $SEVALLA_API_KEY" -H "Content-Type: application/json" \
  -d "$(jq -n --arg cluster "$CLUSTER_ID" --arg repo "$REPO" \
    '{display_name: "storefront", cluster_id: $cluster, source: "privateGit", git_type: "github",
      repo_url: $repo, default_branch: "main"}')" \
  | jq -r .id)
```

CLI: `sevalla apps create --name storefront --cluster "$CLUSTER_ID" --source privateGit --git-type github --repository "$REPO" --branch main`. MCP: `create_application` with `deploy: false`.

**6. Build from the Dockerfile**

[Update application](https://api-docs.sevalla.com/v3/applications/update-application) (`updateApplicationV3`) switches to the Dockerfile build strategy:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s -X PATCH "$API/applications/$APP_ID" \
  -H "Authorization: Bearer $SEVALLA_API_KEY" -H "Content-Type: application/json" \
  -d '{"build_type": "dockerfile", "dockerfile_path": "Dockerfile", "docker_context": "."}' \
  | jq -c '{build_type, dockerfile_path, docker_context}'
```

```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{"build_type":"dockerfile","dockerfile_path":"Dockerfile","docker_context":"."}
```

CLI: `sevalla apps update "$APP_ID" --data '{"build_type": "dockerfile", "dockerfile_path": "Dockerfile", "docker_context": "."}'`. MCP: `sevalla_api_update` with `updateApplicationV3` and the same body.

**7. Wait for the database and connect it**

[Get database](https://api-docs.sevalla.com/v3/databases/get-database) (`getDatabaseV3`) shows `status` `ready` when the database is up, with its internal hostname and port. Then [Create internal connection](https://api-docs.sevalla.com/v3/applications/create-internal-connection) (`createApplicationInternalConnectionV3`):

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
for i in $(seq 1 60); do
  [ "$(curl -s "$API/databases/$PG_ID" -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r '.status // empty')" = ready ] && break
  sleep 10
done
PG=$(curl -s "$API/databases/$PG_ID" -H "Authorization: Bearer $SEVALLA_API_KEY")
curl -s -X POST "$API/applications/$APP_ID/internal-connections" \
  -H "Authorization: Bearer $SEVALLA_API_KEY" -H "Content-Type: application/json" \
  -d "$(jq -n --arg db "$PG_ID" '{target_id: $db, target_type: "database"}')" | jq -r .id
```

The API adds no environment variables; the next steps add `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.

**8. Replace the Fly.io addresses**

[List processes](https://api-docs.sevalla.com/v3/applications/list-processes) (`listProcessesV3`) returns the web process with its `internal_hostname`. Print it, and the entries that still point at Fly.io:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
WEB=$(curl -s "$API/applications/$APP_ID/processes" -H "Authorization: Bearer $SEVALLA_API_KEY" | jq '.data[] | select(.type == "web")')
WEB_ID=$(echo "$WEB" | jq -r .id)
echo "$WEB" | jq -r .internal_hostname
grep -E '\.internal|\.flycast|\.fly\.dev' env.jsonl secrets.jsonl
```

In `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`:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
jq -c --arg url "http://$(echo "$WEB" | jq -r .internal_hostname):8080" \
  'if .key == "APP_INTERNAL_URL" then .value = $url else . end' env.jsonl > env.tmp && mv env.tmp env.jsonl
```

CLI: `sevalla apps processes list --app-id "$APP_ID"`. MCP: `sevalla_api_read` with `listProcessesV3`.

**9. Add the environment variables**

[Create environment variable](https://api-docs.sevalla.com/v3/applications/create-environment-variable) (`createEnvironmentVariableV3`), once per variable, available during runtime only: `DATABASE_URL` in the format the internal connection uses, then every secret and `[env]` entry:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{
  jq -n --arg host "$(echo "$PG" | jq -r .internal_hostname)" --arg port "$(echo "$PG" | jq -r .internal_port)" --arg password "$PG_PASSWORD" \
    '{key: "DATABASE_URL", value: "postgres://storefront:\($password)@\($host):\($port)/storefront"}'
  cat secrets.jsonl env.jsonl
} | jq -c '. + {is_runtime: true, is_buildtime: false}' | while IFS= read -r body; do
  curl -s -X POST "$API/applications/$APP_ID/env-vars" \
    -H "Authorization: Bearer $SEVALLA_API_KEY" -H "Content-Type: application/json" -d "$body" | jq -r .key
done
```

It prints each key as it's added. The CLI's `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.

**10. Set up the web process**

[Update process](https://api-docs.sevalla.com/v3/applications/update-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:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
WEB_GROUP=$(jq -r '.http_service.processes[0] // "app"' fly-config.json)
curl -s -X PATCH "$API/applications/$APP_ID/processes/$WEB_ID" \
  -H "Authorization: Bearer $SEVALLA_API_KEY" -H "Content-Type: application/json" \
  -d "$(jq -n --arg command "exec $(jq -r --arg group "$WEB_GROUP" '.processes[$group]' fly-config.json)" \
    '{entrypoint: $command, readiness_probe: {httpGet: {path: "/healthz", port: 8080, scheme: "HTTP"},
      initialDelaySeconds: 10, periodSeconds: 30, timeoutSeconds: 5, successThreshold: 1, failureThreshold: 3}}')" \
  | jq -c '{entrypoint, readiness_probe}'
```

```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{"entrypoint":"exec node server.js","readiness_probe":{"httpGet":{"path":"/healthz","port":8080,"scheme":"HTTP"},"initialDelaySeconds":10,"periodSeconds":30,"timeoutSeconds":5,"successThreshold":1,"failureThreshold":3}}
```

CLI: `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.

**11. Create the background workers and the job**

[Create process](https://api-docs.sevalla.com/v3/applications/create-process) (`createProcessV3`): a background worker for every other process group, its command after `exec`, then a job with the start policy `beforeDeployment` for `release_command`:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
jq -r --arg web "$WEB_GROUP" '.processes | to_entries[] | select(.key != $web) | "\(.key)\t\(.value)"' fly-config.json \
  | while IFS="$(printf '\t')" read -r name command; do
    curl -s -X POST "$API/applications/$APP_ID/processes" \
      -H "Authorization: Bearer $SEVALLA_API_KEY" -H "Content-Type: application/json" \
      -d "$(jq -n --arg name "$name" --arg command "exec $command" --arg size "$H1_ID" \
        '{display_name: $name, type: "worker", entrypoint: $command,
          resource_type_id: $size, scaling_strategy: {type: "manual", config: {instanceCount: 1}}}')" \
      | jq -c '{display_name, type, entrypoint}'
  done
curl -s -X POST "$API/applications/$APP_ID/processes" \
  -H "Authorization: Bearer $SEVALLA_API_KEY" -H "Content-Type: application/json" \
  -d "$(jq -n --arg command "$(jq -r .deploy.release_command fly-config.json)" --arg size "$H1_ID" \
    '{display_name: "release", type: "job", entrypoint: $command, job_start_policy: "beforeDeployment",
      resource_type_id: $size, scaling_strategy: {type: "manual", config: {instanceCount: 1}}}')" \
  | jq -c '{display_name, type, entrypoint}'
```

```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{"display_name":"worker","type":"worker","entrypoint":"exec node worker.js"}
{"display_name":"release","type":"job","entrypoint":"node migrate.js"}
```

Skip the job if `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.

**12. Attach the disk**

[Attach or update process disk](https://api-docs.sevalla.com/v3/applications/attach-or-update-process-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:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s -X PUT "$API/applications/$APP_ID/processes/$WEB_ID/disk" \
  -H "Authorization: Bearer $SEVALLA_API_KEY" -H "Content-Type: application/json" \
  -d "$(jq -n --arg size "$DISK_ID" --arg path "$(jq -r '.mounts[0].destination' fly-config.json)" \
    '{resource_type_id: $size, mount_path: $path}')" \
  | jq -c '{size_gib, mount_path}'
```

```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{"size_gib":10,"mount_path":"/data"}
```

CLI: `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.

**13. Restore the data**

[Toggle external connection](https://api-docs.sevalla.com/v3/databases/toggle-external-connection) (`toggleDatabaseExternalConnectionV3`) opens the database to your computer. Wait until [Get database](https://api-docs.sevalla.com/v3/databases/get-database) returns its `external_hostname`, then restore the dump from [Copy the PostgreSQL data](#copy-the-postgresql-data):

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s -X POST "$API/databases/$PG_ID/external-connection/toggle" -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r .message
for i in $(seq 1 60); do
  PG=$(curl -s "$API/databases/$PG_ID" -H "Authorization: Bearer $SEVALLA_API_KEY")
  [ "$(echo "$PG" | jq -r '.status // empty')" = ready ] && [ -n "$(echo "$PG" | jq -r '.external_hostname // empty')" ] && break
  sleep 10
done
export PGHOST="$(echo "$PG" | jq -r .external_hostname)" PGPORT="$(echo "$PG" | jq -r .external_port)" PGPASSWORD="$PG_PASSWORD"
for i in $(seq 1 30); do
  pg_restore --username=storefront --dbname=storefront \
    --no-owner --no-privileges --clean --if-exists --jobs=4 \
    storefront.dump && break
  sleep 10
done
```

The first command prints `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`.

**14. Deploy**

[Trigger deployment](https://api-docs.sevalla.com/v3/applications/trigger-deployment) (`triggerDeploymentV3`) deploys the latest commit of the branch:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
DEPLOYMENT_ID=$(curl -s -X POST "$API/applications/$APP_ID/deployments" \
  -H "Authorization: Bearer $SEVALLA_API_KEY" -H "Content-Type: application/json" -d '{}' | jq -r .id)
```

CLI: `sevalla apps deployments trigger "$APP_ID"`. MCP: `trigger_deployment`.

**15. Wait for the deployment**

[Get deployment](https://api-docs.sevalla.com/v3/applications/get-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:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
for i in $(seq 1 120); do
  STATUS=$(curl -s "$API/applications/$APP_ID/deployments/$DEPLOYMENT_ID" \
    -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r '.status // empty')
  case $STATUS in success|failed|cancelled|skipped) break ;; esac
  sleep 15
done
echo "$STATUS"
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
success
```

CLI: `sevalla apps deployments get "$DEPLOYMENT_ID" --app-id "$APP_ID"`. MCP: `get_deployment`.

**16. Turn on auto-deploy**

[Update application](https://api-docs.sevalla.com/v3/applications/update-application) (`updateApplicationV3`):

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s -X PATCH "$API/applications/$APP_ID" \
  -H "Authorization: Bearer $SEVALLA_API_KEY" -H "Content-Type: application/json" \
  -d '{"auto_deploy": true}' | jq -r .auto_deploy
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
true
```

CLI: `sevalla apps update "$APP_ID" --data '{"auto_deploy": true}'`. MCP: `sevalla_api_update` with `updateApplicationV3` and the same body.

Then copy the files as in [Copy the volume files](#copy-the-volume-files) and run the checks in [Deploy and verify](#deploy-and-verify). To build with Ingot instead of the Dockerfile, update the application with `{"build_type": "ingot"}` and deploy again. [Add application domain](https://api-docs.sevalla.com/v3/applications/add-application-domain) (`addApplicationDomainV3`) adds each custom domain; [Get application domain](https://api-docs.sevalla.com/v3/applications/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`.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.