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

# Deploy a Payload application

> Deploy Payload 3 on Next.js to Sevalla with Ingot or a Dockerfile: PostgreSQL with migrations in a job, PAYLOAD_SECRET, and uploads in object storage.

A Payload project runs on Sevalla as a Next.js application: [Ingot](https://docs.sevalla.com/applications/builds/ingot.md) installs its dependencies, runs `next build`, and starts the web process with the `start` script, `next start`, which serves the site, the admin panel and the API on the port in the `PORT` environment variable. Its data lives in a Sevalla PostgreSQL database, whose schema a job migrates before each deployment, and uploaded files go to an object storage bucket, so they survive every deployment.

## At a glance

| | |
| - | - |
| Source | A Git repository |
| Build | [Ingot](https://docs.sevalla.com/applications/builds/ingot.md) (Node.js 24, npm, Next.js), or the [Dockerfile](#deploy-with-a-dockerfile) |
| Start command | `cross-env NODE_OPTIONS=--no-deprecation next start`, from `scripts.start` in `package.json` |
| Port | The `PORT` environment variable, `8080` by default |
| Processes | Web process; `Migrate` job (`npx payload migrate`, **Before deployment**) |
| Services | PostgreSQL 18 over an internal connection; a private bucket for uploads |
| Disk | None |
| Size | **H1** for the web process, **S1** for the `Migrate` job, **DB1** for the database |

### Environment variables

| Variable | Required | Available during | Secret | Value |
| - | - | - | - | - |
| `DATABASE_URL` | Yes | Runtime | Yes | `DB_URL` from the internal connection, renamed: `postgres://<user>:<password>@<host>:<port>/<database>` |
| `PAYLOAD_SECRET` | Yes | Runtime | Yes | Generate with `openssl rand -hex 32` |
| `S3_ENDPOINT` | Yes | Runtime | No | The bucket's **Endpoint**, such as `https://<account-id>.r2.cloudflarestorage.com` |
| `S3_BUCKET` | Yes | Runtime | No | The bucket's **Bucket name**, such as `storefront-media-q8w2e` |
| `S3_ACCESS_KEY_ID` | Yes | Runtime | Yes | The bucket's **Access key** |
| `S3_SECRET_ACCESS_KEY` | Yes | Runtime | Yes | The bucket's **Secret key** |
| `PORT` | No | Runtime | No | Set by Sevalla to the web process port |

The internal connection also adds `DB_HOST`, `DB_PORT`, `DB_DATABASE`, `DB_USERNAME` and `DB_PASSWORD`, which Payload doesn't read. The `Migrate` job gets the same variables as the web process.

## Before you begin

* A Sevalla company with a payment method, and a GitHub, GitLab or Bitbucket account for the repository. See [Git providers](https://docs.sevalla.com/git/overview.md).
* Node.js 24.22.1 or later on your computer. Payload 3.92 needs Node.js 22.23.4 or later on the 22 line, or 24.22.1 or later.
* PostgreSQL on your computer for development. With Docker:

  ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
  docker run -d --name storefront-db -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=storefront -p 5432:5432 postgres:18
  ```

## Create the project

Create a project from Payload's blank template with the PostgreSQL adapter, then update Next.js and sharp and add the S3 storage adapter:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
npx create-payload-app@latest --name storefront --template blank --db postgres --db-connection-string postgresql://postgres:postgres@localhost:5432/storefront --use-npm --no-agent --no-git
cd storefront
npm install next@16 sharp@0.35 @payloadcms/storage-s3
```

* The flags answer every question `create-payload-app` would ask. The command writes `DATABASE_URL` and a random `PAYLOAD_SECRET` to `.env`, which `.gitignore` keeps out of the repository.
* The template pins Next.js 16.3.3 and sharp 0.35.4, which have published security fixes. `next@16` and `sharp@0.35` install the newest release of each line.
* The template has `Users` and `Media` collections. `Media` is the upload collection.

## Prepare the project for Sevalla

### Store uploads in object storage

In `src/payload.config.ts`, import the adapter and replace `plugins: [],` with the S3 storage plugin for the `media` collection:

```ts src/payload.config.ts theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import { s3Storage } from '@payloadcms/storage-s3'

// ...

  plugins: [
    s3Storage({
      collections: {
        media: true,
      },
      bucket: process.env.S3_BUCKET || '',
      config: {
        endpoint: process.env.S3_ENDPOINT,
        region: 'auto',
        credentials: {
          accessKeyId: process.env.S3_ACCESS_KEY_ID || '',
          secretAccessKey: process.env.S3_SECRET_ACCESS_KEY || '',
        },
      },
    }),
  ],
```

Sevalla buckets run on Cloudflare R2, which ignores the region, but the AWS SDK needs one: `auto`. The plugin turns off local storage for `media`, uploads each file to the bucket, and serves it through Payload at `/api/media/file/<filename>`, so the collection's access rules still apply and the bucket can stay private.

### Create the first migration

On your computer, `npm run dev` changes the database schema to match your collections on its own. In production, Payload doesn't: it applies migrations. Create the first one from the collections:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
npx payload migrate:create initial
```

Payload writes the migration to `src/migrations/`, with an `index.ts` that lists it. Commit the folder: the `Migrate` job you create in [Deploy to Sevalla](#deploy-to-sevalla) applies it. After each change to a collection, create another migration the same way.

### Pin the Node.js version

The template's `engines.node`, `^22.23.4 || >=24.22.1`, lets Ingot build on its default line. Pin Node.js 24, from the first release Payload supports, so the build doesn't change when a new Ingot release moves its default line:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
npm pkg set engines.node="^24.22.1"
```

| File | Why Sevalla needs it |
| - | - |
| `package.json` | `scripts.build` runs `next build`, `scripts.start` runs `next start` for the web process, and `engines.node` pins Node.js 24. |
| `package-lock.json` | Ingot installs exactly these versions with `npm ci`. |
| `.npmrc` | From the template: `legacy-peer-deps=true`, which `npm ci` needs to install the same tree as `npm install`. |
| `src/payload.config.ts` | Reads `DATABASE_URL`, `PAYLOAD_SECRET` and the bucket's variables when the server starts. |
| `src/migrations/` | The schema, applied by the `Migrate` job. |

Ingot detects Next.js and needs no other configuration: `next start` listens on `PORT`, which Sevalla sets to `8080`, on all interfaces, and the build needs no environment variables. The template's `Dockerfile` is for a standalone build that this guide doesn't use; Ingot ignores it, and [Deploy with a Dockerfile](#deploy-with-a-dockerfile) replaces it. Run `npm run dev` and open `http://localhost:3000/admin` to check the project locally. Then commit everything and push it to your Git provider:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
git init
git add .
git commit -m "Prepare for Sevalla"
git branch -M main
git remote add origin https://github.com/acme/storefront.git
git push -u origin main
```

## Deploy to Sevalla

These steps build with [Ingot](https://docs.sevalla.com/applications/builds/ingot.md). To build from your own Dockerfile instead, see [Deploy with a Dockerfile](#deploy-with-a-dockerfile).

**1. Create the database**

In the sidebar, click **Databases**, then click **Create database**. Under **Database**, choose **PostgreSQL** and the newest version. Enter `orders-db` as the **Display name**, choose a **Data center** and a **Size**, then click **Create database**. See [Create a database](https://docs.sevalla.com/databases/create.md) for every field.

**2. Create the bucket**

In the sidebar, click **Object storage**, then click **Create object storage**. Enter `storefront-media` as the **Name**, choose the **Location** closest to the database, and click **Create object storage**. See [Create a bucket](https://docs.sevalla.com/object-storage/create.md).

**3. Create the 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**. Under **Location**, choose the same **Data center** as the database. Keep the **Size** at **H1** or choose a larger one, then click **Create application**. The application opens on its **Overview** tab with the status **Not deployed yet**: creating an application doesn't deploy it.

**4. 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**. In the list of variables, change the key `DB_URL` to `DATABASE_URL`, then click **Add internal connection**. See [Connect an application](https://docs.sevalla.com/databases/connect.md#connect-an-application).

**5. Add the environment variables**

Click the **Environment variables** tab, then click **Add environment variables**. Add these variables and click **Add environment variables**:

| Variable | Value |
| - | - |
| `PAYLOAD_SECRET` | A random string that signs sign-in tokens. Generate one with `openssl rand -hex 32`, and keep it: changing it signs every user out. |
| `S3_ENDPOINT` | The bucket's **Endpoint**, from its **Settings** tab |
| `S3_BUCKET` | The bucket's **Bucket name**, such as `storefront-media-q8w2e`, not its display name |
| `S3_ACCESS_KEY_ID` | The bucket's **Access key** |
| `S3_SECRET_ACCESS_KEY` | The bucket's **Secret key** |

See [Connect with the S3 API](https://docs.sevalla.com/object-storage/connect.md#connection-values) for where each bucket value is.

**6. Create the migration job**

Click the **Processes** tab, then click **Create process**. Choose **Job**, enter `Migrate` as the **Name** and `npx payload migrate` as the **Custom start command**. Keep the **Start policy** at **Before deployment**, choose **S1** as the **Resource**, then click **Create process**. `payload migrate` loads your whole configuration and needs more memory than the 300 MB of **H1**.

**7. Deploy**

Click the **Overview** tab, then click **Deploy now** under **Recent deployments**. Check the **Branch** and click **Deploy**.

**8. Turn on auto-deploy**

To deploy every push to the branch automatically, click the **Settings** tab, 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).

![The Create process sheet with Job selected, the Name and Custom start command fields, the Start policy set to Before deployment, and the H1 resource](https://assets.sevalla.com/screenshots/light/app-processes-job-create.png)

The job runs after the build and before the new version gets traffic. If it fails, the deployment fails and the previous version keeps serving.

## Verify the deployment

1. On the **Deployments** tab, click the deployment. Its **Build log** shows Ingot's [build plan](https://docs.sevalla.com/applications/builds/ingot.md#read-the-build-plan): Node.js 24 from `engines.node`, npm from `package-lock.json`, Next.js, the start command from the `start` script, and dev dependencies removed after the build.

   ```text Build log theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   ╭─ ■ Next.js · Node.js 24 ─────────────────────────────────────────────────────────────────────────────╮
   │ Node.js          24                                                  package.json engines "^24.22.1" │
   │ Package manager  npm                                                 package-lock.json               │
   │ Framework        Next.js                                             package.json                    │
   │ Start            cross-env NODE_OPTIONS=--no-deprecation next start  package.json scripts.start      │
   │ DevDependencies  removed after the build                                                             │
   ├─ Image ──────────────────────────────────────────────────────────────────────────────────────────────┤
   │ Build            node:24-trixie                                                                      │
   │ Runtime          node:24-trixie-slim                                                                 │
   │ User             1000:1000                                                                           │
   │ Init             tini                                                                                │
   │ Port             $PORT, default 8080                                                                 │
   ╰─────────────────────────────────────────────────────────────────────────────────────── Ingot v0.4.1 ─╯
   ```

   During the rollout, the build log shows `Migrate has been successful`. On the **Logs** tab, the `Migrate` process's output lists `Migrating: <timestamp>_initial`, then `Done.`
2. Wait for the deployment's status to change to **Deployed**. The application's status next to its name changes to **Running**.
3. Open the application's domain followed by `/admin`, for example `https://storefront-x7k2m.sevalla.app/admin`. Payload opens its **Create first user** form. Enter an **Email** and a **Password**, confirm the password, and click **Create**.
4. Click **Media**, then **Create New**. Choose an image with **Select a file**, enter an **Alt** text, and click **Save**. The file is on the bucket's **Objects** tab, and opens at `https://storefront-x7k2m.sevalla.app/api/media/file/<filename>`.
5. Deploy again with **Deploy now**. When the deployment is **Deployed**, the image is still in **Media** and still opens: the record is in PostgreSQL and the file in the bucket, not on the instance's disk, which each deployment replaces.

To check from a terminal, for example as an agent, use Payload's REST API instead of steps 3 and 4. `first-register` works only while the project has no users. With `mug.png` in the current folder:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
APP_URL=https://storefront-x7k2m.sevalla.app
curl -s -o /dev/null -w '%{http_code}\n' $APP_URL/admin
PASSWORD="Aa1$(openssl rand -hex 12)"
TOKEN=$(curl -s $APP_URL/api/users/first-register -H "Content-Type: application/json" \
  -d "{\"email\":\"ana@example.com\",\"password\":\"$PASSWORD\"}" | jq -r .token)
curl -s $APP_URL/api/media -H "Authorization: JWT $TOKEN" -F "file=@mug.png" -F '_payload={"alt":"Coffee mug"}' | jq -c '{message, url: .doc.url}'
curl -s -o /dev/null -w '%{http_code} %{content_type}\n' $APP_URL/api/media/file/mug.png
```

Keep the password to sign in as `ana@example.com`. The output:

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
200
{"message":"Media successfully created.","url":"/api/media/file/mug.png"}
200 image/png
```

With the API, [Get deployment](https://api-docs.sevalla.com/v3/applications/get-deployment) returns the deployment's status and [Get application deployment logs](https://api-docs.sevalla.com/v3/applications/get-application-deployment-logs) its build log; see [Deploy with the API](#deploy-with-the-api).

![The application header with its name, Running status, repository, branch and the domain that opens the application](https://assets.sevalla.com/screenshots/light/app-overview-address.png)

If the deployment fails, read its build log, then see [Troubleshoot](#troubleshoot) and [Troubleshoot Ingot builds](https://docs.sevalla.com/applications/builds/ingot.md#troubleshoot-ingot-builds).

## Deploy with a Dockerfile

Ingot needs no Dockerfile. Write your own when you want full control of the image: the base image, the system packages and every layer. Replace the template's `Dockerfile` and add a `.dockerignore` at the root of the repository:

```dockerfile Dockerfile theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
FROM node:24-trixie-slim AS build
WORKDIR /app
COPY package.json package-lock.json .npmrc ./
RUN npm ci
COPY . .
RUN npm run build && rm -rf .next/cache && npm prune --omit=dev

FROM node:24-trixie-slim
WORKDIR /app
ENV NODE_ENV=production PORT=8080 NODE_OPTIONS=--no-deprecation NPM_CONFIG_UPDATE_NOTIFIER=false
COPY --from=build /app ./
# Next.js writes optimized images to .next/cache while it runs
RUN mkdir .next/cache && chown 1000:1000 .next/cache
USER 1000:1000
EXPOSE 8080
CMD ["node_modules/.bin/next", "start"]
```

| Line or choice | Why |
| - | - |
| `node:24-trixie-slim` | The official Node.js 24 image on Debian 13, the line `engines.node` pins. The tag follows Node.js 24's patch releases, so each build gets its security fixes; a digest would stay on one release. `sharp` installs a prebuilt binary, so neither stage needs compilers. |
| Two stages | `next build` runs in the first stage, which then drops the build cache and the dev dependencies. The final stage copies the result. It keeps `src/` and the full `node_modules`, because the `Migrate` job runs `payload migrate` from the same image, and that loads `src/payload.config.ts` and `src/migrations/`. |
| `package.json`, `package-lock.json` and `.npmrc` first | Docker reuses the `npm ci` layer while they don't change, so a code change skips the install. |
| `USER 1000:1000` | Payload runs as the image's unprivileged `node` user. The application stays owned by root, so it can read it but not change it; only `.next/cache` belongs to the user. |
| No init process | `next start` handles `SIGTERM` itself and stops at once. |
| `CMD ["node_modules/.bin/next", "start"]` | The `start` script without npm and `cross-env` in between. `NODE_OPTIONS=--no-deprecation` does what `cross-env` did. Next.js listens on `PORT`, which Sevalla sets at runtime; the image's default matches Sevalla's. |

```text .dockerignore theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
.git
node_modules
.next
.env*
npm-debug.log*
Dockerfile
.dockerignore
```

Commit and push both files. Ingot doesn't use the `Dockerfile`, so you can keep it in a repository that builds with Ingot, but it adds the `.dockerignore` patterns to its own, and its build plan lists them in an `Ignored` row. Then switch the application to the Dockerfile build strategy:

**1. Choose the Dockerfile build strategy**

On the application's **Settings** tab, in the **Build strategy** section, click **Update build strategy**. Click **Dockerfile**, keep the **Dockerfile path** `Dockerfile` and the **Docker context** `.`, then click **Update build strategy**.

**2. Deploy**

Click the **Overview** tab, then click **Deploy now** under **Recent deployments**. Check the **Branch** and click **Deploy**.

The deployment's **Build log** shows `📦 Building and pushing docker image using Dockerfile...` instead of Ingot's build plan, then `Migrate has been successful`, and its **Dockerfile** tab shows your `Dockerfile`. When the deployment is **Deployed**, the admin panel opens and the images in **Media** still load. See [Dockerfile](https://docs.sevalla.com/applications/builds/dockerfile.md) for build arguments and private base images.

## Deploy changes

With **Auto-deploy** on, every push to the branch starts a deployment; otherwise, click **Deploy now** for each change. Each deployment builds the image, runs the `Migrate` job, then replaces the web process's instances. When you change a collection, run `npx payload migrate:create` on your computer and commit the new migration with the code. See [Deployments](https://docs.sevalla.com/applications/deployments.md).

## Use environment variables

Add configuration on the application's **Environment variables** tab and read it with `process.env` in `src/payload.config.ts` and in server code. Variables apply at the next deployment. Variables that start with `NEXT_PUBLIC_` are built into the browser code by `next build`, so they must be **Available during buildtime**, and a change needs a new build. See [Environment variables](https://docs.sevalla.com/applications/environment-variables.md).

## Add a custom domain

To serve the application from your own domain, such as `cms.example.com`, add the domain on the application's **Domains** tab and create the DNS records that Sevalla shows. Custom domains need a web process larger than **H1**, so [scale](https://docs.sevalla.com/applications/scaling.md) the web process first. See [Custom domains](https://docs.sevalla.com/domains/overview.md).

## Troubleshoot

| Symptom | Cause | Fix |
| - | - | - |
| The deployment fails at `Migrate has failed`, and the job's logs are empty | The job ran out of memory on **H1** and was stopped (exit code `137`) before it printed anything | Update the `Migrate` job and choose **S1** or larger as the **Resource**, then deploy. |
| Pages return `500`, and the runtime logs show `Error: missing secret key. A secret key is needed to secure Payload.` | `PAYLOAD_SECRET` isn't set | Add `PAYLOAD_SECRET`, then deploy. |
| Uploads fail with `Something went wrong.`, and the runtime logs show `There was an error while uploading files corresponding to the collection media`, then `Access Denied` | `S3_BUCKET` holds the bucket's display name, such as `storefront-media`: the keys work only for the bucket name | Set `S3_BUCKET` to the **Bucket name** from the bucket's **Settings** tab, then deploy. |

## Deploy with the API

This sequence creates the same deployment as [Deploy to Sevalla](#deploy-to-sevalla) through the [Sevalla API](https://docs.sevalla.com/developer-tools/api.md), so an agent or a script can run it as is. Each step names the operation, shows the `curl` command, and gives the matching [CLI](https://docs.sevalla.com/developer-tools/cli.md) command and [MCP server](https://docs.sevalla.com/ai-agents/overview.md) tool.

You need:

* An [API key](https://docs.sevalla.com/developer-tools/api-keys.md) with the **Company developer** role, in the `SEVALLA_API_KEY` environment variable. The CLI reads the same key from `SEVALLA_API_TOKEN`.
* `curl`, `jq` and `openssl`, and the project pushed to GitHub as in [Prepare the project for Sevalla](#prepare-the-project-for-sevalla).
* A GitHub account that can read the repository, connected to Sevalla. See [Git providers](https://docs.sevalla.com/git/overview.md). The API can't list connected accounts: when none can read the repository, [Create application](https://api-docs.sevalla.com/v3/applications/create-application) fails with `400` and `Cannot access repository https://github.com/acme/storefront: Repository not found or you don't have access.`

A call that fails returns `status` and `message` instead of the resource, and `jq -r .id` prints `null`: stop and read the message.

**1. Look up the location and the sizes**

[List clusters](https://api-docs.sevalla.com/v3/resources/list-clusters) (`listClustersV3`) returns every location your company can use, with its region ID. This guide uses Frankfurt, Germany, `eu-frankfurt-1`. [List process resource types](https://api-docs.sevalla.com/v3/resources/list-process-resource-types) (`listProcessResourceTypesV3`) and [List database resource types](https://api-docs.sevalla.com/v3/resources/list-database-resource-types) (`listDatabaseResourceTypesV3`) return the sizes.

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
CLUSTER_ID=$(curl -s https://api.sevalla.com/v3/resources/clusters \
  -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r '.[] | select(.location == "eu-frankfurt-1") | .id')
SIZES=$(curl -s https://api.sevalla.com/v3/resources/process-resource-types -H "Authorization: Bearer $SEVALLA_API_KEY")
H1_ID=$(jq -r '.[] | select(.name == "h1") | .id' <<< "$SIZES")
S1_ID=$(jq -r '.[] | select(.name == "s1") | .id' <<< "$SIZES")
DB_SIZE_ID=$(curl -s https://api.sevalla.com/v3/resources/database-resource-types \
  -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r '.[] | select(.name == "db1") | .id')
```

CLI: `sevalla resources clusters`, `sevalla resources process-types` and `sevalla resources db-types`. MCP: `get_platform_options`.

**2. Create the application**

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

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
APP_ID=$(curl -s https://api.sevalla.com/v3/applications \
  -H "Authorization: Bearer $SEVALLA_API_KEY" \
  -H "Content-Type: application/json" \
  -d @- <<JSON | jq -r .id
{
  "display_name": "storefront",
  "cluster_id": "$CLUSTER_ID",
  "source": "privateGit",
  "git_type": "github",
  "repo_url": "https://github.com/acme/storefront",
  "default_branch": "main"
}
JSON
)
```

CLI: `sevalla apps create --name storefront --cluster "$CLUSTER_ID" --source privateGit --git-type github --repository https://github.com/acme/storefront --branch main`. MCP: `create_application` with `deploy` set to `false`.

**3. Size the web process**

The API creates the web process at **S1**, while the dashboard creates it at **H1**, which runs `next start`. [List processes](https://api-docs.sevalla.com/v3/applications/list-processes) (`listProcessesV3`) returns the web process, and [Update process](https://api-docs.sevalla.com/v3/applications/update-process) (`updateProcessV3`) sets its size. Keep **S1**, and skip this step, to add a custom domain later.

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
WEB_ID=$(curl -s https://api.sevalla.com/v3/applications/$APP_ID/processes \
  -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r '.data[] | select(.type == "web") | .id')
curl -s -X PATCH https://api.sevalla.com/v3/applications/$APP_ID/processes/$WEB_ID \
  -H "Authorization: Bearer $SEVALLA_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"resource_type_id\": \"$H1_ID\"}" | jq -r .resource_type_name
```

CLI: `sevalla apps processes update "$WEB_ID" --app-id "$APP_ID" --resource-type "$H1_ID"`. MCP: `scale_process` with `size_id` and `apply_now` set to `false`.

**4. Create the database**

[Create database](https://api-docs.sevalla.com/v3/databases/create-database) (`createDatabaseV3`) starts creating a PostgreSQL 18 database with a password you generate. [List database versions](https://api-docs.sevalla.com/v3/resources/list-database-versions) (`listDatabaseVersionsV3`) lists the versions of a location.

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
DB_PASSWORD=$(openssl rand -hex 24)
DB_ID=$(curl -s https://api.sevalla.com/v3/databases \
  -H "Authorization: Bearer $SEVALLA_API_KEY" \
  -H "Content-Type: application/json" \
  -d @- <<JSON | jq -r .id
{
  "display_name": "orders-db",
  "type": "postgresql",
  "version": "18",
  "cluster_id": "$CLUSTER_ID",
  "resource_type_id": "$DB_SIZE_ID",
  "db_name": "storefront",
  "db_user": "storefront",
  "db_password": "$DB_PASSWORD"
}
JSON
)
```

CLI: `sevalla databases create --name orders-db --type postgresql --db-version 18 --cluster "$CLUSTER_ID" --resource-type "$DB_SIZE_ID" --db-name storefront --db-user storefront --db-password "$DB_PASSWORD"`. MCP: `create_database`, which generates the password and doesn't return it; with `connect_to_application_id` and `env_var_name` set to `DATABASE_URL`, it also does the next step and stores the connection URL as `postgresql://...`, which Payload reads like `postgres://`.

**5. Connect the database**

[Create internal connection](https://api-docs.sevalla.com/v3/databases/create-internal-connection) (`createDatabaseInternalConnectionV3`) connects the database to the application over the private network. Unlike the dashboard, it adds no environment variables: a later step adds `DATABASE_URL`.

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s https://api.sevalla.com/v3/databases/$DB_ID/internal-connections \
  -H "Authorization: Bearer $SEVALLA_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"target_id\": \"$APP_ID\", \"target_type\": \"app\"}"
```

CLI: `sevalla databases connections internal-create "$DB_ID" --target-id "$APP_ID" --target-type app`. MCP: `sevalla_api_create` with `createDatabaseInternalConnectionV3`.

**6. Create the bucket**

[Create object storage bucket](https://api-docs.sevalla.com/v3/object-storages/create-object-storage-bucket) (`createObjectStorageV3`) creates a private bucket in Western Europe (`weur`), close to Frankfurt. Payload serves the files itself, so the bucket needs no public access.

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
BUCKET_ID=$(curl -s https://api.sevalla.com/v3/object-storage \
  -H "Authorization: Bearer $SEVALLA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"display_name": "storefront-media", "location": "weur"}' | jq -r .id)
```

CLI: `sevalla object-storage create --name storefront-media --location weur`. MCP: `sevalla_api_create` with `createObjectStorageV3` and the same body.

**7. Read the database and bucket details**

[Get database](https://api-docs.sevalla.com/v3/databases/get-database) (`getDatabaseV3`) returns the database's internal hostname and port, once its `status` is `ready`. [Get object storage bucket](https://api-docs.sevalla.com/v3/object-storages/get-object-storage-bucket) (`getObjectStorageV3`) returns the bucket's endpoint, name and keys.

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
for i in $(seq 1 60); do
  [ "$(curl -s https://api.sevalla.com/v3/databases/$DB_ID -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r '.status // empty')" = ready ] && break
  sleep 10
done
DB=$(curl -s https://api.sevalla.com/v3/databases/$DB_ID -H "Authorization: Bearer $SEVALLA_API_KEY")
BUCKET=$(curl -s https://api.sevalla.com/v3/object-storage/$BUCKET_ID -H "Authorization: Bearer $SEVALLA_API_KEY")
```

CLI: `sevalla databases get "$DB_ID" --json` and `sevalla object-storage get "$BUCKET_ID" --json`. MCP: `get_database`, and `sevalla_api_read` with `getObjectStorageV3`.

**8. Add the environment variables**

[Create environment variable](https://api-docs.sevalla.com/v3/applications/create-environment-variable) (`createEnvironmentVariableV3`) adds one variable per call. `add_var` sends each one as available during runtime only, since the build needs none. `DATABASE_URL` has the format the dashboard uses:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
add_var() {
  curl -s https://api.sevalla.com/v3/applications/$APP_ID/env-vars \
    -H "Authorization: Bearer $SEVALLA_API_KEY" \
    -H "Content-Type: application/json" \
    -d "$(jq -n --arg key "$1" --arg value "$2" '{key: $key, value: $value, is_runtime: true, is_buildtime: false}')" | jq -r .key
}
add_var DATABASE_URL "postgres://$(jq -r .db_user <<< "$DB"):$DB_PASSWORD@$(jq -r .internal_hostname <<< "$DB"):$(jq -r .internal_port <<< "$DB")/$(jq -r .db_name <<< "$DB")"
PAYLOAD_SECRET=$(openssl rand -hex 32)
add_var PAYLOAD_SECRET "$PAYLOAD_SECRET"
add_var S3_ENDPOINT "$(jq -r .endpoint <<< "$BUCKET")"
add_var S3_BUCKET "$(jq -r .bucket_name <<< "$BUCKET")"
add_var S3_ACCESS_KEY_ID "$(jq -r .access_key <<< "$BUCKET")"
add_var S3_SECRET_ACCESS_KEY "$(jq -r .secret_key <<< "$BUCKET")"
```

[List environment variables](https://api-docs.sevalla.com/v3/applications/list-environment-variables) (`listEnvironmentVariablesV3`) returns the value later; the MCP server's tools don't. Don't change `PAYLOAD_SECRET` afterwards: a new one signs every user out.

CLI: `sevalla apps env-vars create "$APP_ID" --key PAYLOAD_SECRET --value "$PAYLOAD_SECRET"`, once per variable; the CLI makes each one available during the build too. MCP: `set_environment_variables` with all the variables in one call, each with `runtime` `true` and `buildtime` `false`.

**9. Create the migration job**

[Create process](https://api-docs.sevalla.com/v3/applications/create-process) (`createProcessV3`) adds the `Migrate` job. A job needs `scaling_strategy` and `job_start_policy`; `beforeDeployment` is the dashboard's **Before deployment**.

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s https://api.sevalla.com/v3/applications/$APP_ID/processes \
  -H "Authorization: Bearer $SEVALLA_API_KEY" \
  -H "Content-Type: application/json" \
  -d @- <<JSON | jq -r .display_name
{
  "display_name": "Migrate",
  "type": "job",
  "entrypoint": "npx payload migrate",
  "resource_type_id": "$S1_ID",
  "scaling_strategy": {"type": "manual", "config": {"instanceCount": 1}},
  "job_start_policy": "beforeDeployment"
}
JSON
```

CLI: `sevalla apps processes create "$APP_ID" --name Migrate --type job --resource-type "$S1_ID" --command "npx payload migrate" --job-start-policy beforeDeployment`. MCP: `sevalla_api_create` with `createProcessV3` and the same body.

**10. Turn on auto-deploy**

[Update application](https://api-docs.sevalla.com/v3/applications/update-application) (`updateApplicationV3`) with `auto_deploy` deploys every push to the branch.

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

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

**11. Deploy**

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

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
DEPLOYMENT_ID=$(curl -s https://api.sevalla.com/v3/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`.

**12. Wait for the deployment**

[Get deployment](https://api-docs.sevalla.com/v3/applications/get-deployment) (`getDeploymentV3`) returns the deployment's `status`: `waiting`, then `inProgress`, then `success` or `failed`. The loop polls every 15 seconds for up to 30 minutes and keeps polling through a failed request.

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
for i in $(seq 1 120); do
  STATUS=$(curl -s "https://api.sevalla.com/v3/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"
```

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

**13. Check the deployment**

[Get application deployment logs](https://api-docs.sevalla.com/v3/applications/get-application-deployment-logs) (`getApplicationDeploymentLogsV3`) returns the build log, which starts with the build plan in [Verify the deployment](#verify-the-deployment) and, on a failed deployment, ends with the error. [Get application runtime logs](https://api-docs.sevalla.com/v3/applications/get-application-runtime-logs) (`getApplicationRuntimeLogsV3`) returns what the `Migrate` job printed. [List application domains](https://api-docs.sevalla.com/v3/applications/list-application-domains) (`listApplicationDomainsV3`) returns the application's domain:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s "https://api.sevalla.com/v3/applications/$APP_ID/deployments/$DEPLOYMENT_ID/logs?limit=5000" \
  -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r '.[].message'
curl -s -G https://api.sevalla.com/v3/applications/$APP_ID/runtime-logs --data-urlencode "search=Migrating" \
  -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r '.[].message'
APP_URL=https://$(curl -s https://api.sevalla.com/v3/applications/$APP_ID/domains \
  -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r '.data[] | select(.is_primary) | .name')
curl -s -o /dev/null -w '%{http_code}\n' $APP_URL/admin
```

The build log includes `✅ Migrate has been successful`, the runtime logs show a line such as `[05:11:53] INFO: Migrating: 20261029_190828_initial`, and the last command prints `200`. Then run the checks in [Verify the deployment](#verify-the-deployment) with `$APP_URL` as the domain.

CLI: `sevalla apps deployments logs "$DEPLOYMENT_ID" --app-id "$APP_ID"`, `sevalla apps logs runtime "$APP_ID" --search Migrating` and `sevalla apps domains list --app-id "$APP_ID"`. MCP: `get_deployment_logs`, `search_runtime_logs` and `get_application`.


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