> ## 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 Strapi application

> Deploy a Strapi 5 CMS to Sevalla with Ingot or a Dockerfile: PostgreSQL over an internal connection, the admin panel built during the build, and uploads in object storage.

A Strapi project runs on Sevalla as an application: [Ingot](https://docs.sevalla.com/applications/builds/ingot.md) installs its dependencies, builds the admin panel with the `build` script, `strapi build`, and starts the web process with the `start` script, `strapi start`, which listens on the port in the `PORT` environment variable. Its content lives in a Sevalla PostgreSQL database, and the files editors upload 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), or the [Dockerfile](#deploy-with-a-dockerfile) |
| Start command | `strapi start`, from `scripts.start` in `package.json` |
| Port | The `PORT` environment variable, `8080` by default |
| Processes | Web process. Strapi updates its tables when it starts, so there's no migration job |
| Services | PostgreSQL 18 over an internal connection; a bucket with public access for uploads |
| Disk | None |
| Health check | `GET /_health` returns `204` (readiness probe) |
| Size | **S1** for the web process (Strapi needs more than the 300 MB of **H1**), **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>` |
| `DATABASE_CLIENT` | Yes | Runtime | No | `postgres`. Without it, Strapi uses SQLite |
| `APP_KEYS` | Yes | Runtime | Yes | Four random keys, comma-separated, from the `node` command in [Deploy to Sevalla](#deploy-to-sevalla) |
| `API_TOKEN_SALT` | Yes | Runtime | Yes | From the same `node` command |
| `ADMIN_JWT_SECRET` | Yes | Runtime | Yes | From the same `node` command |
| `TRANSFER_TOKEN_SALT` | Yes | Runtime | Yes | From the same `node` command |
| `JWT_SECRET` | Yes | Runtime | Yes | From the same `node` command |
| `ENCRYPTION_KEY` | Yes | Runtime | Yes | From the same `node` command |
| `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** |
| `S3_PUBLIC_URL` | Yes | Runtime | No | `https://` and the bucket's public domain, such as `https://storefront-media-q8w2e.sevalla.storage` |
| `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 Strapi doesn't read.

## 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 on your computer. Strapi 5 supports Node.js 20 to 26.
* 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 Strapi project in TypeScript that uses the local PostgreSQL database, and install the S3 upload provider:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
npx create-strapi-app@latest storefront --non-interactive --skip-cloud --no-example --typescript --use-npm --install --no-git-init --no-run --dbclient postgres --dbhost localhost --dbport 5432 --dbname storefront --dbusername postgres --dbpassword postgres
cd storefront
npm install @strapi/provider-upload-aws-s3
```

The flags answer every question `create-strapi-app` would ask: no Strapi Cloud account, no example data, npm, and the database on your computer. The command writes the database settings and random secrets to `.env`, which `.gitignore` keeps out of the repository.

Add a content type for products, with a name and an image. Strapi doesn't let you create content types in production, so they live in the repository:

```json src/api/product/content-types/product/schema.json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{
  "kind": "collectionType",
  "collectionName": "products",
  "info": {
    "singularName": "product",
    "pluralName": "products",
    "displayName": "Product"
  },
  "options": {
    "draftAndPublish": false
  },
  "attributes": {
    "name": {
      "type": "string",
      "required": true
    },
    "image": {
      "type": "media",
      "multiple": false,
      "allowedTypes": ["images"]
    }
  }
}
```

```ts src/api/product/controllers/product.ts theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import { factories } from '@strapi/strapi';

export default factories.createCoreController('api::product.product');
```

```ts src/api/product/routes/product.ts theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import { factories } from '@strapi/strapi';

export default factories.createCoreRouter('api::product.product');
```

```ts src/api/product/services/product.ts theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import { factories } from '@strapi/strapi';

export default factories.createCoreService('api::product.product');
```

The Content-Type Builder in the admin panel writes the same files when you create a type with `npm run develop` on your computer.

## Prepare the project for Sevalla

### Store uploads in object storage

Strapi reads the files in `config/env/production/` on top of `config/` when `NODE_ENV` is `production`, which it is on Sevalla. Add the upload provider there, so uploads go to the bucket on Sevalla and stay in `public/uploads` on your computer:

```ts config/env/production/plugins.ts theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import type { Core } from '@strapi/strapi';

const config = ({ env }: Core.Config.Shared.ConfigParams): Core.Config.Plugin => ({
  upload: {
    config: {
      provider: 'aws-s3',
      providerOptions: {
        baseUrl: env('S3_PUBLIC_URL'),
        s3Options: {
          endpoint: env('S3_ENDPOINT'),
          region: 'auto',
          credentials: {
            accessKeyId: env('S3_ACCESS_KEY_ID'),
            secretAccessKey: env('S3_SECRET_ACCESS_KEY'),
          },
          params: {
            Bucket: env('S3_BUCKET'),
          },
        },
      },
    },
  },
});

export default config;
```

Sevalla buckets run on Cloudflare R2, which ignores the region, but the AWS SDK needs one: `auto`. `baseUrl` makes Strapi save each file's address on the bucket's public domain. The upload limits in `config/plugins.ts`, such as the file types Strapi refuses, still apply.

Then let the admin panel show images from the bucket. Strapi's default Content Security Policy only allows its own address:

```ts config/env/production/middlewares.ts theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import type { Core } from '@strapi/strapi';

const config = ({ env }: Core.Config.Shared.ConfigParams): Core.Config.Middlewares => [
  'strapi::logger',
  'strapi::errors',
  {
    name: 'strapi::security',
    config: {
      contentSecurityPolicy: {
        useDefaults: true,
        directives: {
          'img-src': ["'self'", 'data:', 'blob:', 'market-assets.strapi.io', env('S3_PUBLIC_URL')],
          'media-src': ["'self'", 'data:', 'blob:', 'market-assets.strapi.io', env('S3_PUBLIC_URL')],
        },
      },
    },
  },
  'strapi::cors',
  'strapi::poweredBy',
  'strapi::query',
  'strapi::body',
  'strapi::session',
  'strapi::favicon',
  'strapi::public',
];

export default config;
```

### Pin the Node.js version

`create-strapi-app` sets `engines.node` to `>=20.0.0 <=26.x.x`, a range in which Ingot builds on its default line. Pin Node.js 24, 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.x"
```

| File | Why Sevalla needs it |
| - | - |
| `package.json` | `scripts.build` (`strapi build`) builds the admin panel during the build, `scripts.start` (`strapi start`) starts the web process, and `engines.node` pins Node.js 24. |
| `package-lock.json` | Ingot installs exactly these versions with `npm ci`. |
| `config/server.ts` | Created by `create-strapi-app`. Strapi listens on `PORT`, which Sevalla sets to `8080`, on all interfaces (`HOST` defaults to `0.0.0.0`). |
| `config/database.ts` | Created by `create-strapi-app`. Reads `DATABASE_CLIENT` and `DATABASE_URL`, which you set on Sevalla. |
| `config/env/production/` | Uploads to the bucket and lets the admin panel show them. |
| `src/api/product/` | The content type, which production can't create. |

Strapi needs no other configuration: the build needs no environment variables, and Ingot removes the dev dependencies after the build. Run `npm run develop` and open `http://localhost:1337/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).

Generate the production secrets first. Strapi signs sessions and tokens with them, so keep them in a password manager: changing them later signs every editor out and invalidates API tokens.

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
node -e 'const k = () => require("crypto").randomBytes(16).toString("base64"); console.log(`APP_KEYS=${k()},${k()},${k()},${k()}\nAPI_TOKEN_SALT=${k()}\nADMIN_JWT_SECRET=${k()}\nTRANSFER_TOKEN_SALT=${k()}\nJWT_SECRET=${k()}\nENCRYPTION_KEY=${k()}`)'
```

**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**. On the bucket's **Settings** tab, under **Public access**, click **Enable public access** and confirm. 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. Choose **S1** or a larger **Size**: Strapi needs more than the 300 MB of memory that **H1** has, and an **H1** instance is stopped as soon as Strapi starts. 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**. Paste the six lines the `node` command printed into the first **Key** field: the sheet fills in one variable per line. Then add these and click **Add environment variables**:

| Variable | Value |
| - | - |
| `DATABASE_CLIENT` | `postgres`. Without it, Strapi uses SQLite. |
| `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** |
| `S3_PUBLIC_URL` | `https://` followed by the bucket's public domain, such as `https://storefront-media-q8w2e.sevalla.storage` |

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

**6. Deploy**

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

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

Strapi creates and updates its tables when it starts, from the content types in the repository, so the project needs no migration job.

## 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 `engines.npm` in `package.json`, which `create-strapi-app` sets to `>=6.0.0`, 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"}}
   ╭─ ■ Node.js 24 application ─────────────────────────────────╮
   │ Node.js          24            package.json engines "24.x" │
   │ Package manager  npm >=6.0.0   package.json engines        │
   │ Start            strapi 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 ─╯
   ```

   Later, the build log shows `strapi build` building the admin panel: `✔ Building admin panel`.
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`. The page shows **Welcome to Strapi!**. Fill in **First name**, **Last name**, **Email** and **Password**, confirm the password, and click **Let's start** to create the first administrator.
4. From a terminal, check the health endpoint and the API, with your application's domain:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -s -o /dev/null -w '%{http_code}\n' https://storefront-x7k2m.sevalla.app/_health
   curl -s https://storefront-x7k2m.sevalla.app/api/products
   ```

   The first command prints `204`. The second prints `{"data":null,"error":{"status":403,"name":"ForbiddenError","message":"Forbidden","details":{}}}`: the `products` route exists, and reading it needs an API token. 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 Ingot builds](https://docs.sevalla.com/applications/builds/ingot.md#troubleshoot-ingot-builds). If it deploys but the domain doesn't respond, read the application's [runtime logs](https://docs.sevalla.com/applications/logs.md) and see [Troubleshoot](#troubleshoot).

## Add content and read it from the API

1. In the admin panel, click **Content Manager**, click **Product**, then click **Create new entry**. Enter `Coffee mug` as the **name**, add an image from your computer in the **image** field, and click **Save**. Strapi uploads the image to the bucket: it's on the bucket's **Objects** tab.
2. Click **Settings**, then **API Tokens**, then **Create new API Token**. Enter `storefront-read` as the **Name**, choose a **Token duration**, such as **Unlimited**, and **Read-only** as the **Token type**, then click **Save**. Copy the token: Strapi shows it only once.
3. Read the products, with the token instead of `<token>` and your application's domain:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -H "Authorization: Bearer <token>" "https://storefront-x7k2m.sevalla.app/api/products?populate=image"
   ```

   The response lists the product, and its `image.url` is on the bucket's public domain, such as `https://storefront-media-q8w2e.sevalla.storage/mug_dbad2b89ae.png`. Open it: the image loads.
4. Deploy again with **Deploy now**, then repeat the request. The product and its image are still there: the content is in PostgreSQL and the file in the bucket, not on the instance's disk, which each deployment replaces.

To do the same from a terminal, for example as an agent, use Strapi's admin API. `register-admin` works only while the project has no administrator, so run this instead of step 3 of [Verify the deployment](#verify-the-deployment), 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
ADMIN_PASSWORD="Aa1$(openssl rand -hex 12)"
ADMIN_TOKEN=$(curl -s $APP_URL/admin/register-admin -H "Content-Type: application/json" \
  -d "{\"firstname\":\"Ana\",\"lastname\":\"Silva\",\"email\":\"ana@example.com\",\"password\":\"$ADMIN_PASSWORD\"}" | jq -r .data.token)
FILE_ID=$(curl -s $APP_URL/upload -H "Authorization: Bearer $ADMIN_TOKEN" -F "files=@mug.png" | jq -r '.[0].id')
curl -s $APP_URL/content-manager/collection-types/api::product.product -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" -d "{\"name\":\"Coffee mug\",\"image\":$FILE_ID}" | jq -c '{name: .data.name}'
READ_TOKEN=$(curl -s $APP_URL/admin/api-tokens -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d '{"name":"storefront-read","type":"read-only","lifespan":null,"description":""}' | jq -r .data.accessKey)
curl -s -H "Authorization: Bearer $READ_TOKEN" "$APP_URL/api/products?populate=image" | jq -c '.data[] | {name, url: .image.url}'
```

The password has an uppercase letter, a lowercase letter and a digit, as Strapi requires; keep it to sign in as `ana@example.com`. The output is the new product, then the product as the read-only token reads it, with its image on the bucket's public domain; Strapi adds a different suffix to the file name on each upload:

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{"name":"Coffee mug"}
{"name":"Coffee mug","url":"https://storefront-media-q8w2e.sevalla.storage/mug_dbad2b89ae.png"}
```

## Send traffic only to started instances

Strapi takes a few seconds to start. Without a readiness probe, Sevalla sends requests to a new instance as soon as its container starts, so for a moment during each deployment requests fail with `503`. Strapi answers `/_health` with `204` once it's started; make that the web process's readiness probe:

**1. Open the web process**

On the **Processes** tab, open the web process's menu and click **Update process**.

**2. Turn on the readiness probe**

Under **Health checks**, turn on **Readiness probe** with the **Path** `/_health`. Click **Update process**, then deploy. The probe applies with the deployment.

From then on, each deployment moves traffic to the new instances only after Strapi answers. See [Health checks](https://docs.sevalla.com/applications/processes.md#health-checks).

## 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. Add a `Dockerfile` and a `.dockerignore` to 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 ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev

FROM node:24-trixie-slim
WORKDIR /app
ENV NODE_ENV=production PORT=8080
COPY --from=build /app ./
USER 1000:1000
EXPOSE 8080
CMD ["node_modules/.bin/strapi", "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. Strapi's native packages, such as `sharp` and `@swc/core`, install prebuilt binaries, so neither stage needs compilers. |
| Two stages | `npm run build` compiles the TypeScript to `dist/` and builds the admin panel in the first stage. `npm prune --omit=dev` then drops the dev dependencies, and the final stage copies only the result. |
| `package.json` and `package-lock.json` first | Docker reuses the `npm ci` layer while the lock file doesn't change, so a change to the code or the content types skips the install. |
| `USER 1000:1000` | Strapi runs as the image's unprivileged `node` user. The application stays owned by root, so Strapi can read it but not change it; uploads go to the bucket, not to the disk. |
| No init process | `strapi start` handles `SIGTERM` itself: it logs `Shutting down Strapi` and closes its database connections. |
| `CMD ["node_modules/.bin/strapi", "start"]` | Starts Strapi without npm in between, so the signal reaches it. Strapi 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
dist
build
.strapi
.tmp
.env*
npm-debug.log*
public/uploads/*
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, and its **Dockerfile** tab shows your `Dockerfile`. When the deployment is **Deployed**, the request in [Add content and read it from the API](#add-content-and-read-it-from-the-api) returns the same product and image. 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, then replaces the web process's instances; Strapi updates the database tables for changed content types when the new instances start. Change content types with `npm run develop` on your computer and commit the files it writes. See [Deployments](https://docs.sevalla.com/applications/deployments.md).

## Use environment variables

Add configuration on the application's **Environment variables** tab and read it in `config/` files with `env('NAME')`. Variables apply at the next deployment. Variables that start with `STRAPI_ADMIN_` are built into the admin panel, 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**, which **S1** is. See [Custom domains](https://docs.sevalla.com/domains/overview.md).

## Troubleshoot

| Symptom | Cause | Fix |
| - | - | - |
| The runtime logs show `Middleware "strapi::session": App keys are required.` | `APP_KEYS` isn't set | Add the six secrets from [Deploy to Sevalla](#deploy-to-sevalla), then deploy. |
| With the Dockerfile, the runtime logs show `EACCES: permission denied, mkdir '/app/.tmp'` | `DATABASE_CLIENT` isn't set, so Strapi tries to create a SQLite database in the read-only application folder | Set `DATABASE_CLIENT` to `postgres`, then deploy. |
| The domain returns `503`, and a banner says `The app exited with code 137 (OOMKilled, SIGKILL).` | The web process is **H1**, which doesn't have the memory Strapi needs | [Scale](https://docs.sevalla.com/applications/scaling.md) the web process to **S1** or larger, then deploy. |
| Images in the admin panel don't load, but their URLs open in the browser | `S3_PUBLIC_URL` isn't set, or doesn't match the bucket's public domain, so the Content Security Policy blocks them | Set `S3_PUBLIC_URL` to `https://` and the bucket's public domain, then deploy. |

## Deploy with the API

This sequence creates the same deployment as [Deploy to Sevalla](#deploy-to-sevalla) and [Send traffic only to started instances](#send-traffic-only-to-started-instances) 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 database size**

[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 database resource types](https://api-docs.sevalla.com/v3/resources/list-database-resource-types) (`listDatabaseResourceTypesV3`) returns the database sizes; **DB1** is the smallest.

```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')
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` 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 of size **S1**, the size Strapi needs, 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. 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 Strapi reads like `postgres://`.

**4. 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: the next steps add `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`.

**5. Create the bucket**

[Create object storage bucket](https://api-docs.sevalla.com/v3/object-storages/create-object-storage-bucket) (`createObjectStorageV3`) creates the bucket in Western Europe (`weur`), close to Frankfurt, with public access on.

```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", "public_access": true}' | jq -r .id)
```

CLI: `sevalla object-storage create --name storefront-media --location weur`, then `sevalla object-storage cdn-domain enable "$BUCKET_ID"` for public access. MCP: `sevalla_api_create` with `createObjectStorageV3` and the same body.

**6. 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, keys and public domain.

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

**7. 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, and the six secrets come from `openssl`:

```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")"
add_var DATABASE_CLIENT postgres
APP_KEYS="$(openssl rand -base64 16),$(openssl rand -base64 16),$(openssl rand -base64 16),$(openssl rand -base64 16)"
API_TOKEN_SALT=$(openssl rand -base64 16)
ADMIN_JWT_SECRET=$(openssl rand -base64 16)
TRANSFER_TOKEN_SALT=$(openssl rand -base64 16)
JWT_SECRET=$(openssl rand -base64 16)
ENCRYPTION_KEY=$(openssl rand -base64 16)
add_var APP_KEYS "$APP_KEYS"
add_var API_TOKEN_SALT "$API_TOKEN_SALT"
add_var ADMIN_JWT_SECRET "$ADMIN_JWT_SECRET"
add_var TRANSFER_TOKEN_SALT "$TRANSFER_TOKEN_SALT"
add_var JWT_SECRET "$JWT_SECRET"
add_var ENCRYPTION_KEY "$ENCRYPTION_KEY"
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")"
add_var S3_PUBLIC_URL "https://$(jq -r .domain <<< "$BUCKET")"
```

[List environment variables](https://api-docs.sevalla.com/v3/applications/list-environment-variables) (`listEnvironmentVariablesV3`) returns the values later; the MCP server's tools don't. Don't change the secrets afterwards: new ones sign every editor out and invalidate API tokens.

CLI: `sevalla apps env-vars create "$APP_ID" --key DATABASE_CLIENT --value postgres`, 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`.

**8. Turn on the readiness probe**

[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`) gives it the probe the dashboard creates. Send `timeoutSeconds`: without it, the API sets 120 seconds, not the dashboard's 1.

```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 '{"readiness_probe": {"httpGet": {"path": "/_health", "port": 8080, "scheme": "HTTP"}, "initialDelaySeconds": 0, "periodSeconds": 10, "timeoutSeconds": 1, "successThreshold": 1, "failureThreshold": 3}}' | jq .readiness_probe
```

CLI: `sevalla apps processes list --app-id "$APP_ID"`; the CLI can't set probes. MCP: `get_application`, then `sevalla_api_update` with `updateProcessV3` and the same body.

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

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

**11. 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. A Strapi deployment takes about 4 minutes.

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

**12. 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. [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'
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/_health
```

The last command prints `204`. Then run the checks in [Verify the deployment](#verify-the-deployment) and [Add content and read it from the API](#add-content-and-read-it-from-the-api) with `$APP_URL` as the domain.

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


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