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

> Move Render services to Sevalla: how web services, private services, workers, cron jobs, disks, Postgres, Key Value and render.yaml map, then copy the data and move the domains.

A Render web service moves to Sevalla as an application, and the background workers, cron jobs and pre-deploy command that run the same code become processes of that application. A private service becomes an application of its own with HTTP traffic off, a persistent disk becomes a disk, and Render Postgres and Render Key Value become Sevalla databases. You recreate the setup while Render keeps serving, copy the data during a short maintenance window, then point the domains at Sevalla.

## At a glance

| | |
| - | - |
| Source | The repository your Render services deploy from, on GitHub, GitLab or Bitbucket |
| Build | [Ingot](https://docs.sevalla.com/applications/builds/ingot.md) in place of Render's native runtimes, or your `Dockerfile` with the [Dockerfile](https://docs.sevalla.com/applications/builds/dockerfile.md) build strategy in place of Render's Docker runtime |
| Start command | The web service's start command, such as `node web.js` |
| Port | The `PORT` environment variable, `8080` by default (Render's default is `10000`) |
| Processes | Web process; a **Background worker** per Render background worker, its command after `exec`; a **Cron job** per Render cron job; a **Job** with the start policy **Before deployment** for the pre-deploy command |
| Services | A PostgreSQL database for Render Postgres and a Valkey database for Render Key Value, over internal connections; one application with HTTP traffic off per private service |
| Disk | A disk of at least 10 GB at the Render disk's mount path, such as `/var/data`, on the process that replaces the Render service |
| Health check | A readiness probe on the web process for Render's health check path, such as `GET /healthz` |
| 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 Render Postgres | Build and runtime | Yes | `DB_URL` from the internal connection to `orders-db`, renamed: `postgres://<user>:<password>@<host>:<port>/<database>` |
| `REDIS_URL` | With Render Key Value | Build and runtime | Yes | Added by the internal connection to `storefront-cache`: `redis://:<password>@<host>:<port>/0` |
| The private service's address, here `FILES_HOSTPORT` | With a private service | Build and runtime | No | The internal hostname of the private service's web process and its port: `<internal hostname>:8080` |
| A secret file's contents, here `APP_CONFIG_JSON` | With secret files | Build and runtime | Yes | The file's contents, from the Render API |
| The variables of the Render services and environment groups | As on Render | Build and runtime | As on Render | From the Render API, without the ones that point at Render datastores and private services |
| `PORT` | No | Runtime | No | Set by Sevalla to the web process port. Don't add it |

## How Render maps to Sevalla

| Render | Sevalla | Notes |
| - | - | - |
| Web service | [Application](https://docs.sevalla.com/applications/overview.md) and its web process | Built from a Git repository or a [Docker image](https://docs.sevalla.com/applications/docker-images.md) |
| Background worker | [Background worker](https://docs.sevalla.com/applications/processes.md#background-workers) | A process of the application whose code it runs |
| Cron job | [Cron job](https://docs.sevalla.com/applications/processes.md#cron-jobs) | The same cron schedule, in UTC unless you set a time zone |
| Pre-deploy command (`preDeployCommand`) | [Job](https://docs.sevalla.com/applications/processes.md#jobs) with the start policy **Before deployment** | Runs after the build, before the rollout. If it fails, the deployment fails and the previous version keeps running |
| Private service | Application with [HTTP traffic off](https://docs.sevalla.com/applications/networking.md#turn-http-traffic-off) | Other applications reach it over an [internal connection](https://docs.sevalla.com/applications/networking.md#internal-connections) |
| Internal address, such as `storefront-files:10000` | [Internal hostname](https://docs.sevalla.com/applications/networking.md#internal-hostnames), such as `storefront-files-x7k2m-web.storefront-files-x7k2m.svc.cluster.local:8080` | Reachable only from applications with an internal connection to it |
| Persistent disk | [Disk](https://docs.sevalla.com/applications/disks.md) | One per web process or background worker, from 10 GB. As on Render, a process with a disk runs one instance and stops before the new version starts |
| Render Postgres | [PostgreSQL database](https://docs.sevalla.com/databases/overview.md) | |
| Render Key Value (Valkey), legacy Render Redis | [Valkey or Redis database](https://docs.sevalla.com/databases/overview.md) | Copy the keys with `DUMP` and `RESTORE`. See [Copy the Key Value data](#copy-the-key-value-data) |
| `fromDatabase` and `fromService` references | [Internal connection](https://docs.sevalla.com/databases/connect.md#connect-an-application) | Adds the connection details as environment variables |
| Environment variables | [Environment variables](https://docs.sevalla.com/applications/environment-variables.md) | One set per application, shared by all its processes |
| Environment groups | [Global variables](https://docs.sevalla.com/applications/environment-variables.md#global-variables), or each application's own variables | Global variables reach every application in the company, not a list you choose |
| Secret files | No equivalent | Keep the file's contents in an environment variable. See [Move the environment variables](#move-the-environment-variables) |
| Blueprints (`render.yaml`) | No Blueprint file | Recreate the setup with [Deploy with the API](#deploy-with-the-api), or manage it as code with the [Terraform provider](https://docs.sevalla.com/developer-tools/terraform.md) |
| Native runtimes and the build command | [Ingot](https://docs.sevalla.com/applications/builds/ingot.md) | Installs dependencies from the lock file and runs the framework's build. To run another build command, see [Configure the build](https://docs.sevalla.com/applications/builds/ingot.md#configure-the-build) |
| Docker runtime | [Dockerfile](https://docs.sevalla.com/applications/builds/dockerfile.md) build strategy | |
| Root directory (`rootDir`) | [Build path](https://docs.sevalla.com/applications/builds/overview.md#build-settings) | The folder Sevalla builds from |
| Compute plans (instance types) | [Process sizes](https://docs.sevalla.com/applications/scaling.md#instance-sizes) | H1, S1 to S5, C1 to C4 and M1 to M5, set per process |
| Manual scaling and autoscaling | [Instances and horizontal autoscaling](https://docs.sevalla.com/applications/scaling.md) | Set per web process and background worker |
| Free web services spinning down | [Hibernation](https://docs.sevalla.com/applications/scaling.md#hibernation) | |
| Health check path | [Readiness probe](https://docs.sevalla.com/applications/processes.md#health-checks) | |
| Auto-Deploy | [Automatic deployments](https://docs.sevalla.com/applications/deployments.md#deploy-automatically-on-push) | From GitHub, GitLab or Bitbucket |
| Build filters | [Deploy only when some files change](https://docs.sevalla.com/applications/deployments.md#deploy-only-when-some-files-change) | |
| Deploy hooks | [Deploy hooks](https://docs.sevalla.com/applications/deployments.md#deploy-with-a-deploy-hook) | A URL that starts a deployment |
| Rollbacks | [Roll back a deployment](https://docs.sevalla.com/applications/deployments.md#roll-back-a-deployment) | |
| Service previews and preview environments | [Pipelines](https://docs.sevalla.com/pipelines/overview.md) and [preview apps](https://docs.sevalla.com/pipelines/preview-apps.md) | A preview app copies one application, not a whole Blueprint |
| Projects and environments | [Projects](https://docs.sevalla.com/projects/overview.md) and [pipelines](https://docs.sevalla.com/pipelines/overview.md) | Projects group services; pipeline stages hold staging and production |
| Maintenance mode | [Maintenance mode](https://docs.sevalla.com/applications/maintenance-mode.md) | |
| Shell, SSH and one-off jobs | [Web terminal](https://docs.sevalla.com/applications/web-terminal.md) | From a script: [Run a command in a process](https://api-docs.sevalla.com/v3/applications/run-a-command-in-a-process) |
| Logs and metrics | [Logs](https://docs.sevalla.com/applications/logs.md) and [Analytics](https://docs.sevalla.com/applications/analytics.md) | |
| Custom domains | [Custom domains](https://docs.sevalla.com/domains/overview.md) | Sevalla issues and renews the SSL certificates |
| Webhooks | [Webhooks](https://docs.sevalla.com/developer-tools/webhooks.md) | |
| Workspaces and members | [Users and roles](https://docs.sevalla.com/company/users-and-roles.md) | |
| Render API, CLI and MCP server | [Sevalla API](https://docs.sevalla.com/developer-tools/api.md), [CLI](https://docs.sevalla.com/developer-tools/cli.md) and [MCP server](https://docs.sevalla.com/ai-agents/overview.md) | |

## Before you begin

* A [Render API key](https://render.com/docs/api#1-create-an-api-key) in `RENDER_API_KEY`, for a workspace member who can see the services, plus `curl` and `jq`. The commands on this page read the Render setup through the [Render API](https://api-docs.render.com).
* The services' 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 (`pg_dump`, `pg_restore`, `psql`) at the major version of the newer of the two databases or later, such as 18, if the services use Render Postgres: `pg_dump` refuses to dump a server newer than itself. See [Import and export PostgreSQL](https://docs.sevalla.com/databases/import-export/postgresql.md#before-you-begin).
* Python 3 with `pip`, to copy Key Value data.
* A maintenance window: the Render web service is in maintenance mode, and its workers and cron jobs are suspended, while you copy the data.

The examples move this Render setup, described in its `render.yaml`. Replace the names with yours:

```yaml render.yaml theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
services:
  - type: web
    name: storefront
    runtime: node
    region: frankfurt
    plan: 0.5c-512mb
    buildCommand: npm ci
    preDeployCommand: node migrate.js
    startCommand: node web.js
    healthCheckPath: /healthz
    envVars:
      - key: DATABASE_URL
        fromDatabase: { name: orders-db, property: connectionString }
      - key: REDIS_URL
        fromService: { type: keyvalue, name: storefront-cache, property: connectionString }
      - key: FILES_HOSTPORT
        fromService: { type: pserv, name: storefront-files, property: hostport }
      - key: APP_CONFIG_FILE
        value: /etc/secrets/app-config.json # a secret file
      - fromGroup: storefront-shared
  - type: pserv
    name: storefront-files
    runtime: node
    region: frankfurt
    plan: 0.5c-512mb
    buildCommand: npm ci
    startCommand: node files.js
    disk: { name: files, mountPath: /var/data, sizeGB: 1 }
  - type: worker
    name: storefront-worker
    runtime: node
    region: frankfurt
    plan: 0.5c-512mb
    buildCommand: npm ci
    startCommand: node worker.js
    envVars:
      - key: DATABASE_URL
        fromDatabase: { name: orders-db, property: connectionString }
      - key: REDIS_URL
        fromService: { type: keyvalue, name: storefront-cache, property: connectionString }
      - fromGroup: storefront-shared
  - type: cron
    name: storefront-cleanup
    runtime: node
    region: frankfurt
    plan: 0.5c-512mb
    schedule: "*/5 * * * *"
    buildCommand: npm ci
    startCommand: node cron.js
    envVars:
      - key: DATABASE_URL
        fromDatabase: { name: orders-db, property: connectionString }
      - fromGroup: storefront-shared
  - type: keyvalue
    name: storefront-cache
    region: frankfurt
    plan: free
    ipAllowList: []
databases:
  - name: orders-db
    region: frankfurt
    databaseName: storefront
    user: storefront
envVarGroups:
  - name: storefront-shared
    envVars:
      - key: APP_NAME
        value: storefront
      - key: SESSION_SECRET
        generateValue: true
```

## Take stock of the Render services

Collect what you'll recreate. A `render.yaml` describes what its Blueprint created, but not services created in the dashboard, values entered there, generated values or secret files, so read the setup from the Render API:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
RENDER=https://api.render.com/v1
curl -s "$RENDER/services?limit=100" -H "Authorization: Bearer $RENDER_API_KEY" \
  | jq -c '.[].service | {id, type, name, region: .serviceDetails.region,
      start: .serviceDetails.envSpecificDetails.startCommand,
      preDeploy: .serviceDetails.envSpecificDetails.preDeployCommand,
      schedule: .serviceDetails.schedule, healthCheck: .serviceDetails.healthCheckPath,
      disk: .serviceDetails.disk.mountPath} | with_entries(select(.value != null))'
curl -s "$RENDER/postgres" -H "Authorization: Bearer $RENDER_API_KEY" | jq -c '.[].postgres | {id, name, version, region}'
curl -s "$RENDER/key-value" -H "Authorization: Bearer $RENDER_API_KEY" | jq -c '.[].keyValue | {id, name, version, region}'
curl -s "$RENDER/env-groups" -H "Authorization: Bearer $RENDER_API_KEY" | jq -c '.[].envGroup | {id, name}'
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{"id":"srv-dbi7eeu7bikc73ac0ffg","type":"web_service","name":"storefront","region":"frankfurt","start":"node web.js","preDeploy":"node migrate.js","healthCheck":"/healthz"}
{"id":"srv-dbi7eeu7bikc73ac0fe0","type":"background_worker","name":"storefront-worker","region":"frankfurt","start":"node worker.js"}
{"id":"crn-dbi7eeu7bikc73ac0feg","type":"cron_job","name":"storefront-cleanup","region":"frankfurt","start":"node cron.js","schedule":"*/5 * * * *"}
{"id":"srv-dbi7dve7bikc73abv88g","type":"private_service","name":"storefront-files","region":"frankfurt","start":"node files.js","disk":"/var/data"}
{"id":"dpg-dbi7dve7bikc73abv8h0-a","name":"orders-db","version":"18","region":"frankfurt"}
{"id":"red-dbi7dve7bikc73abv810","name":"storefront-cache","version":"8.1.10","region":"frankfurt"}
{"id":"evg-dbi7dve7bikc73abv7ug","name":"storefront-shared"}
```

* Each web service, private service and background worker shows its start command, and the web service its pre-deploy command and health check path. Each cron job shows its schedule, and a service with a disk its mount path.
* `region` is the Render region. The closest Sevalla locations are **San Jose, USA** for `oregon`, **Chicago, USA** for `ohio`, **Ashburn, USA** for `virginia`, **Frankfurt, Germany** for `frankfurt` and **Singapore** for `singapore`. Put every service and database in the same location: internal connections work only inside one. See [Locations](https://docs.sevalla.com/platform/locations.md).
* The Postgres `version` is the PostgreSQL major version. Create the Sevalla database with the same version or a newer one.
* New Render Key Value instances run Valkey 8, shown with a version such as `8.1.10`; legacy instances run Redis. Create a Sevalla database of the same engine, at the same version or newer.

Save the IDs for the next commands:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
WEB_ID='<web service ID>'          # srv-...
WORKER_ID='<background worker ID>' # srv-...
CRON_ID='<cron job ID>'            # crn-...
FILES_ID='<private service ID>'    # srv-...
GROUP_ID='<environment group ID>'  # evg-...
PG_RENDER_ID='<Render Postgres ID>'   # dpg-...
KV_RENDER_ID='<Render Key Value ID>'  # red-...
```

## Create the databases

**1. Create the PostgreSQL database**

In the sidebar, click **Databases**, then click **Create database**. Under **Database**, choose **PostgreSQL** and a version at least as new as Render's. Enter `orders-db` as the **Display name**, choose the **Data center** closest to the Render region and a **Size** with room for the data, then click **Create database**.

**2. Create the Valkey database**

If the services use Render Key Value, create a second database the same way: choose **Valkey** (or **Redis** for a legacy Render Redis instance), enter `storefront-cache` as the **Display name**, and choose the same **Data center**. See [Create a database](https://docs.sevalla.com/databases/create.md).

## Create the applications

Create one application for the web service and the workers, cron jobs and pre-deploy command that share its code, and one for each private service.

**1. Create the main application**

In the sidebar, click **Applications**, then click **Create** > **Application**. Under **Source**, click **Private repository**, then choose the **Git provider**, the **Repository** and the **Branch** the Render web service deploys. Under **Location**, choose the same **Data center** as the databases, enter `storefront` as the name, choose a **Size**, and click **Create application**. Creating an application doesn't deploy it. See [Create an application](https://docs.sevalla.com/applications/create.md).

**2. Create the private service application**

Create a second application the same way for the private service, named `storefront-files`, from the same repository and **Data center**.

**3. Set its start command**

On `storefront-files`, click the **Processes** tab, click **⋯** on the **Web** process, then click **Update process**. Enter the private service's start command after `exec` as the **Custom start command**, here `exec node files.js`, and click **Update process**.

**4. Turn its HTTP traffic off**

Click the **Networking** tab. In **Expose HTTP traffic**, click **Disable**, then click **Disable HTTP** in the dialog. The application keeps running and answers only on the private network. See [Turn HTTP traffic off](https://docs.sevalla.com/applications/networking.md#turn-http-traffic-off).

**5. Add the disk**

Click the **Disks** tab, then click **Create disk**. Pick the **Web** process, enter the Render disk's mount path as the **Path**, here `/var/data`, pick a **Size** of at least the Render disk's size (10 GB is the smallest), and click **Create disk**. See [Persistent storage](https://docs.sevalla.com/applications/disks.md).

Sevalla runs the web process on `PORT`, `8080` by default, where Render used `10000`. A server that listens on `PORT` needs no change. A web process on **H1** can't have custom domains; if you'll move a domain to `storefront`, choose a larger size, such as **S1**.

## Connect the services

**1. Connect PostgreSQL**

On `storefront`, 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**.

**2. Connect Valkey**

Add an internal connection to `storefront-cache` the same way. Its URL is already called `REDIS_URL`.

**3. Connect the private service**

Add an internal connection to the `storefront-files` application. **Connected services** then lists its web process's internal hostname, such as `storefront-files-x7k2m-web.storefront-files-x7k2m.svc.cluster.local`.

Render's internal URLs don't work on Sevalla, so update every value that names a Render host:

| Render value | Sevalla value |
| - | - |
| Render Postgres internal URL, `postgresql://storefront:<password>@dpg-<id>-a/storefront` | `DATABASE_URL` from the internal connection, `postgres://storefront:<password>@<host>:<port>/storefront`. Clients that accept `postgresql://` accept `postgres://` too |
| Render Key Value internal URL, `redis://red-<id>:6379`, with no password | `REDIS_URL` from the internal connection, `redis://:<password>@<host>:<port>/0`. Sevalla requires the password, which the URL includes |
| Private service address, `storefront-files:10000` | The internal hostname and the web process port: `storefront-files-x7k2m-web.storefront-files-x7k2m.svc.cluster.local:8080` |
| `https://storefront.onrender.com` | The application's `sevalla.app` domain, or your custom domain once it's moved |

See [Connect an application](https://docs.sevalla.com/databases/connect.md#connect-an-application) and [Internal hostnames](https://docs.sevalla.com/applications/networking.md#internal-hostnames).

## Move the environment variables

On Render, each service and each environment group has its own variables. On Sevalla, all processes of an application share its variables, so the web service's, the workers', the cron jobs' and the linked environment groups' variables go into one set.

**1. Export the variables**

Read them from the Render API and merge them into `render.json`. If two services set the same key to different values, keep the one the application needs, or rename one in your code:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{
  for id in "$WEB_ID" "$WORKER_ID" "$CRON_ID"; do
    curl -s "$RENDER/services/$id/env-vars?limit=100" -H "Authorization: Bearer $RENDER_API_KEY" \
      | jq '[.[].envVar | {(.key): .value}] | add // {}'
  done
  curl -s "$RENDER/env-groups/$GROUP_ID" -H "Authorization: Bearer $RENDER_API_KEY" \
    | jq '[.envVars[] | {(.key): .value}] | add // {}'
} | jq -s 'add' > render.json
jq -r 'keys[]' render.json
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
APP_CONFIG_FILE
APP_NAME
DATABASE_URL
FILES_HOSTPORT
REDIS_URL
SESSION_SECRET
```

**2. Export the secret files**

Sevalla has no secret files. List the web service's, then save each file's contents for an environment variable. An environment group's secret files are in `secretFiles` of the same group request:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s "$RENDER/services/$WEB_ID/secret-files" -H "Authorization: Bearer $RENDER_API_KEY" | jq -r '.[].secretFile.name'
curl -s "$RENDER/services/$WEB_ID/secret-files/app-config.json" -H "Authorization: Bearer $RENDER_API_KEY" \
  | jq -j .content > app-config.json
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
app-config.json
```

**3. Remove what Sevalla replaces**

Delete the variables that point at Render datastores and private services, which the internal connections replace, and add the secret file as a variable. The web process writes the file when it starts, in a folder the application can write to, so point the path variable there:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
jq --rawfile content app-config.json 'del(.DATABASE_URL, .REDIS_URL, .FILES_HOSTPORT)
  | .APP_CONFIG_JSON = $content | .APP_CONFIG_FILE = "/tmp/app-config.json"' render.json > config.json
jq -r 'keys[]' config.json
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
APP_CONFIG_FILE
APP_CONFIG_JSON
APP_NAME
SESSION_SECRET
```

Update any value that contains the Render domain, such as allowed hosts, to the application's `sevalla.app` domain under its name in the page header. Both files contain secrets: don't commit them.

**4. Add the variables**

On `storefront`, click the **Environment variables** tab, then click **Add environment variables**. Add each key and value from `config.json`, plus `FILES_HOSTPORT` with the internal hostname of `storefront-files` and `:8080`. Keep **Available during runtime** and **Available during buildtime** selected, and click **Add environment variables**. **Import .env** takes a file of `KEY=value` lines, such as the output of `jq -r 'to_entries[] | "\(.key)=\(.value)"' config.json`, for values without line breaks.

![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)

The application can't write to `/etc/secrets`, where Render puts secret files: Ingot runs it as user `1000:1000`, which can write to `/app` and `/tmp`. Either read the variable in your code, or write the file when the process starts, as the web process does in [Recreate the services as processes](#recreate-the-services-as-processes). An environment group that many applications share can become [global variables](https://docs.sevalla.com/applications/environment-variables.md#global-variables), which reach every application in the company. Don't add `PORT`: Sevalla sets it to the web process's port.

## Recreate the services as processes

| Render | Sevalla process on `storefront` | What to set |
| - | - | - |
| Web service, `startCommand: node web.js` | The existing **Web** process | Nothing when Ingot starts the same command: Ingot uses the `start` script of `package.json`, and its build plan shows it in the **Start** row. Otherwise set the **Custom start command**. Here it also writes the secret file: `printf %s "$APP_CONFIG_JSON" > /tmp/app-config.json && exec node web.js` |
| `healthCheckPath: /healthz` | The web process's readiness probe | Under **Health checks**, turn on **Readiness probe**, set the **Path** to `/healthz`, and keep the **Port** at `8080` |
| `preDeployCommand: node migrate.js` | A **Job** with the **Start policy** **Before deployment** | The command as the **Custom start command** |
| Background worker, `startCommand: node worker.js` | A **Background worker** | The 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 |
| Cron job, `schedule: "*/5 * * * *"`, `startCommand: node cron.js` | A **Cron job** | The command as the **Custom start command**, and the same **Schedule**. Render and Sevalla both read it in UTC |

On the **Processes** tab, click **Create process** for each worker, cron job and pre-deploy command. Choose **Background worker**, **Cron job** or **Job**, enter a **Name** and the **Custom start command**, set the **Start policy** (jobs) or the **Schedule** (cron jobs), choose a **Resource**, and click **Create process**. To update the web process, click **⋯** on it, then **Update process**. See [Processes](https://docs.sevalla.com/applications/processes.md).

> [!NOTE]
>
> Render builds each service on its own. On Sevalla, every process runs from the application's one image, built once per deployment. A background worker or cron job built from another repository or root directory needs an application of its own.

## Stop writes on Render

Turn on maintenance mode for the web service, which then answers every request with `503` and Render's maintenance page, and suspend the background workers and cron jobs:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s -X PATCH "$RENDER/services/$WEB_ID" -H "Authorization: Bearer $RENDER_API_KEY" \
  -H "Content-Type: application/json" -d '{"serviceDetails": {"maintenanceMode": {"enabled": true, "uri": ""}}}' \
  | jq -c .serviceDetails.maintenanceMode
for id in "$WORKER_ID" "$CRON_ID"; do
  curl -s -o /dev/null -w "%{http_code}\n" -X POST "$RENDER/services/$id/suspend" -H "Authorization: Bearer $RENDER_API_KEY"
done
```

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

Maintenance mode is available for paid web services only; suspend a free web service instead. The private service keeps running: you need it to copy its disk. Jobs still waiting in a Key Value queue move with the data, and the Sevalla worker picks them up.

## Copy the PostgreSQL data

**1. Dump the Render database**

Read the external URL, which Render accepts from any IP address unless you restricted it, and dump the database into a custom-format archive:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
RENDER_DB_URL=$(curl -s "$RENDER/postgres/$PG_RENDER_ID/connection-info" \
  -H "Authorization: Bearer $RENDER_API_KEY" | jq -r .externalConnectionString)
pg_dump --dbname="$RENDER_DB_URL" --format=custom --no-owner --no-privileges --file=render.dump
```

**2. 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).

**3. Restore the dump**

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

`--clean --if-exists` drops each table before it restores it, so the restore works on an empty database and on one where the pre-deploy job already created the tables.

**4. Compare the tables**

Count the rows and hash the contents of every table on both sides. Each pair of lines must match:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
for table in $(psql "$RENDER_DB_URL" -At -c "SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY 1"); do
  for url in "$RENDER_DB_URL" "$SEVALLA_DB_URL"; do
    psql "$url" -At -c "SELECT '$table', count(*), md5(coalesce(string_agg(t::text, ',' ORDER BY t::text COLLATE \"C\"), '')) FROM $table t"
  done
done
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
cron_runs|4|adb4469bc211721c4e8e9f2f8006bf2b
cron_runs|4|adb4469bc211721c4e8e9f2f8006bf2b
jobs_done|3|b4f27e0c3be023ec9ba92f38129fc76e
jobs_done|3|b4f27e0c3be023ec9ba92f38129fc76e
notes|1000|20fda92a6e0657ef7d62376f97dec094
notes|1000|20fda92a6e0657ef7d62376f97dec094
```

A Sevalla PostgreSQL database sorts text with the `C` collation, where Render Postgres uses `en_US.UTF8`: an `ORDER BY` on a text column can return rows in another order, which is why the comparison sorts with `COLLATE "C"`. If your application depends on the order, add a `COLLATE` clause to those queries.

To rehearse, run these steps once without stopping writes, deploy, and test on the `sevalla.app` domain. The real copy's `--clean` replaces the rehearsal's data.

## Copy the Key Value data

Skip this when Key Value holds only caches you can lose: the application refills them. Otherwise copy the keys with `DUMP` and `RESTORE`, which keep each key's type and expiry:

**1. Allow your computer on Render**

Render Key Value accepts external connections only from the IP addresses in its allow list. Add yours, then read the external URL, which starts with `rediss://` (TLS):

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
MY_IP=$(curl -s https://checkip.amazonaws.com)
curl -s -X PATCH "$RENDER/key-value/$KV_RENDER_ID" -H "Authorization: Bearer $RENDER_API_KEY" \
  -H "Content-Type: application/json" \
  -d "$(jq -n --arg cidr "$MY_IP/32" '{ipAllowList: [{cidrBlock: $cidr, description: "migration"}]}')" \
  | jq -c '.ipAllowList | length'
RENDER_KV_URL=$(curl -s "$RENDER/key-value/$KV_RENDER_ID/connection-info" \
  -H "Authorization: Bearer $RENDER_API_KEY" | jq -r .externalConnectionString)
```

The request replaces the allow list; it prints `1`. An empty list (`ipAllowList: []`) blocks external connections again.

**2. Open the Valkey database to your computer**

Enable the external connection of `storefront-cache`, as for PostgreSQL, and copy its **External connection URL**.

**3. Copy the keys**

Save this script, then run it with both URLs:

```python copy_keys.py theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import os

import redis

source = redis.from_url(os.environ["RENDER_KV_URL"])
target = redis.from_url(os.environ["SEVALLA_KV_URL"])

copied = 0
for key in source.scan_iter(count=1000):
    data = source.dump(key)
    if data is None:  # the key expired during the copy
        continue
    ttl = source.pttl(key)
    target.restore(key, max(ttl, 0), data, replace=True)
    copied += 1
print(f"Copied {copied} keys")
print(f"Render: {source.dbsize()} keys, Sevalla: {target.dbsize()} keys")
```

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
pip install redis
SEVALLA_KV_URL="redis://:<password>@<external hostname>:<external port>/0" \
RENDER_KV_URL="$RENDER_KV_URL" python3 copy_keys.py
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
Copied 324 keys
Render: 324 keys, Sevalla: 324 keys
```

The script copies database `0` and keeps each key's expiry. Use a URL that starts with `redis://`, as Sevalla's `REDIS_URL` does: redis-py doesn't accept `valkey://`. If `RESTORE` fails with `DUMP payload version or checksum are wrong`, the Sevalla database runs an older version than Render's: create it with a newer version. See [Import and export Redis](https://docs.sevalla.com/databases/import-export/redis.md).

## Copy the disk files

A disk's files move as an archive: you pack them on Render, bring the archive to your computer, and unpack it on the Sevalla disk.

**1. Deploy the private service application**

On `storefront-files`, click **Deploy now** on the **Overview** tab, check the **Branch**, and click **Deploy**. Its web process must run, with the disk mounted, before you can copy files to it.

**2. Pack the files on Render**

In the Render Dashboard, open the private service's **Shell** page and archive the disk's mount path, then send the archive with [Magic Wormhole](https://magic-wormhole.readthedocs.io/), which Render's native runtimes include:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
tar -czf /tmp/files.tar.gz -C /var/data .
wormhole send /tmp/files.tar.gz
```

`wormhole send` prints a code, such as `1-integrate-button`, and waits. If you set up [SSH for Render](https://render.com/docs/ssh), you can copy the archive with `scp -s` instead.

**3. Receive the archive**

On your computer, with the code from the Render shell:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
pip install magic-wormhole
wormhole receive --accept-file 1-integrate-button
tar -tzf files.tar.gz | grep -vc '/$'
```

The last command prints the number of files in the archive.

**4. Unpack it on the Sevalla disk**

Up to 100 MB, open the [web terminal](https://docs.sevalla.com/applications/web-terminal.md) on the `storefront-files` web process, [upload](https://docs.sevalla.com/applications/web-terminal.md#upload-files) `files.tar.gz` to `/var/data`, and unpack it there:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
tar -xzf /var/data/files.tar.gz --strip-components=1 -C /var/data && rm /var/data/files.tar.gz
```

`--strip-components=1` skips the archive's top folder, `.`: on Sevalla it's the disk's root, which belongs to root, and `tar` fails with `Cannot change mode to rwxrwxr-x: Operation not permitted` when it tries to update it. For a larger archive, see the disk step of [Deploy with the API](#deploy-with-the-api).

**5. Compare the files**

Run the same command in the Render shell and in the Sevalla web terminal. The outputs must match:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
cd /var/data && find . -type f | sort | xargs sha256sum | sha256sum
```

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

The files belong to the user that unpacked them, user `1000` on Sevalla with Ingot, the user the application runs as.

## Deploy and verify

**1. Deploy**

On `storefront`, click **Deploy now** on the **Overview** tab, check the **Branch**, and click **Deploy**.

**2. Check the deployment**

On the **Deployments** tab, wait for the status **Deployed**. The build log shows the job's result, `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 job's result. `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'
```

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

**3. Compare the responses**

Request the same pages on Sevalla that you requested on Render before you turned on maintenance mode, and compare the answers. The sample application's `/stats` reads every kind of data: PostgreSQL rows, Valkey keys, the secret file, the environment group's variable, and the private service's disk over the internal hostname:

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

```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{"app":"storefront","greeting":"Hello from storefront","notes":{"count":1000,"md5":"4ddf23e51fc9af92e0d0c88f9634444a"},"keys":324,"product1":"{\"id\":1,\"name\":\"Product 1\",\"price\":7}","files":{"count":30,"sha256":"415f9c8ddec92e67638acdb22c780500675f42fa6b15db70a6574a2885c2760d"}}
```

**4. Check the worker and the cron job**

On the **Processes** tab, the web process and the background worker show **Running**. [Get application runtime logs](https://api-docs.sevalla.com/v3/applications/get-application-runtime-logs) (`getApplicationRuntimeLogsV3`) with `search` finds what they log, and [Trigger cron job](https://api-docs.sevalla.com/v3/applications/trigger-cron-job) (`triggerProcessCronV3`) runs the cron job now instead of waiting for its schedule. `CRON_PROCESS_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 https://storefront-x7k2m.sevalla.app/jobs | jq -c .
curl -s -X POST "$API/applications/$APP_ID/processes/$CRON_PROCESS_ID/trigger" -H "Authorization: Bearer $SEVALLA_API_KEY" > /dev/null
sleep 60
curl -s "$API/applications/$APP_ID/runtime-logs?search=processed%20job" -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r '.[].message'
curl -s "$API/applications/$APP_ID/processes/$CRON_PROCESS_ID/runs" -H "Authorization: Bearer $SEVALLA_API_KEY" \
  | jq -c '.data[0] | {trigger, status, exit_code}'
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{"queued":"16308fe2"}
processed job 16308fe2
{"trigger":"manual","status":"succeeded","exit_code":0}
```

The worker logs the ID that `/jobs` returned, and [List cron runs](https://api-docs.sevalla.com/v3/applications/list-cron-runs) (`listProcessCronRunsV3`) shows the run you started. CLI: `sevalla apps logs runtime "$APP_ID" --search "processed job"` and `sevalla apps processes runs --app-id "$APP_ID" --process-id "$CRON_PROCESS_ID"`. MCP: `search_runtime_logs`.

**5. Turn on auto-deploy**

To deploy every push to the branch automatically, click the **Settings** tab of each application, then click **Update auto-deploy** in the **Auto-deploy** section. Turn on **Auto-deploy** and click **Update auto-deploy**. See [Deployments](https://docs.sevalla.com/applications/deployments.md#deploy-automatically-on-push).

## Move the custom domains

Render points a subdomain with a `CNAME` record to the service's `onrender.com` domain, and a root domain with an `A` record to `216.24.57.1` (or an `ALIAS` or `ANAME` record). Keep those records until Sevalla's certificate is ready, so Render serves the domain until you switch:

**1. Lower the TTL**

A day before, lower the TTL of the domain's record at your DNS provider to 300 seconds, so the switch reaches visitors within minutes.

**2. Add each domain**

On the `storefront` application's **Domains** tab, click **Add custom domain**, enter the domain in **Domain name**, for example `shop.example.com`, and click **Add custom domain**. Render adds a `www` redirect for a root domain automatically; on Sevalla, add `www.example.com` as a domain of its own.

**3. Verify it**

Add the verification records that Sevalla shows at your DNS provider: a `TXT` record on `_cf-custom-hostname.shop.example.com` for ownership and, once listed, one on `_acme-challenge.shop.example.com` for the certificate. Leave the record that points at Render. When the domain's status changes to **Pointing**, its certificate is ready. For a wildcard domain, Render's `_acme-challenge` `CNAME` to `verify.renderdns.com` takes the name Sevalla needs: replace it with Sevalla's. See [Verify the domain](https://docs.sevalla.com/domains/overview.md#verify-the-domain).

**4. Point it at Sevalla**

Replace the `CNAME` to `onrender.com`, or the `A` record to `216.24.57.1`, 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).

**5. Remove it from Render**

When the domain is **Active**, delete it from the Render service's **Custom Domains** settings.

Add the custom domains to any setting that lists allowed hosts, then deploy.

## Shut down the Render services

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

1. Disable the external connections you opened: on each database's **Networking** tab, click **Disable** under **External connection**, then click **Disable** in the dialog. On Render, `ipAllowList: []` closes Key Value again.
2. Keep `render.dump` and `files.tar.gz` until you no longer need them.
3. If the services come from a Blueprint, disconnect it first, on the Blueprint's page in the Render Dashboard or with `curl -s -X DELETE "$RENDER/blueprints/<Blueprint ID>" -H "Authorization: Bearer $RENDER_API_KEY"`. Its resources stay, and a later Blueprint sync can no longer recreate the ones you delete.
4. Delete the Render services, then the Postgres database, the Key Value instance and the environment group, in the Render Dashboard or with the Render API's delete requests, such as `curl -s -X DELETE "$RENDER/services/$WEB_ID" -H "Authorization: Bearer $RENDER_API_KEY"`. Render keeps no backup of a deleted Postgres database.

To repeat the setup for staging and pull request previews, put the applications in a [pipeline](https://docs.sevalla.com/pipelines/overview.md) and turn on [preview apps](https://docs.sevalla.com/pipelines/preview-apps.md).

## Troubleshoot

| Symptom | Cause | Fix |
| - | - | - |
| `pg_dump: error: aborting because of server version mismatch` | Your `pg_dump` is older than the Render Postgres server | Install the client tools of the server's major version or newer, such as 18 |
| A table's row count matches but its hash doesn't | Sevalla sorts text with the `C` collation, Render Postgres with `en_US.UTF8` | Sort with `COLLATE "C"`, as the comparison loop does |
| A table has more rows on Sevalla than on Render, such as cron run logs | The Sevalla cron job or worker wrote to the database after the copy | Deploy `storefront` after the copy, as this page does, and compare before the first scheduled run |
| `TypeError: Protocol - valkey: - is not a valid Redis protocol. Expected redis: or rediss:` (node-redis) or `ValueError: Redis URL must specify one of the following schemes (redis://, rediss://, unix://)` (redis-py) | `REDIS_URL` from a Valkey internal connection added before Oct 30, 2026 starts with `valkey://` | Change the scheme to `redis://` |
| `Client IP address is not in the allowlist.` from the Render Key Value URL | Render Key Value accepts external connections only from its IP allow list | Add your IP address, as in [Copy the Key Value data](#copy-the-key-value-data) |
| `tar: .: Cannot change mode to rwxrwxr-x: Operation not permitted` | The archive's top folder, `.`, is the disk's root on Sevalla, which belongs to root | Unpack with `--strip-components=1` |
| `sh: 1: curl: not found` | Ingot's images don't include `curl` | Download with the language's HTTP client, such as Node.js's `fetch`, or add `curl` to the image with `INGOT_RUN_PACKAGES`. See [Configure the build](https://docs.sevalla.com/applications/builds/ingot.md#configure-the-build) |
| The private service's `sevalla.app` domain answers `404` | HTTP traffic is off, as intended | Reach it from `storefront` at its internal hostname |

## Deploy with the API

This sequence does what [Create the databases](#create-the-databases) 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 creates the databases and both applications, connects them, adds the variables, turns each Render service into its process, copies the data and deploys. A script or an AI agent runs it as is, in one shell, after [Take stock of the Render services](#take-stock-of-the-render-services) and [Move the environment variables](#move-the-environment-variables), so `RENDER`, the Render IDs and `config.json` are set. 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 CLI reads it from `SEVALLA_API_TOKEN`.
* 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 an 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.
* `RENDER_API_KEY`, `curl`, `jq`, `openssl`, the PostgreSQL client tools, and Python 3 with `redis` installed and `copy_keys.py` from [Copy the Key Value data](#copy-the-key-value-data) in the current folder.
* `files.tar.gz` from [Copy the disk files](#copy-the-disk-files), behind an HTTPS link.

The API allows 20 service creations per hour per company for each kind of service; this sequence creates 2 databases and 2 applications.

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
API=https://api.sevalla.com/v3
REPO=https://github.com/acme/storefront
```

**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 Render region, here `eu-frankfurt-1` (Frankfurt, Germany) for Render's `frankfurt`:

```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 database resource types](https://api-docs.sevalla.com/v3/resources/list-database-resource-types) (`listDatabaseResourceTypesV3`) and [List disk resource types](https://api-docs.sevalla.com/v3/resources/list-disk-resource-types) (`listDiskResourceTypesV3`). Save **S1** for the processes, **DB1** for the databases (pick a larger size for more data) and the 10 GB disk:

```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')
DB_SIZE_ID=$(curl -s "$API/resources/database-resource-types" -H "Authorization: Bearer $SEVALLA_API_KEY" \
  | jq -r '.[] | select(.name == "db1") | .id')
DISK_SIZE_ID=$(curl -s "$API/resources/disk-resource-types" -H "Authorization: Bearer $SEVALLA_API_KEY" \
  | jq -r '.[] | select(.size_gib == 10) | .id')
```

CLI: `sevalla resources process-types`, `sevalla resources db-types` and `sevalla resources disk-types`. MCP: `get_platform_options` for the process and database sizes, and `sevalla_api_read` with `listDiskResourceTypesV3`.

**3. Look up the database versions**

[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 PostgreSQL and Valkey versions, which are at least as new as Render's:

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

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

**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 Valkey database**

[Create database](https://api-docs.sevalla.com/v3/databases/create-database) (`createDatabaseV3`) again, for Render Key Value:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
KV_PASSWORD=$(openssl rand -hex 16)
KV_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 "$VALKEY_VERSION" --arg password "$KV_PASSWORD" \
    '{display_name: "storefront-cache", type: "valkey", version: $version, cluster_id: $cluster,
      resource_type_id: $size, db_name: "storefront", db_password: $password}')" \
  | jq -r .id)
```

CLI: `sevalla databases create --name storefront-cache --type valkey --db-version "$VALKEY_VERSION" --cluster "$CLUSTER_ID" --resource-type "$DB_SIZE_ID" --db-name storefront --db-password "$KV_PASSWORD"`. MCP: `create_database` with `type: "valkey"`.

**6. Create the private service application**

[Create application](https://api-docs.sevalla.com/v3/applications/create-application) (`createApplicationV3`) creates `storefront-files` with a web process at **S1**, without deploying it:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
FILES_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-files", cluster_id: $cluster, source: "privateGit", git_type: "github",
      repo_url: $repo, default_branch: "main"}')" \
  | jq -r .id)
```

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

**7. Make its web process private**

[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`) sets the private service's start command and turns HTTP traffic off. The response has the internal hostname the main application uses:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
FILES_WEB_ID=$(curl -s "$API/applications/$FILES_APP_ID/processes" -H "Authorization: Bearer $SEVALLA_API_KEY" \
  | jq -r '.data[] | select(.type == "web") | .id')
FILES_HOST=$(curl -s -X PATCH "$API/applications/$FILES_APP_ID/processes/$FILES_WEB_ID" \
  -H "Authorization: Bearer $SEVALLA_API_KEY" -H "Content-Type: application/json" \
  -d '{"entrypoint": "exec node files.js", "is_ingress_enabled": false}' | jq -r .internal_hostname)
echo "$FILES_HOST"
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
storefront-files-x7k2m-web.storefront-files-x7k2m.svc.cluster.local
```

CLI: `sevalla apps processes update "$FILES_WEB_ID" --app-id "$FILES_APP_ID" --command "exec node files.js"`; the CLI can't turn HTTP traffic off. MCP: `sevalla_api_update` with `updateProcessV3` and the same body.

**8. Attach the disk**

[Attach or update process disk](https://api-docs.sevalla.com/v3/applications/attach-or-update-process-disk) (`updateProcessDiskV3`) mounts a 10 GB disk at the Render disk's mount path:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s -X PUT "$API/applications/$FILES_APP_ID/processes/$FILES_WEB_ID/disk" \
  -H "Authorization: Bearer $SEVALLA_API_KEY" -H "Content-Type: application/json" \
  -d "$(jq -n --arg size "$DISK_SIZE_ID" '{resource_type_id: $size, mount_path: "/var/data"}')" \
  | jq -c '{size_gib, mount_path}'
```

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

CLI: `sevalla apps processes disk update "$FILES_WEB_ID" --app-id "$FILES_APP_ID" --resource-type "$DISK_SIZE_ID" --mount-path /var/data`. MCP: `sevalla_api_update` with `updateProcessDiskV3` and the same body.

**9. Create the main application**

[Create application](https://api-docs.sevalla.com/v3/applications/create-application) (`createApplicationV3`) creates `storefront` with a web process at **S1**, which can have custom domains:

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

**10. Wait for the databases**

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

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
for id in "$PG_ID" "$KV_ID"; do
  for i in $(seq 1 60); do
    [ "$(curl -s "$API/databases/$id" -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r '.status // empty')" = ready ] && break
    sleep 10
  done
done
PG=$(curl -s "$API/databases/$PG_ID" -H "Authorization: Bearer $SEVALLA_API_KEY")
KV=$(curl -s "$API/databases/$KV_ID" -H "Authorization: Bearer $SEVALLA_API_KEY")
```

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

**11. Connect the services**

[Create internal connection](https://api-docs.sevalla.com/v3/applications/create-internal-connection) (`createApplicationInternalConnectionV3`), once per database and once for the private service application:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
for target in "database $PG_ID" "database $KV_ID" "app $FILES_APP_ID"; do
  set -- $target
  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 type "$1" --arg id "$2" '{target_id: $id, target_type: $type}')" > /dev/null
done
curl -s "$API/applications/$APP_ID/internal-connections" -H "Authorization: Bearer $SEVALLA_API_KEY" \
  | jq -r '.[].target_display_name'
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
orders-db
storefront-cache
storefront-files
```

The API adds no environment variables; the next step adds them. CLI: `sevalla apps internal-connections create "$APP_ID" --target-id "$PG_ID" --target-type database`, and `--target-type app` for the application. MCP: `sevalla_api_create` with `createApplicationInternalConnectionV3` and the same body.

**12. Add the environment variables**

[Create environment variable](https://api-docs.sevalla.com/v3/applications/create-environment-variable) (`createEnvironmentVariableV3`), once per variable: `DATABASE_URL`, `REDIS_URL` and `FILES_HOSTPORT` in place of Render's internal URLs, then every entry of `config.json`:

```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"}'
  jq -n --arg host "$(echo "$KV" | jq -r .internal_hostname)" --arg port "$(echo "$KV" | jq -r .internal_port)" --arg password "$KV_PASSWORD" \
    '{key: "REDIS_URL", value: "redis://:\($password)@\($host):\($port)/0"}'
  jq -n --arg host "$FILES_HOST" '{key: "FILES_HOSTPORT", value: "\($host):8080"}'
  jq -c 'to_entries[] | {key, value}' config.json
} | 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
```

It prints each key as it's added. CLI: `sevalla apps env-vars create "$APP_ID" --key <key> --value <value>`, once per variable. MCP: `set_environment_variables` with all the variables in one call.

**13. Set up the web process**

[Update process](https://api-docs.sevalla.com/v3/applications/update-process) (`updateProcessV3`) gives the web process a start command that writes the secret file first, and a readiness probe for Render's health check path:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
WEB_PROCESS_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_PROCESS_ID" \
  -H "Authorization: Bearer $SEVALLA_API_KEY" -H "Content-Type: application/json" \
  -d "$(jq -n '{entrypoint: "printf %s \"$APP_CONFIG_JSON\" > /tmp/app-config.json && exec node web.js",
    readiness_probe: {httpGet: {path: "/healthz", port: 8080, scheme: "HTTP"},
      initialDelaySeconds: 0, periodSeconds: 10, timeoutSeconds: 1, successThreshold: 1, failureThreshold: 3}}')" \
  | jq -r .entrypoint
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
printf %s "$APP_CONFIG_JSON" > /tmp/app-config.json && exec node web.js
```

CLI: `sevalla apps processes update "$WEB_PROCESS_ID" --app-id "$APP_ID" --command '<command>'`; the CLI can't set probes. MCP: `sevalla_api_update` with `updateProcessV3` and the same body.

**14. Create the processes**

[Create process](https://api-docs.sevalla.com/v3/applications/create-process) (`createProcessV3`), once per Render service that runs the same code: a job with the start policy `beforeDeployment` for the pre-deploy command, a background worker with its command after `exec`, and a cron job with Render's schedule:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
process() {
  curl -s -X POST "$API/applications/$APP_ID/processes" \
    -H "Authorization: Bearer $SEVALLA_API_KEY" -H "Content-Type: application/json" \
    -d "$(jq -n --arg size "$S1_ID" --argjson extra "$1" \
      '{resource_type_id: $size, scaling_strategy: {type: "manual", config: {instanceCount: 1}}} + $extra')"
}
process '{"display_name": "migrate", "type": "job", "entrypoint": "node migrate.js", "job_start_policy": "beforeDeployment"}' \
  | jq -c '{display_name, type, entrypoint}'
WORKER_PROCESS_ID=$(process '{"display_name": "worker", "type": "worker", "entrypoint": "exec node worker.js"}' | jq -r .id)
CRON_PROCESS_ID=$(process '{"display_name": "cleanup", "type": "cron", "entrypoint": "node cron.js", "schedule": "*/5 * * * *"}' | jq -r .id)
```

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

CLI: `sevalla apps processes create "$APP_ID" --name worker --type worker --resource-type "$S1_ID" --command "exec node worker.js"`, with `--type job --job-start-policy beforeDeployment` for the job and `--type cron --schedule "*/5 * * * *"` for the cron job. MCP: `sevalla_api_create` with `createProcessV3` and the same body.

**15. Deploy the private service**

[Trigger deployment](https://api-docs.sevalla.com/v3/applications/trigger-deployment) (`triggerDeploymentV3`) deploys the latest commit of the branch, and [Get deployment](https://api-docs.sevalla.com/v3/applications/get-deployment) (`getDeploymentV3`) reports the `status`, which ends as `success`, `failed`, `cancelled` or `skipped`. The disk copy needs this web process running. The loop keeps polling through a failed request, for up to 30 minutes:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
FILES_DEPLOYMENT_ID=$(curl -s -X POST "$API/applications/$FILES_APP_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/applications/$FILES_APP_ID/deployments/$FILES_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 trigger "$FILES_APP_ID"`, then `sevalla apps deployments get "$FILES_DEPLOYMENT_ID" --app-id "$FILES_APP_ID"`. MCP: `trigger_deployment`, then `get_deployment`.

**16. Open the databases to your computer**

Stop writes on Render first, as in [Stop writes on Render](#stop-writes-on-render). [Toggle external connection](https://api-docs.sevalla.com/v3/databases/toggle-external-connection) (`toggleDatabaseExternalConnectionV3`) turns each database's external connection on, then [Get database](https://api-docs.sevalla.com/v3/databases/get-database) (`getDatabaseV3`) returns `external_hostname` and `external_port` once it's ready:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
for id in "$PG_ID" "$KV_ID"; do
  curl -s -X POST "$API/databases/$id/external-connection/toggle" -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r .message
done
for id in "$PG_ID" "$KV_ID"; do
  for i in $(seq 1 60); do
    DB=$(curl -s "$API/databases/$id" -H "Authorization: Bearer $SEVALLA_API_KEY")
    [ "$(echo "$DB" | jq -r .status)" = ready ] && [ "$(echo "$DB" | jq -r .external_hostname)" != null ] && break
    sleep 10
  done
done
PG=$(curl -s "$API/databases/$PG_ID" -H "Authorization: Bearer $SEVALLA_API_KEY")
KV=$(curl -s "$API/databases/$KV_ID" -H "Authorization: Bearer $SEVALLA_API_KEY")
SEVALLA_DB_URL="postgres://storefront:$PG_PASSWORD@$(echo "$PG" | jq -r .external_hostname):$(echo "$PG" | jq -r .external_port)/storefront"
SEVALLA_KV_URL="redis://:$KV_PASSWORD@$(echo "$KV" | jq -r .external_hostname):$(echo "$KV" | jq -r .external_port)/0"
```

It prints `External connection toggle started` twice. CLI: `sevalla databases connections external-toggle "$PG_ID"`. MCP: `sevalla_api_create` with `toggleDatabaseExternalConnectionV3`.

**17. Restore PostgreSQL**

The same commands as [Copy the PostgreSQL data](#copy-the-postgresql-data), with the Render API's external URL:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
RENDER_DB_URL=$(curl -s "$RENDER/postgres/$PG_RENDER_ID/connection-info" \
  -H "Authorization: Bearer $RENDER_API_KEY" | jq -r .externalConnectionString)
pg_dump --dbname="$RENDER_DB_URL" --format=custom --no-owner --no-privileges --file=render.dump
pg_restore --dbname="$SEVALLA_DB_URL" --clean --if-exists --no-owner --no-privileges render.dump
```

Then compare the tables with the loop in [Copy the PostgreSQL data](#copy-the-postgresql-data).

**18. Restore the keys**

Allow your IP address on the Render Key Value instance, then run `copy_keys.py` from [Copy the Key Value data](#copy-the-key-value-data):

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
MY_IP=$(curl -s https://checkip.amazonaws.com)
curl -s -X PATCH "$RENDER/key-value/$KV_RENDER_ID" -H "Authorization: Bearer $RENDER_API_KEY" \
  -H "Content-Type: application/json" \
  -d "$(jq -n --arg cidr "$MY_IP/32" '{ipAllowList: [{cidrBlock: $cidr, description: "migration"}]}')" > /dev/null
RENDER_KV_URL=$(curl -s "$RENDER/key-value/$KV_RENDER_ID/connection-info" \
  -H "Authorization: Bearer $RENDER_API_KEY" | jq -r .externalConnectionString)
RENDER_KV_URL="$RENDER_KV_URL" SEVALLA_KV_URL="$SEVALLA_KV_URL" python3 copy_keys.py
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
Copied 324 keys
Render: 324 keys, Sevalla: 324 keys
```

**19. Unpack the files on the disk**

Put `files.tar.gz` from [Copy the disk files](#copy-the-disk-files) behind an HTTPS link only you know, such as a presigned link to an [object storage](https://docs.sevalla.com/object-storage/overview.md) bucket. [Run a command in a process](https://api-docs.sevalla.com/v3/applications/run-a-command-in-a-process) (`execProcessCommandV3`) then downloads and unpacks it into the disk, and hashes the result for the comparison with Render:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
ARCHIVE_URL='<HTTPS link to files.tar.gz>'
exec_files() {
  curl -s -X POST "$API/applications/$FILES_APP_ID/processes/$FILES_WEB_ID/exec" \
    -H "Authorization: Bearer $SEVALLA_API_KEY" -H "Content-Type: application/json" \
    -d "$(jq -n --arg script "$1" --arg arg "${2-}" '{command: ["sh", "-c", $script, "sh", $arg], timeout: 60}')"
}
exec_files 'node -e "fetch(process.argv[1]).then(r => require(\"stream\").Readable.fromWeb(r.body).pipe(process.stdout))" "$1" \
  | tar -xzf - --strip-components=1 -C /var/data' "$ARCHIVE_URL" | jq -c '{exit_code, stderr}'
exec_files 'cd /var/data && find . -type f | sort | xargs sha256sum | sha256sum' | jq -r .stdout
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{"exit_code":0,"stderr":""}
6ea785e3dcf17624ce5c2afabb05de5ad4a601ab490d6374ace0753c23a0d64b  -
```

The second line must match the hash from the Render shell. The command runs without a shell unless you start one, as here with `sh -c`, and stops after 60 seconds at most; for an archive that takes longer to download, use the web terminal. Ingot's Node.js image has no `curl`, so the download uses Node.js's `fetch`; in an image of another language, use its HTTP client. CLI: `sevalla apps processes exec "$FILES_WEB_ID" --app-id "$FILES_APP_ID" --timeout 60 -- sh -c '<script>'`. MCP: `sevalla_api_create` with `execProcessCommandV3` and the same body.

**20. Deploy the main application**

[Trigger deployment](https://api-docs.sevalla.com/v3/applications/trigger-deployment) (`triggerDeploymentV3`) and [Get deployment](https://api-docs.sevalla.com/v3/applications/get-deployment) (`getDeploymentV3`), as for `storefront-files`. The `migrate` job runs first:

```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)
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 trigger "$APP_ID"`. MCP: `trigger_deployment`.

**21. Close the databases again**

[Toggle external connection](https://api-docs.sevalla.com/v3/databases/toggle-external-connection) (`toggleDatabaseExternalConnectionV3`) turns the external connections off. The applications keep using the internal connections:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
for id in "$PG_ID" "$KV_ID"; do
  curl -s -X POST "$API/databases/$id/external-connection/toggle" -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r .message
done
```

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

**22. Turn on auto-deploy**

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

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

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

Then run the checks in [Deploy and verify](#deploy-and-verify). [Add application domain](https://api-docs.sevalla.com/v3/applications/add-application-domain) (`addApplicationDomainV3`) adds each custom domain and returns the DNS records in `dns_records`.


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