> ## 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 DigitalOcean App Platform

> Move a DigitalOcean App Platform app to Sevalla: map its app spec's services, workers, jobs, static sites, bindable variables and routes, copy the managed PostgreSQL data, then move the domains.

A DigitalOcean App Platform app moves to Sevalla as an application with one process per service, worker and job component, a static site for each static site component, and a Sevalla database in place of each managed database. You read the app spec with `doctl`, recreate each component while the App Platform 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 |
| Build | [Ingot](https://docs.sevalla.com/applications/builds/ingot.md), or the app's own [Dockerfile](https://docs.sevalla.com/applications/builds/dockerfile.md) |
| Start command | What Ingot detects, such as the `start` script in `package.json`, or the service's `run_command` |
| Port | The `PORT` environment variable, `8080` by default, like the service's `http_port` |
| Processes | Web process for the service; a **Background worker** per worker, its `run_command` after `exec`; a **Job** with the start policy **Before deployment** per `PRE_DEPLOY` job and **After successful deployment** per `POST_DEPLOY` job; a **Cron job** per `SCHEDULED` job |
| Services | A PostgreSQL database for each managed PostgreSQL database, over an internal connection; a [static site](https://docs.sevalla.com/static-sites/overview.md) for each static site component |
| Disk | None |
| Health check | The service's `health_check` 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 a managed PostgreSQL database | Build and runtime | Yes | `DB_URL` from the internal connection to `orders-db`, renamed, in place of `${db.DATABASE_URL}`: `postgres://<user>:<password>@<host>:<port>/<database>` |
| The app's plain variables | As on App Platform | As their `scope`: Runtime for `RUN_TIME`, Build and runtime for `RUN_AND_BUILD_TIME` | No | From the app spec's app-level and component-level `envs` |
| The app's encrypted variables | As on App Platform | As their `scope` | Yes | Read with `doctl apps console`, because the app spec holds them encrypted |
| Variables with other bindable values | As on App Platform | As their `scope` | No | See [Replace the bindable variables](#replace-the-bindable-variables) |
| `PORT` | No | Runtime | No | Set by Sevalla to the web process port. Don't add it |

## How DigitalOcean App Platform maps to Sevalla

| App Platform | Sevalla | Notes |
| - | - | - |
| App | [Application](https://docs.sevalla.com/applications/overview.md) for each service, plus a [static site](https://docs.sevalla.com/static-sites/overview.md) for each static site component | An application has one web process. A second service becomes a second application, connected with an [internal connection](https://docs.sevalla.com/applications/networking.md#internal-connections) |
| App spec (`doctl apps spec get`) | The dashboard, the [Sevalla API](https://docs.sevalla.com/developer-tools/api.md) and the [CLI](https://docs.sevalla.com/developer-tools/cli.md) | Sevalla has no spec file. [Deploy with the API](#deploy-with-the-api) turns the spec into API calls |
| Service component | [Web process](https://docs.sevalla.com/applications/processes.md#web-process) | Listens on `PORT`, `8080` by default |
| `run_command` | **Custom start command** of the process | Start long-running commands with `exec` |
| `source_dir` | **Build path** of an application's build strategy, **Root directory** of a static site | |
| Worker component | [Background worker](https://docs.sevalla.com/applications/processes.md#background-workers) | |
| Job with `kind: PRE_DEPLOY` | [Job](https://docs.sevalla.com/applications/processes.md#jobs) with the start policy **Before deployment** | If it fails, the deployment fails and the previous version keeps running |
| Job with `kind: POST_DEPLOY` | Job with the start policy **After successful deployment** | |
| Job with `kind: FAILED_DEPLOY` | Job with the start policy **After failed deployment** | |
| Job with `kind: SCHEDULED` | [Cron job](https://docs.sevalla.com/applications/processes.md#cron-jobs) | Any cron schedule down to every minute, in UTC or the time zone you set |
| Static site component | [Static site](https://docs.sevalla.com/static-sites/overview.md) | Its own `sevalla.page` domain. `source_dir` is the **Root directory**, `output_dir` the **Publish directory** |
| Functions component | No equivalent | Serve the functions' routes from an application |
| `health_check` | [Readiness probe](https://docs.sevalla.com/applications/processes.md#health-checks) | HTTP checks only. The default failure threshold is 9 on App Platform and 3 on Sevalla |
| `liveness_health_check` | Liveness probe | |
| `instance_size_slug`, `instance_count`, `autoscaling` | [Process sizes, instances and horizontal autoscaling](https://docs.sevalla.com/applications/scaling.md) | Set per web process and background worker |
| App-level and component-level `envs` | [Environment variables](https://docs.sevalla.com/applications/environment-variables.md) | One set per application, shared by all its processes |
| `type: SECRET` | Environment variables | Sevalla stores every value encrypted |
| Bindable variables (`${db.DATABASE_URL}`, `${APP_URL}`) | Variables from internal connections, [variables Sevalla sets](https://docs.sevalla.com/applications/environment-variables.md#variables-sevalla-sets) and `${KEY}` references | See [Replace the bindable variables](#replace-the-bindable-variables) |
| Managed PostgreSQL | [PostgreSQL database](https://docs.sevalla.com/databases/overview.md) | |
| Managed MySQL | MySQL database | |
| Managed Valkey | Valkey database | |
| Managed MongoDB, Kafka, OpenSearch | No equivalent | Keep them on DigitalOcean and add the application's [egress IP address](https://docs.sevalla.com/applications/networking.md#egress-ip-addresses) to their trusted sources |
| Trusted sources | Private internal connections; [IP restrictions](https://docs.sevalla.com/databases/connect.md#restrict-access-by-ip-address) on a database's external connection | |
| Spaces | [Object storage](https://docs.sevalla.com/object-storage/overview.md) | S3-compatible buckets |
| `ingress` rules that route paths to components | No equivalent | Each application and static site has its own domains. See [Replace the ingress rules](#replace-the-ingress-rules) |
| `ingress` rules with `redirect` | A [`_redirects` file](https://docs.sevalla.com/static-sites/redirects) on a static site, or redirects in the application | |
| `ingress` rules with `cors` | A [`_headers` file](https://docs.sevalla.com/static-sites/headers) on a static site, or CORS headers set by the application | |
| `domains`, the `ondigitalocean.app` starter domain | [Custom domains](https://docs.sevalla.com/domains/overview.md), the `sevalla.app` and `sevalla.page` domains | Sevalla issues and renews the SSL certificates |
| `deploy_on_push` | [Auto-deploy](https://docs.sevalla.com/applications/deployments.md#deploy-automatically-on-push) | |
| Maintenance mode | [Maintenance mode](https://docs.sevalla.com/applications/maintenance-mode.md) | |
| `doctl apps console` | [Web terminal](https://docs.sevalla.com/applications/web-terminal.md) | |
| `doctl apps logs` | [Logs](https://docs.sevalla.com/applications/logs.md) | |
| Insights | [Analytics](https://docs.sevalla.com/applications/analytics.md) | |
| Dedicated egress IPs | [Egress IP addresses](https://docs.sevalla.com/applications/networking.md#egress-ip-addresses) | One fixed address per OCI location |

## Before you begin

* `doctl`, signed in with `doctl auth init` to the team that owns the app, with a token that can read and update apps and databases.
* The app's code in a repository on GitHub, GitLab or Bitbucket. See [Git providers](https://docs.sevalla.com/git/overview.md).
* A Sevalla company with a payment method.
* The PostgreSQL client tools, at the same major version as the managed database or newer, and `jq`. See [Import and export PostgreSQL](https://docs.sevalla.com/databases/import-export/postgresql.md#before-you-begin).
* A maintenance window: the App Platform app is offline while you copy the data.

## Take stock of the App Platform app

The app spec holds every setting you recreate. Find the app's ID, then save its spec and list the database clusters:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
doctl apps list --format ID,Spec.Name,DefaultIngress
DO_APP_ID='<the app's ID>'
doctl apps spec get "$DO_APP_ID" --format json > app.json
doctl databases list --format ID,Name,Engine,Version,Region,Size
```

`app.json` contains the encrypted values of secret variables and your settings: don't commit it. List the components to recreate:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
jq -r 'to_entries[] | select(.key | IN("services", "workers", "jobs", "static_sites", "databases")) | .key as $type | .value[] | [$type, .name, (.kind // .engine // "-") + (if .schedule then " " + .schedule.cron else "" end), (.run_command // .build_command // .cluster_name)] | @tsv' app.json
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
services	api	-	npm start
static_sites	web	-	npm run build
workers	worker	-	node worker.js
jobs	migrate	PRE_DEPLOY	node migrate.js
jobs	post-deploy	POST_DEPLOY	node post-deploy.js
jobs	cleanup	SCHEDULED */15 * * * *	node cleanup.js
databases	db	PG	m6-orders-db
```

The example app has a service, a static site, a worker, three jobs and a managed PostgreSQL database. Every component becomes a process or a service on Sevalla:

* The spec's `region` is where the app runs. Create the Sevalla services in the closest [location](https://docs.sevalla.com/platform/locations.md):

  | App Platform region | Closest Sevalla location |
  | - | - |
  | `nyc`, `atl`, `ric` | Ashburn, USA |
  | `tor` | Montréal, Canada |
  | `mkc` | Chicago, USA |
  | `sfo` | San Jose, USA |
  | `ams` | Amsterdam, Netherlands |
  | `fra` | Frankfurt, Germany |
  | `lon` | London, UK |
  | `blr` | Hyderabad, India |
  | `sgp` | Singapore |
  | `syd` | Sydney, Australia |

* A database with `production: true` is a managed database cluster. Its `cluster_name` is the **Name** in `doctl databases list`, and **Version** is the PostgreSQL version: create the Sevalla database with the same version or a newer one.

* `domains` lists the custom domains to move. The starter domain on `ondigitalocean.app` isn't listed and doesn't move: Sevalla gives the application a `sevalla.app` domain and the static site a `sevalla.page` domain.

List the variables that don't have a plain value: the encrypted ones and the ones that use bindable variables.

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
jq -r '([{name: "app", envs: .envs}] + .services + .workers + .jobs + .static_sites)[] | .name as $c | .envs[]? | select(.type == "SECRET" or (.value | contains("${"))) | [$c, .key, (if .type == "SECRET" then "(encrypted)" else .value end)] | @tsv' app.json
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
app	PUBLIC_URL	${APP_URL}
app	DATABASE_URL	${db.DATABASE_URL}
app	DATABASE_CA_CERT	${db.CA_CERT}
api	CORS_ORIGIN	${APP_URL}
api	SESSION_SECRET	(encrypted)
migrate	COMMIT_SHA	${_self.COMMIT_HASH}
post-deploy	COMMIT_SHA	${_self.COMMIT_HASH}
web	API_URL	${APP_URL}/api
```

The spec holds encrypted values as `EV[1:...]`, which only App Platform can decrypt. Read each one in a console session of the component that has it, here the `api` service, and save it in a file named `secrets.env` as `KEY=value`, one per line:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
doctl apps console "$DO_APP_ID" api
```

In the console, run `printenv SESSION_SECRET`, then `exit`. `secrets.env` contains secrets: don't commit it either.

## Create the database

In the sidebar, click **Databases**, then click **Create database**. Under **Database**, choose **PostgreSQL** and a version at least as new as the managed database's. Enter `orders-db` as the **Display name**, choose the **Data center** closest to the app's region and a **Size** with room for the data, then click **Create database**. The database's status changes to **Ready** when it's up. 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** of the service. 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. Check the build**

Sevalla builds with [Ingot](https://docs.sevalla.com/applications/builds/ingot.md), which detects the language and the start command without configuration, as App Platform's buildpacks do. If the service has a `source_dir`, click the **Settings** tab, click **Update build strategy** under **Build strategy**, set the **Build path** to that folder, and click **Update build strategy**. If the service builds from a `dockerfile_path`, choose **Dockerfile** there instead and enter the path. See [Build strategies](https://docs.sevalla.com/applications/builds/overview.md).

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

## 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 your code already reads, and click **Add internal connection**. See [Connect an application](https://docs.sevalla.com/databases/connect.md#connect-an-application).

The URL starts with `postgres://` and has no `sslmode`: the internal connection stays on Sevalla's private network. If your code connects with TLS and the managed database's CA certificate (`${db.CA_CERT}`), make it connect without them when that variable isn't set, as the example app does: DigitalOcean's certificate doesn't validate a Sevalla database.

## Move the environment variables

App Platform has app-level variables, which every component gets, and component-level variables, which win over them. On Sevalla, every process of the application shares the application's variables, so the app-level and the component-level variables of the service, the workers and the jobs all go to the application. If two components set the same key to different values, set the value in the process's start command instead, for example `WORKER_INTERVAL_SECONDS=5 exec node worker.js`. A static site's variables go to the [static site](#create-the-static-site).

**1. Export the plain variables**

Write the variables with plain values into one file per `scope`:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
for scope in RUN_TIME BUILD_TIME RUN_AND_BUILD_TIME; do
  jq -r --arg scope "$scope" '[.envs[]?, ((.services + .workers + .jobs)[] | .envs[]?)]
    | map(select((.scope // "RUN_AND_BUILD_TIME") == $scope and .type != "SECRET" and (.value | contains("${") | not)))
    | unique_by(.key)[] | "\(.key)=\(.value)"' app.json > "$scope.env"
done
```

**2. Import them**

On the application, click the **Environment variables** tab, then click **Add environment variables**. Click **Import .env** and choose `RUN_AND_BUILD_TIME.env`, keep **Available during runtime** and **Available during buildtime** selected, and click **Add environment variables**. Import `RUN_TIME.env` the same way with only **Available during runtime** selected, and `BUILD_TIME.env` with only **Available during buildtime**. Skip empty files.

**3. Add the secrets**

Import `secrets.env` with the availability of each secret's `scope`.

**4. Replace the bindable variables**

Add each variable that used a bindable variable with its Sevalla value from [Replace the bindable variables](#replace-the-bindable-variables). `DATABASE_URL` is already there, from the internal connection.

![The Add environment variables sheet with key and value rows, Add another, Import .env and the availability options](https://assets.sevalla.com/screenshots/light/app-environment-variables-add.png)

Don't add `PORT`: Sevalla sets it to the web process's port. See [Environment variables](https://docs.sevalla.com/applications/environment-variables.md).

### Replace the bindable variables

App Platform fills in bindable variables (`${...}`) when it deploys. Sevalla has no bindable variables with these names, so each one becomes a variable from the internal connection, a [variable Sevalla sets](https://docs.sevalla.com/applications/environment-variables.md#variables-sevalla-sets), or a value you type:

| App Platform | Sevalla |
| - | - |
| `${db.DATABASE_URL}`, `${db.DATABASE_PRIVATE_URL}` | `DB_URL` from the internal connection, renamed to the key the app uses |
| `${db.HOSTNAME}`, `${db.PORT}`, `${db.USERNAME}`, `${db.PASSWORD}`, `${db.DATABASE}` | `DB_HOST`, `DB_PORT`, `DB_USERNAME`, `DB_PASSWORD`, `DB_DATABASE` from the internal connection |
| `${db.CA_CERT}` | None: delete the variable. It's DigitalOcean's certificate, and the internal connection URL has no TLS settings |
| `${db.REDIS_URL}` | `REDIS_URL` from an internal connection to a Valkey or Redis database |
| `${APP_URL}`, `${_self.PUBLIC_URL}` | The application's or static site's URL, such as `https://storefront-x7k2m.sevalla.app`, typed as the value. Use the custom domain once you've moved it |
| `${APP_DOMAIN}` | The domain without `https://` |
| `${APP_ID}` | `SVL_APP_ID`, set by Sevalla |
| `${_self.COMMIT_HASH}` | `SVL_DEPLOYMENT_COMMIT_SHA`, set by Sevalla. Read it in your code: a value of `${SVL_DEPLOYMENT_COMMIT_SHA}` stays as typed, because references resolve only to the application's and the company's own variables |
| `${<service>.PRIVATE_URL}`, `${<service>.PRIVATE_DOMAIN}` | `APP_HOST` from an internal connection to that service's application, with its port |

For the example app, `PUBLIC_URL` is the application's URL and `CORS_ORIGIN` is the static site's URL. `DATABASE_CA_CERT` and `COMMIT_SHA` go away: the jobs read `SVL_DEPLOYMENT_COMMIT_SHA` when `COMMIT_SHA` isn't set. On the static site, `API_URL` becomes the application's URL followed by `/api`.

## Recreate the components

| App Platform component | Sevalla process | What to set |
| - | - | - |
| Service `api` with `run_command: npm start` | The existing **Web** process | Nothing when Ingot detects the same command: its build plan shows it in the **Start** row of the build log. Otherwise, the `run_command` after `exec` as the **Custom start command** |
| Worker `worker` with `run_command: node worker.js` | A **Background worker** for each worker | The `run_command` after `exec` as the **Custom start command**: `exec node worker.js`. `exec` makes the command the instance's main process, so it gets `SIGTERM` when the instance stops |
| Job with `kind: PRE_DEPLOY` | A **Job** with the **Start policy** **Before deployment** | The `run_command` as the **Custom start command** |
| Job with `kind: POST_DEPLOY` | A **Job** with the **Start policy** **After successful deployment** | The `run_command` as the **Custom start command** |
| Job with `kind: FAILED_DEPLOY` | A **Job** with the **Start policy** **After failed deployment** | The `run_command` as the **Custom start command** |
| Job with `kind: SCHEDULED` | A **Cron job** | The `run_command` as the **Custom start command**, `schedule.cron` as the **Schedule**, and `schedule.time_zone` with **Set custom time zone** unless it's UTC |

**1. Create the processes**

On the **Processes** tab, click **Create process** for each worker and job. Choose **Background worker**, **Cron job** or **Job**, enter the component's name as the **Name** and the **Custom start command**, set the **Start policy** (jobs) or the **Schedule** (cron jobs), choose a **Resource**, and click **Create process**.

**2. Add the health check**

If the service has a `health_check` with an `http_path`, click **⋯** on the **Web** process, then click **Update process**. Under **Health checks**, turn on **Readiness probe**, set the **Path** to the `http_path`, and copy any timings the spec sets. App Platform marks an instance unhealthy after 9 failed checks by default, Sevalla after 3. Set a `liveness_health_check` as the **Liveness probe** the same way, then click **Update process**.

A service without `http_path` has a TCP health check on App Platform. Sevalla's probes are HTTP only: add a path that answers `200`, or leave the probes off. Choose each process's size and instance count from the spec's `instance_size_slug` and `instance_count`, with at least as much memory: **S1** has 1 GB, against 512 MB for `apps-s-1vcpu-0.5gb`. See [Processes](https://docs.sevalla.com/applications/processes.md) and [Scaling](https://docs.sevalla.com/applications/scaling.md).

## Create the static site

**1. Open the create form**

In the sidebar, click **Static sites**, then click **Create static site**. Under **Source**, click **Private repository** and choose the **Git provider**, the **Repository** and the **Branch** of the static site component.

**2. Copy the build settings**

Under **Build settings**, set the **Build command** to the component's `build_command`, the **Root directory** to its `source_dir`, and the **Publish directory** to its `output_dir`. App Platform searches `_static`, `dist`, `public` and `build` when `output_dir` isn't set: enter the folder your build writes.

**3. Add its variables**

Under **Environment variables**, add the component's `envs`, with bindable values replaced. For the example app, `API_URL` is the application's URL followed by `/api`.

**4. Create it**

Click **Create static site**. The first deployment starts at once. See [Create a static site](https://docs.sevalla.com/static-sites/create.md).

## Replace the ingress rules

App Platform serves every component of an app on the same domains and routes requests by path, with `ingress` rules such as `/api` to the service and `/` to the static site. On Sevalla, each application and each static site has domains of its own, and no setting routes paths between them. [Load balancers](https://docs.sevalla.com/load-balancers/overview.md) route by location, not by path, and a static site's [proxy rules](https://docs.sevalla.com/static-sites/redirects.md#proxy-another-site) cache responses, so they don't fit an API either.

* **Give each service its own domain.** For example, keep `shop.example.com` for the static site and move the service to `api.example.com`. Point the static site's API address (`API_URL` in the example app) at the application's domain, and let the application send CORS headers for the static site's domain (`CORS_ORIGIN` in the example app).
* **Keep the service's paths.** A rule with `preserve_path_prefix: true` passes `/api/products` to the service as it is: the service then answers on `https://api.example.com/api/products`. Without it, App Platform strips the prefix, and the service answers on `https://api.example.com/products`.
* **Or serve everything from the application.** If the static files can be built into the application, serve them from the web process and keep one domain.
* **Redirect rules** become a [`_redirects` file](https://docs.sevalla.com/static-sites/redirects) on the static site, or redirects in the application. **CORS rules** become a [`_headers` file](https://docs.sevalla.com/static-sites/headers) on the static site, or headers that the application sets.

## Copy the PostgreSQL data

**1. Stop writes on App Platform**

Archive the app: App Platform stops every component, including workers and scheduled jobs, and answers every request with an offline page and status `503`. Archiving is an update of the spec, with `maintenance.archive`:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
jq '.maintenance.archive = true' app.json > app-archived.json
doctl apps update "$DO_APP_ID" --spec app-archived.json --wait
```

To bring the app back, set `archive` to `false` the same way. App Platform then deploys it again and runs its `POST_DEPLOY` jobs.

**2. Allow your computer in the trusted sources**

A database cluster with trusted sources accepts connections only from them, such as the app. Add your IP address:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
DO_DB_ID='<the database cluster's ID from doctl databases list>'
doctl databases firewalls append "$DO_DB_ID" --rule ip_addr:$(curl -s https://api.ipify.org)
```

Without it, `pg_dump` waits and then fails with `Operation timed out`.

**3. Dump the database**

`doctl databases connection` returns the cluster's connection URL for the `defaultdb` database and the `doadmin` user. `--show` puts the real password in it: without it, `doctl` prints `********`. If the spec's database has a `db_name`, put that name in place of `defaultdb`:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
DO_DB_URL=$(doctl databases connection "$DO_DB_ID" --format URI --no-header --show)
pg_dump "$DO_DB_URL" --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**

With the external connection's details in the URL:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
SEVALLA_DB_URL="postgres://<user>:<password>@<external hostname>:<external port>/<database>"
pg_restore --dbname="$SEVALLA_DB_URL" --no-owner --no-privileges storefront.dump
```

See [Import a database](https://docs.sevalla.com/databases/import-export/postgresql.md#import-a-database) for large databases and errors.

**6. Compare the data**

Count the rows and take a checksum of every table on both sides, and compare the results:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
compare() {
  for table in $(psql "$1" -Atc "SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY 1"); do
    psql "$1" -Atc "SELECT '$table', count(*), md5(coalesce(string_agg(t::text, ',' ORDER BY t::text COLLATE \"C\"), '')) FROM $table t"
  done
}
compare "$DO_DB_URL" > digitalocean.txt
compare "$SEVALLA_DB_URL" > sevalla.txt
diff digitalocean.txt sevalla.txt && echo "Data matches"
```

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

`sevalla.txt` lists each table with its row count and checksum, for example `orders|1000|ed8a6c61bbd5d6e738497c271125ce7b`.

A Sevalla PostgreSQL database sorts text with the `C` collation, where a DigitalOcean managed database uses `en_US.UTF-8`: an `ORDER BY` on a text column can return rows in another order, which is why the comparison sorts with `COLLATE "C"`. If your app depends on that order, test those queries on Sevalla before the cutover.

To rehearse, run these steps once without archiving the app, deploy, and test on the `sevalla.app` domain. For the real cutover, empty the database first, for example with `pg_restore`'s `--clean --if-exists` options, so the restore doesn't collide with the rehearsal's tables.

## 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 each job's result, such as `migrate 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 jobs' results. `APP_ID` and `DEPLOYMENT_ID` come from [Deploy with the API](#deploy-with-the-api):

```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
✅ migrate has been successful
✅ post-deploy has been successful
```

**3. Test the application**

Click the domain under the application's name and test the critical paths. On the **Processes** tab, the web process and the background workers show **Running**. The example app answers with the copied data:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s https://storefront-x7k2m.sevalla.app/api/products
```

```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
[{"id":1,"name":"Coffee mug","price_cents":1200},{"id":2,"name":"Tea towel","price_cents":900},{"id":3,"name":"Notebook","price_cents":650},{"id":4,"name":"Tote bag","price_cents":1800},{"id":5,"name":"Poster","price_cents":2500}]
```

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, for example the example worker's start line:

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

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
Worker ready, writing a heartbeat every 10s
```

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

**4. Test the static site**

Open the static site's `sevalla.page` domain. The example site's build wrote the application's URL into `config.json`:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s https://storefront-web-k3x9p.sevalla.page/config.json
```

```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{"apiUrl":"https://storefront-x7k2m.sevalla.app/api"}
```

**5. Check the cron jobs**

On the **Processes** tab, click **⋯** on a cron job, click **Run now**, then click **Run now** in the dialog. Its status changes to **Succeeded**. See [Check cron runs](https://docs.sevalla.com/applications/processes.md#check-cron-runs).

With the API, [Trigger cron job](https://api-docs.sevalla.com/v3/applications/trigger-cron-job) (`triggerProcessCronV3`) starts a run and [List cron runs](https://api-docs.sevalla.com/v3/applications/list-cron-runs) (`listProcessCronRunsV3`) returns its status, newest first:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
CRON_ID=$(curl -s "$API/applications/$APP_ID/processes" -H "Authorization: Bearer $SEVALLA_API_KEY" \
  | jq -r '.data[] | select(.type == "cron") | .id')
curl -s -X POST "$API/applications/$APP_ID/processes/$CRON_ID/trigger" \
  -H "Authorization: Bearer $SEVALLA_API_KEY" -H "Content-Type: application/json" -d '{}' | jq -r .message
for i in $(seq 1 30); do
  RUN=$(curl -s "$API/applications/$APP_ID/processes/$CRON_ID/runs" -H "Authorization: Bearer $SEVALLA_API_KEY" \
    | jq -r '.data[0].status // empty')
  case $RUN in succeeded|failed) break ;; esac
  sleep 10
done
echo "$RUN"
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
Cron job triggered
succeeded
```

CLI: `sevalla apps processes trigger "$CRON_ID" --app-id "$APP_ID"`, then `sevalla apps processes runs --app-id "$APP_ID" --process-id "$CRON_ID"`. MCP: `sevalla_api_create` with `triggerProcessCronV3`, then `sevalla_api_read` with `listProcessCronRunsV3`.

**6. Turn on auto-deploy**

To deploy every push to the branch automatically, as `deploy_on_push` did, click the **Settings** tab, then click **Update auto-deploy** in the **Auto-deploy** section. Turn on **Auto-deploy** and click **Update auto-deploy**. The static site has **Auto-deploy** on from the start. See [Deployments](https://docs.sevalla.com/applications/deployments.md#deploy-automatically-on-push).

## Move the custom domains

The app's `domains` serve every component on App Platform. On Sevalla, add each domain to the service that answers it, for example `shop.example.com` to the static site and `api.example.com` to the application (see [Replace the ingress rules](#replace-the-ingress-rules)). App Platform keeps serving each domain until you change its pointing record, so the switch has no downtime:

**1. Add each domain**

On the application's or the static site's **Domains** tab, click **Add custom domain**, enter the domain in **Domain name**, and click **Add custom domain**.

**2. Verify it**

Add the ownership and certificate records that Sevalla shows at your DNS provider, next to the record that points at App Platform. 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**

Replace the record that points at App Platform (a `CNAME` to the app's `ondigitalocean.app` domain, or `A` records to App Platform's ingress IP addresses) with the pointing record that Sevalla shows. The status changes to **Active** when Sevalla sees it. See [Point the domain](https://docs.sevalla.com/domains/overview.md#point-the-domain).

**4. Remove it from App Platform**

Once the domain is **Active** on Sevalla, delete it from the spec's `domains` and update the app.

If DigitalOcean manages the domain's DNS (**We manage your domain**, a `zone` in the spec's `domains`), App Platform keeps the domain's records itself. First remove `zone` from the domain in the spec and update the app, as [DigitalOcean describes](https://docs.digitalocean.com/products/app-platform/how-to/manage-domains/#change-domain-management-option), then change the records in DigitalOcean's DNS or at the DNS provider you move to. After the move, update the values that held the domain, such as `CORS_ORIGIN` and the static site's `API_URL` in the example app, then deploy both.

## Shut down the App Platform 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 your IP address from the cluster's trusted sources: find its `UUID` with `doctl databases firewalls list "$DO_DB_ID"`, then run `doctl databases firewalls remove "$DO_DB_ID" --uuid <rule UUID>`.
3. Keep `storefront.dump` until you no longer need it.
4. Delete the App Platform app with `doctl apps delete "$DO_APP_ID" --force`. The managed database cluster stays, and keeps billing, but the app is no longer one of its trusted sources: a cluster without trusted sources accepts connections from any address.
5. Delete the cluster with `doctl databases delete "$DO_DB_ID" --force` once you're sure. Deleting it deletes its data and its backups.

To repeat the setup for staging, put the applications in a [pipeline](https://docs.sevalla.com/pipelines/overview.md).

## Troubleshoot

| Symptom | Cause | Fix |
| - | - | - |
| `pg_dump: error: connection to server at "<host>" (<IP address>), port 25060 failed: Operation timed out` | The database cluster's trusted sources don't include your IP address | Add it with `doctl databases firewalls append`, as in [Copy the PostgreSQL data](#copy-the-postgresql-data) |
| `FATAL:  password authentication failed for user "doadmin"` | The URL came from `doctl databases connection` without `--show`, so its password is `********` | Add `--show` |
| A variable's value is `${...}` at runtime, for example `${SVL_DEPLOYMENT_COMMIT_SHA}` | A bindable variable, or a reference to a variable Sevalla sets, was copied as it was. Sevalla resolves `${KEY}` only to the application's and the company's variables, and leaves other references as typed | Replace it as in [Replace the bindable variables](#replace-the-bindable-variables) |
| Row counts match but checksums differ | The comparison sorted text with the database's collation, `en_US.UTF-8` on DigitalOcean and `C` on Sevalla | Sort with `COLLATE "C"`, as the comparison in [Copy the PostgreSQL data](#copy-the-postgresql-data) does |

## 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 Ingot: it reads the app spec, creates the database, the application and the static site, turns each component into a process, copies the data and deploys. A script or an AI agent runs it as is, in one shell, from the folder with `secrets.env` from [Take stock of the App Platform app](#take-stock-of-the-app-platform-app). 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.
* `doctl`, signed in, plus `curl`, `jq`, `openssl` and the PostgreSQL client tools.

The API allows 20 service creations per hour per company and operation; this sequence creates three services.

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
API=https://api.sevalla.com/v3
REPO=https://github.com/acme/storefront
DO_APP_ID='<the app's ID>'
DO_DB_ID='<the database cluster's ID>'
doctl apps spec get "$DO_APP_ID" --format json > app.json
```

**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 app's region, here `us-ashburn-1` (Ashburn, USA) for `nyc`:

```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 == "us-ashburn-1") | .id')
```

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

**2. Look up the process size**

[List process resource types](https://api-docs.sevalla.com/v3/resources/list-process-resource-types) (`listProcessResourceTypesV3`). Save **S1** for the processes this sequence creates:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
S1_ID=$(curl -s "$API/resources/process-resource-types" -H "Authorization: Bearer $SEVALLA_API_KEY" \
  | jq -r '.[] | select(.name == "s1") | .id')
```

CLI: `sevalla resources process-types`. MCP: `get_platform_options`.

**3. Look up the database size and version**

[List database resource types](https://api-docs.sevalla.com/v3/resources/list-database-resource-types) (`listDatabaseResourceTypesV3`) and [List database versions](https://api-docs.sevalla.com/v3/resources/list-database-versions) (`listDatabaseVersionsV3`). Save **DB1**, the smallest size, or one with room for the data, and the newest PostgreSQL version, which must be at least the managed database's:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
DB_SIZE_ID=$(curl -s "$API/resources/database-resource-types" -H "Authorization: Bearer $SEVALLA_API_KEY" \
  | jq -r '.[] | select(.name == "db1") | .id')
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-types` and `sevalla resources db-versions --cluster "$CLUSTER_ID"`. MCP: `get_platform_options`.

**4. Create the PostgreSQL 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:

```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 for the `api` service with a web process at **S1**, which can have custom domains, 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. Create the static site**

[Create static site](https://api-docs.sevalla.com/v3/static-sites/create-static-site) (`createStaticSiteV3`) with the static site component's `source_dir`, `build_command` and `output_dir`. It doesn't deploy the site:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
SITE_ID=$(curl -s -X POST "$API/static-sites" \
  -H "Authorization: Bearer $SEVALLA_API_KEY" -H "Content-Type: application/json" \
  -d "$(jq -c --arg repo "$REPO" '.static_sites[0] | {display_name: "storefront-web", repo_url: $repo,
    default_branch: "main", source: "privateGit", git_type: "github", node_version: "lts",
    root_directory: (.source_dir // ""), build_command: .build_command, published_directory: (.output_dir // "")}' app.json)" \
  | jq -r .id)
```

CLI: `sevalla static-sites create --name storefront-web --repository "$REPO" --branch main --build-command "npm --prefix web run build" --publish-directory web/dist --node-version lts`; the CLI has no root directory option, so the build command and publish directory include the folder. MCP: `create_static_site`.

**7. Wait for the database**

[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:

```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")
```

CLI: `sevalla databases get "$PG_ID"`. MCP: `get_database`.

**8. Connect the database**

[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"}}
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 step adds `DATABASE_URL`. CLI: `sevalla apps internal-connections create "$APP_ID" --target-id "$PG_ID" --target-type database`. MCP: `sevalla_api_create` with `createApplicationInternalConnectionV3` and the same body.

**9. Add the environment variables**

[List application domains](https://api-docs.sevalla.com/v3/applications/list-application-domains) (`listApplicationDomainsV3`) and [List static site domains](https://api-docs.sevalla.com/v3/static-sites/list-static-site-domains) (`listStaticSiteDomainsV3`) return the two URLs. Then [Create environment variable](https://api-docs.sevalla.com/v3/applications/create-environment-variable) (`createEnvironmentVariableV3`) adds, once per variable: `DATABASE_URL` in the internal connection's format, the spec's plain app-level and component-level variables with the availability of their `scope`, the secrets from `secrets.env` at runtime only, and the replacements for the example app's bindable variables:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
APP_URL=https://$(curl -s "$API/applications/$APP_ID/domains" -H "Authorization: Bearer $SEVALLA_API_KEY" \
  | jq -r '.data[] | select(.is_primary) | .name')
SITE_URL=https://$(curl -s "$API/static-sites/$SITE_ID/domains" -H "Authorization: Bearer $SEVALLA_API_KEY" \
  | jq -r '.data[] | select(.is_primary) | .name')
{
  jq -cn --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"}'
  jq -c '[.envs[]?, ((.services + .workers + .jobs)[] | .envs[]?)]
    | map(select(.type != "SECRET" and (.value | contains("${") | not))) | unique_by(.key)[]
    | {key, value, is_runtime: (.scope != "BUILD_TIME"), is_buildtime: (.scope != "RUN_TIME")}' app.json
  jq -cR 'capture("^(?<key>[^=]+)=(?<value>.*)$") | . + {is_buildtime: false}' secrets.env
  jq -cn --arg app "$APP_URL" --arg site "$SITE_URL" \
    '{key: "PUBLIC_URL", value: $app}, {key: "CORS_ORIGIN", value: $site} | . + {is_buildtime: false}'
} | jq -c '{is_runtime: true, is_buildtime: true} + .' | 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
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
DATABASE_URL
APP_NAME
WORKER_INTERVAL_SECONDS
SESSION_SECRET
PUBLIC_URL
CORS_ORIGIN
```

CLI: `sevalla apps env-vars create "$APP_ID" --key <key> --value <value>`, once per variable; it makes every variable available during the build and at runtime. MCP: `set_environment_variables` with all the variables in one call.

**10. Add the static site's variables**

[Create static site environment variable](https://api-docs.sevalla.com/v3/static-sites/create-static-site-environment-variable) (`createStaticSiteEnvVarV3`), once per variable of the static site component. The example site's `API_URL` points at the application:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s -X POST "$API/static-sites/$SITE_ID/env-vars" \
  -H "Authorization: Bearer $SEVALLA_API_KEY" -H "Content-Type: application/json" \
  -d "$(jq -n --arg value "$APP_URL/api" '{key: "API_URL", value: $value}')" | jq -r .key
```

CLI: `sevalla static-sites env-vars create "$SITE_ID" --key API_URL --value "$APP_URL/api"`. MCP: `sevalla_api_create` with `createStaticSiteEnvVarV3` and the same body.

**11. Add the health check**

[List processes](https://api-docs.sevalla.com/v3/applications/list-processes) (`listProcessesV3`) finds the web process, and [Update process](https://api-docs.sevalla.com/v3/applications/update-process) (`updateProcessV3`) turns the service's `health_check` into a readiness probe, with App Platform's defaults for the timings the spec leaves out. Always send `timeoutSeconds`: a probe without it gets 120 seconds.

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
WEB_ID=$(curl -s "$API/applications/$APP_ID/processes" -H "Authorization: Bearer $SEVALLA_API_KEY" \
  | jq -r '.data[] | select(.type == "web") | .id')
curl -s -X PATCH "$API/applications/$APP_ID/processes/$WEB_ID" \
  -H "Authorization: Bearer $SEVALLA_API_KEY" -H "Content-Type: application/json" \
  -d "$(jq -c '.services[0] | .http_port as $port | .health_check | {readiness_probe: {
      httpGet: {path: .http_path, port: (.port // $port // 8080), scheme: "HTTP"},
      initialDelaySeconds: (.initial_delay_seconds // 0), periodSeconds: (.period_seconds // 10),
      timeoutSeconds: (.timeout_seconds // 1), successThreshold: (.success_threshold // 1),
      failureThreshold: (.failure_threshold // 9)}}' app.json)" \
  | jq -c .readiness_probe
```

```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{"httpGet":{"path":"/healthz","port":8080,"scheme":"HTTP"},"initialDelaySeconds":0,"periodSeconds":10,"timeoutSeconds":1,"successThreshold":1,"failureThreshold":9}
```

CLI: not available, the CLI can't set probes. MCP: `sevalla_api_update` with `updateProcessV3` and the same body.

**12. Create the workers, jobs and cron jobs**

[Create process](https://api-docs.sevalla.com/v3/applications/create-process) (`createProcessV3`), once per worker and job in the spec: a background worker with `exec` before the `run_command`, a job with the start policy that matches its `kind`, and a cron job with the schedule and time zone of a `SCHEDULED` job:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
jq -c --arg size "$S1_ID" '
  (.workers[]? | {display_name: .name, type: "worker", entrypoint: ("exec " + .run_command),
    scaling_strategy: {type: "manual", config: {instanceCount: (.instance_count // 1)}}}),
  (.jobs[]? | {display_name: .name, entrypoint: .run_command} + (
    if .kind == "SCHEDULED" then {type: "cron", schedule: .schedule.cron, time_zone: (.schedule.time_zone // "UTC")}
    elif .kind == "PRE_DEPLOY" then {type: "job", job_start_policy: "beforeDeployment"}
    elif .kind == "FAILED_DEPLOY" then {type: "job", job_start_policy: "afterFailedDeployment"}
    else {type: "job", job_start_policy: "afterSuccessDeployment"} end))
  | {resource_type_id: $size, scaling_strategy: {type: "manual", config: {instanceCount: 1}}} + .' app.json \
| while IFS= read -r body; do
  curl -s -X POST "$API/applications/$APP_ID/processes" \
    -H "Authorization: Bearer $SEVALLA_API_KEY" -H "Content-Type: application/json" -d "$body" \
    | jq -c '{display_name, type, entrypoint}'
done
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{"display_name":"worker","type":"worker","entrypoint":"exec node worker.js"}
{"display_name":"migrate","type":"job","entrypoint":"node migrate.js"}
{"display_name":"post-deploy","type":"job","entrypoint":"node post-deploy.js"}
{"display_name":"cleanup","type":"cron","entrypoint":"node cleanup.js"}
```

CLI: `sevalla apps processes create "$APP_ID" --name worker --type worker --resource-type "$S1_ID" --command "exec node worker.js"`; for a job add `--type job --job-start-policy afterSuccessDeployment`, for a cron job `--type cron --schedule "*/15 * * * *"` (the CLI can't set a time zone). MCP: `sevalla_api_create` with `createProcessV3` and the same body.

**13. Stop writes on App Platform**

Archive the App Platform app, as in [Copy the PostgreSQL data](#copy-the-postgresql-data), and let your IP address through the database cluster's trusted sources:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
jq '.maintenance.archive = true' app.json > app-archived.json
doctl apps update "$DO_APP_ID" --spec app-archived.json --wait
doctl databases firewalls append "$DO_DB_ID" --rule ip_addr:$(curl -s https://api.ipify.org)
```

**14. Open the database to your computer**

[Toggle external connection](https://api-docs.sevalla.com/v3/databases/toggle-external-connection) (`toggleDatabaseExternalConnectionV3`) turns the external connection on, and [Get database](https://api-docs.sevalla.com/v3/databases/get-database) (`getDatabaseV3`) returns its hostname and port once it's up:

```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" > /dev/null
for i in $(seq 1 30); do
  EXTERNAL=$(curl -s "$API/databases/$PG_ID" -H "Authorization: Bearer $SEVALLA_API_KEY" \
    | jq -r 'select(.external_hostname != null) | "\(.external_hostname):\(.external_port)"')
  [ -n "$EXTERNAL" ] && break
  sleep 10
done
SEVALLA_DB_URL="postgres://storefront:$PG_PASSWORD@$EXTERNAL/storefront"
```

CLI: `sevalla databases connections external-toggle "$PG_ID"`. MCP: `sevalla_api_create` with `toggleDatabaseExternalConnectionV3`.

**15. Copy and compare the data**

Dump the managed database, restore it, and compare the row counts and checksums of every table:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
DO_DB_URL=$(doctl databases connection "$DO_DB_ID" --format URI --no-header --show)
pg_dump "$DO_DB_URL" --format=custom --no-owner --no-privileges --file=storefront.dump
pg_restore --dbname="$SEVALLA_DB_URL" --no-owner --no-privileges storefront.dump
compare() {
  for table in $(psql "$1" -Atc "SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY 1"); do
    psql "$1" -Atc "SELECT '$table', count(*), md5(coalesce(string_agg(t::text, ',' ORDER BY t::text COLLATE \"C\"), '')) FROM $table t"
  done
}
compare "$DO_DB_URL" > digitalocean.txt
compare "$SEVALLA_DB_URL" > sevalla.txt
diff digitalocean.txt sevalla.txt && echo "Data matches"
```

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

**16. Deploy the application**

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

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

**18. Deploy the static site**

[Deploy static site](https://api-docs.sevalla.com/v3/static-sites/deploy-static-site) (`deployStaticSiteV3`), then [Get static site deployment](https://api-docs.sevalla.com/v3/static-sites/get-static-site-deployment) (`getStaticSiteDeploymentV3`) until it ends:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
SITE_DEPLOYMENT_ID=$(curl -s -X POST "$API/static-sites/$SITE_ID/deployments" \
  -H "Authorization: Bearer $SEVALLA_API_KEY" -H "Content-Type: application/json" -d '{}' | jq -r .id)
for i in $(seq 1 120); do
  STATUS=$(curl -s "$API/static-sites/$SITE_ID/deployments/$SITE_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 static-sites deployments trigger "$SITE_ID"`, then `sevalla static-sites deployments get "$SITE_DEPLOYMENT_ID" --site-id "$SITE_ID"`. MCP: `sevalla_api_create` with `deployStaticSiteV3`, then `sevalla_api_read` with `getStaticSiteDeploymentV3`.

**19. Turn on auto-deploy**

[Update application](https://api-docs.sevalla.com/v3/applications/update-application) (`updateApplicationV3`) and [Update static site](https://api-docs.sevalla.com/v3/static-sites/update-static-site) (`updateStaticSiteV3`). Both start with auto-deploy off when the API creates them:

```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
curl -s -X PATCH "$API/static-sites/$SITE_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
true
```

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

Then run the checks in [Deploy and verify](#deploy-and-verify), and turn the external connection off again with the same toggle call. [Add application domain](https://api-docs.sevalla.com/v3/applications/add-application-domain) (`addApplicationDomainV3`) and [Add static site domain](https://api-docs.sevalla.com/v3/static-sites/add-static-site-domain) (`addStaticSiteDomainV3`) add each custom domain; read its DNS records in `dns_records` with [List application domains](https://api-docs.sevalla.com/v3/applications/list-application-domains) a few seconds later.


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