> ## 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 an AdonisJS application

> Deploy an AdonisJS 7 starter kit with Lucid and PostgreSQL to Sevalla: an internal connection, APP_KEY and the variables start/env.ts checks, and migrations in a job.

An AdonisJS application runs on Sevalla as a Node.js application: Ingot, Sevalla's builder, detects AdonisJS, compiles the application into `build/` with `node ace build`, and the web process runs the compiled server, `node build/bin/server.js`. Its data lives in a Sevalla PostgreSQL database that the application reaches over an internal connection, and a job runs the Lucid migrations before each 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 | `node build/bin/server.js`, which Ingot sets for AdonisJS |
| Port | The `PORT` environment variable, `8080` by default |
| Processes | Web process; `Migrate` job (`node build/ace.js migration:run --force`, **Before deployment**) |
| Services | PostgreSQL 18 (`storefront-db`) over an internal connection |
| Disk | None |
| Health check | `GET /` answers with `200` (readiness probe, see [Deploy changes](#deploy-changes)) |
| Size | **H1** for the web process and the `Migrate` job, **DB1** or larger for the database |

### Environment variables

| Variable | Required | Available during | Secret | Value |
| - | - | - | - | - |
| `DB_HOST` | Yes | Runtime | No | The database's internal hostname, from the internal connection |
| `DB_PORT` | Yes | Runtime | No | The internal port, `5432`, from the internal connection |
| `DB_DATABASE` | Yes | Runtime | No | The database name, from the internal connection |
| `DB_USERNAME` | Yes | Runtime | No | The database user, from the internal connection |
| `DB_PASSWORD` | Yes | Runtime | Yes | The database password, from the internal connection |
| `DB_URL` | No | Runtime | Yes | From the internal connection. `config/database.ts` doesn't read it |
| `APP_KEY` | Yes | Runtime | Yes | A new random key: `node ace generate:key --show`, or `openssl rand -base64 32` |
| `APP_URL` | Yes | Runtime | No | `https://` and the application's domain, such as `https://storefront-x7k2m.sevalla.app` |
| `LOG_LEVEL` | Yes | Runtime | No | `info` |
| `SESSION_DRIVER` | Yes | Runtime | No | `cookie` |
| `NODE_ENV`, `HOST` | Yes | Runtime | No | Set to `production` and `0.0.0.0` by Ingot's image, and by the Dockerfile's `ENV` line |
| `PORT` | Yes | Runtime | No | Set by Sevalla to the web process port |

## 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 or later on your computer. AdonisJS 7 needs Node.js 24.
* PostgreSQL on your computer for local development, with a database for the project.

## Create the project

Create a project from the Hypermedia starter kit, which renders pages on the server with Edge templates and comes with sign-up and login, Lucid and Vite:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
npm init adonisjs@latest -- storefront --kit=hypermedia --pkg=npm
cd storefront
```

The kit installs AdonisJS 7, writes a `.env` file with a new `APP_KEY` for local development, and sets Lucid up with SQLite. Replace SQLite with PostgreSQL, and make the Lucide icon set a production dependency:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
npm uninstall better-sqlite3
npm install pg
npm install --save-prod @iconify-json/lucide
```

`start/view.ts` loads the Lucide icons on every start, but the kit lists `@iconify-json/lucide` in `devDependencies`, which a production install leaves out. Without the last command, the server and the migration job stop with `Cannot find package '@iconify-json/lucide' imported from /app/build/start/view.js`.

### Connect Lucid to PostgreSQL

Replace `config/database.ts` with a PostgreSQL connection that reads the variables Sevalla adds when you [connect the database](https://docs.sevalla.com/databases/connect.md#connect-an-application):

```ts config/database.ts theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import app from '@adonisjs/core/services/app'
import env from '#start/env'
import { defineConfig } from '@adonisjs/lucid'

const dbConfig = defineConfig({
  connection: 'pg',
  prettyPrintDebugQueries: true,
  connections: {
    pg: {
      client: 'pg',
      connection: {
        host: env.get('DB_HOST'),
        port: env.get('DB_PORT'),
        user: env.get('DB_USERNAME'),
        password: env.get('DB_PASSWORD'),
        database: env.get('DB_DATABASE'),
      },
      migrations: {
        naturalSort: true,
        paths: ['database/migrations'],
      },
      schemaGeneration: {
        enabled: true,
        rulesPaths: ['./database/schema_rules.js'],
      },
      debug: app.inDev,
    },
  },
})

export default dbConfig
```

AdonisJS checks every variable that `start/env.ts` lists when it starts. Add the database variables at the end of the list in `start/env.ts`, after `SESSION_DRIVER`:

```ts start/env.ts theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
  // Database
  DB_HOST: Env.schema.string({ format: 'host' }),
  DB_PORT: Env.schema.number(),
  DB_USERNAME: Env.schema.string(),
  DB_PASSWORD: Env.schema.string.optional(),
  DB_DATABASE: Env.schema.string(),
```

Then add your local PostgreSQL's values to `.env`, which stays on your computer, for example:

```bash .env theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
DB_HOST=127.0.0.1
DB_PORT=5432
DB_USERNAME=postgres
DB_PASSWORD=local-password
DB_DATABASE=storefront
```

Create the tables in your local database. Lucid also writes the column types it reads from PostgreSQL to `database/schema.ts`, which the models use:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
node ace migration:run
```

## Prepare the project for Sevalla

Pin the Node.js version in `package.json`:

```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` `engines.node` | [Ingot](https://docs.sevalla.com/applications/builds/ingot.md), the default build strategy, builds on the Node.js 24 line. The kit's `>=24.0.0` is a range, which builds on Ingot's default line, and a new Ingot release can move it. See [Pin the Node.js version](https://docs.sevalla.com/guides/javascript/overview.md#pin-the-node-js-version). |
| `package-lock.json` | Ingot installs the exact versions in it with `npm ci`. Commit it. |
| `start/env.ts` | Lists the variables the application needs. Each one you don't set on Sevalla stops the server and the migration job. |

### How Ingot builds and starts the application

Ingot detects AdonisJS from `@adonisjs/core` in `package.json`, and then:

* Installs every dependency, runs the `build` script, `node ace build`, which compiles TypeScript and builds the Vite assets into `build/`, then removes the devDependencies.
* Starts the web process with `node build/bin/server.js`. The kit's `start` script, `node bin/server.js`, is meant to run inside `build/`, so Ingot runs the compiled server from there instead.
* Sets `HOST` to `0.0.0.0`, so the server listens on all network interfaces, on the port in [`PORT`](https://docs.sevalla.com/guides/javascript/overview#listen-on-the-port-environment-variable), which Sevalla sets (`8080` by default).
* Doesn't run migrations. A [job](https://docs.sevalla.com/applications/processes.md#jobs) runs them once per deployment, before the new version starts, with the compiled `ace` command in `build/`.

`bin/server.ts` already calls `app.terminate()` on `SIGTERM`, so the server stops accepting connections and closes its database connections when Sevalla stops an instance. See [How processes run](https://docs.sevalla.com/applications/processes.md#how-processes-run).

Run `npm run dev` and open `http://localhost:3333` to check the application locally. Then commit everything and push it to your Git provider. The kit's `.gitignore` keeps `.env`, `node_modules` and `build` out of the repository:

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

**3. Connect the database**

Click the **Networking** tab. Under **Connected services**, click **Add internal connection**. Choose `storefront-db` as the **Service**, turn on **Add environment variables to the application**, and click **Add internal connection**. Sevalla adds `DB_HOST`, `DB_PORT`, `DB_USERNAME`, `DB_PASSWORD`, `DB_DATABASE` and `DB_URL` to the application, the names `config/database.ts` reads. See [Connect an application](https://docs.sevalla.com/databases/connect.md#connect-an-application).

**4. Add the environment variables**

Click the **Environment variables** tab, then click **Add environment variables**. Add the other variables that `start/env.ts` checks, then click **Add environment variables**:

| Variable | Value |
| - | - |
| `APP_KEY` | A new random key. Generate one with `node ace generate:key --show`, and copy the value after `APP_KEY = `. Don't reuse the key in your local `.env`. |
| `APP_URL` | The application's address, for example `https://storefront-x7k2m.sevalla.app`. The domain is under the application's name in the page header. |
| `LOG_LEVEL` | `info` |
| `SESSION_DRIVER` | `cookie` |

Ingot's image sets `NODE_ENV` and `HOST`, and Sevalla sets `PORT`.

**5. Create the migration job**

Click the **Processes** tab, then click **Create process**. Choose **Job**, enter `Migrate` as the **Name** and `node build/ace.js migration:run --force` as the **Custom start command**. Keep the **Start policy** at **Before deployment** and the **Resource** at **H1**, then click **Create process**. `--force` lets Lucid run migrations with `NODE_ENV` set to `production`.

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

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

## Verify the deployment

1. On the **Deployments** tab, wait for the deployment's status to change to **Deployed**. The application's status next to its name changes to **Running**. Open the deployment to read its build log. Ingot's [build plan](https://docs.sevalla.com/applications/builds/ingot.md#read-the-build-plan) comes first:

   ```text Build log theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   ╭─ ■ AdonisJS · Node.js 24 ──────────────────────────────────────────────╮
   │ Node.js          24                        package.json engines "24.x" │
   │ Package manager  npm                       package-lock.json           │
   │ Framework        AdonisJS                  package.json                │
   │ Start            node build/bin/server.js  AdonisJS                    │
   │ 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 ─╯
   ```

   The plan is followed by the warning `! AdonisJS validates the variables start/env.ts lists, such as APP_KEY and LOG_LEVEL, when it starts: set them for the app`, which the variables you added take care of. Later, during the rollout, the log shows `Migrate has been successful`. If the job fails, the deployment fails and nothing changes for visitors.
2. Click the domain under the application's name. The starter kit's home page opens with its styles, which shows that the Vite assets load.
3. Add `/signup` to the address, and create an account. AdonisJS saves the user in PostgreSQL, signs you in and opens `/dashboard`, which shows `Your dashboard is ready`.

To check from a terminal, read the deployment's status with [Get deployment](https://api-docs.sevalla.com/v3/applications/get-deployment) and its build log with [Get application deployment logs](https://api-docs.sevalla.com/v3/applications/get-application-deployment-logs), as the last steps of [Deploy with the API](#deploy-with-the-api) do, then request the home page and create an account the way `/signup` does in a browser. Use a new email address each time:

```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/
JAR=$(mktemp)
CSRF=$(curl -s -c "$JAR" https://storefront-x7k2m.sevalla.app/signup | grep -o "name='_csrf' value='[^']*'" | cut -d"'" -f4)
curl -s -L -b "$JAR" -c "$JAR" https://storefront-x7k2m.sevalla.app/signup \
  --data-urlencode "_csrf=$CSRF" \
  --data-urlencode "fullName=Ada Lovelace" \
  --data-urlencode "email=ada@example.com" \
  --data-urlencode "password=correct-horse-battery" \
  --data-urlencode "passwordConfirmation=correct-horse-battery" \
  | grep -o 'Your dashboard is ready'
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
200
Your dashboard is ready
```

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

The Dockerfile on this page copies only the compiled `build/` folder into the image, so first make the server find the Vite manifest inside it. Replace `config/vite.ts`:

```ts config/vite.ts theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import app from '@adonisjs/core/services/app'
import { defineConfig } from '@adonisjs/vite'

const viteBackendConfig = defineConfig({
  buildDirectory: 'public/assets',
  manifestFile: app.makePath('public/assets/.vite/manifest.json'),
  assetsUrl: '/assets',
  scriptAttributes: {
    defer: true,
  },
})

export default viteBackendConfig
```

The kit's `manifestFile` is the relative path `public/assets/.vite/manifest.json`, which `@adonisjs/vite` reads from the folder the process starts in, `/app`. Ingot's image keeps the whole project, including the `public/assets/` that `node ace build` also leaves in it, so the relative path works there. An image with only `build/` has no `/app/public/assets/`, and every page fails with `Missing manifest file. Make sure to first create a build`. `app.makePath()` resolves the path from the application's own folder, `build/`, so the change works with both build strategies.

Then 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 node ace build

FROM node:24-trixie-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

FROM node:24-trixie-slim
# hadolint ignore=DL3008
RUN apt-get update \
  && apt-get install -y --no-install-recommends tini \
  && rm -rf /var/lib/apt/lists/*
WORKDIR /app
ENV NODE_ENV=production HOST=0.0.0.0
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/build ./build
USER 1000:1000
EXPOSE 8080
ENTRYPOINT ["tini", "--"]
CMD ["node", "build/bin/server.js"]
```

| Line or choice | Why |
| - | - |
| `node:24-trixie-slim` | The Node.js 24 line that `engines.node` pins, on Debian 13, the image Ingot runs too. A line tag, not a digest, so each build picks up Node.js and Debian security fixes. |
| Three stages | `build` installs every dependency and runs `node ace build`. `deps` installs only the production dependencies. The final stage copies those `node_modules` and the compiled `build/`, without TypeScript, Vite or the source. |
| `package.json` and `package-lock.json` copied before the source | A change to your code reuses the cached `npm ci` layers. |
| `build/` inside `/app` | The image has the same layout as Ingot's, so the server starts with `node build/bin/server.js` and the `Migrate` job's command works with both build strategies. |
| `HOST=0.0.0.0` | `start/env.ts` requires `HOST`, and the server must listen on all network interfaces. |
| `tini` | Runs as PID 1 and passes `SIGTERM` on to Node.js, so `app.terminate()` runs. It comes from Debian's packages, unpinned so that rebuilds get its fixes, which is why the line before it tells hadolint to skip its version-pinning check. |
| `USER 1000:1000` | The image's `node` user, not root. The copied files stay owned by root, so the server can read its code but not change it. |

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

The `.dockerignore` keeps your local `.env`, with its development `APP_KEY`, out of the image.

Commit and push the three 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: its build plan then shows `Ignored 8 patterns from .dockerignore`, and the Ingot build still passes because none of these patterns removes a file it needs. 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 file. The `Migrate` job and the environment variables stay as they are. When the deployment's status is **Deployed**, the home page and `/signup` work as before. See [Dockerfile](https://docs.sevalla.com/applications/builds/dockerfile.md).

## 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. For a schema change, create a migration with `node ace make:migration`, run it locally, and commit it with the code. See [Deployments](https://docs.sevalla.com/applications/deployments.md).

Without a readiness probe, Sevalla sends requests to a new instance as soon as its container starts, before AdonisJS has booted, so a request can fail with `503` during a deployment. To avoid that, add a readiness probe to the web process: on the **Processes** tab, click **⋯** on the web process, click **Update process**, and in **Health checks** turn on the **Readiness probe** with the **Path** `/`. New instances then get traffic only once they answer. See [Health checks](https://docs.sevalla.com/applications/processes.md#health-checks).

## Use environment variables

Add a variable to the list in `start/env.ts`, read it with `env.get()`, and add it on the application's **Environment variables** tab. Variables apply at the next deployment, and AdonisJS stops at start if one that `start/env.ts` requires is missing. See [Environment variables](https://docs.sevalla.com/applications/environment-variables.md).

## Add a custom domain

[Add the domain](https://docs.sevalla.com/domains/overview.md) on the application's **Domains** tab, then set `APP_URL` to it, for example `https://shop.example.com`, and deploy. Custom domains need a web process larger than **H1**, so [scale](https://docs.sevalla.com/applications/scaling.md) the web process first.

## Troubleshoot

| Symptom | Cause | Fix |
| - | - | - |
| The `Migrate` job or the web process stops with `EnvValidationException: Validation failed for one or more environment variables`, followed by lines such as `Missing environment variable "APP_KEY"` | A variable that `start/env.ts` requires isn't set, or isn't **Available during runtime** | Add it on the **Environment variables** tab, then deploy. The database variables come from the internal connection. |
| The `Migrate` job stops with `Value for environment variable "APP_URL" must be a valid URL, instead received "https://"` | `APP_URL` was set before the application had its domain, so it holds only `https://` | Set `APP_URL` to `https://` and the domain, such as `https://storefront-x7k2m.sevalla.app`, then deploy. [Deploy with the API](#deploy-with-the-api) waits for the domain before it sets the variable. |
| The deployment fails at `Migrate has failed`, and the job's logs show `Cannot find package '@iconify-json/lucide'` | The icon set is still a devDependency | Run `npm install --save-prod @iconify-json/lucide` and commit `package.json` and `package-lock.json`. |
| With the Dockerfile, every page returns `500`, and the runtime logs show `Missing manifest file. Make sure to first create a build` | `manifestFile` in `config/vite.ts` is a relative path, and the image holds only `build/` | Use `app.makePath()`, as in [Deploy with a Dockerfile](#deploy-with-a-dockerfile). |

For more causes, see [Troubleshoot Ingot builds](https://docs.sevalla.com/applications/builds/ingot.md#troubleshoot-ingot-builds) and [Troubleshoot builds and deployments](https://docs.sevalla.com/applications/troubleshooting/builds-and-deployments.md).

## Deploy with the API

The [Sevalla API](https://docs.sevalla.com/developer-tools/api.md) makes the same deployment as [Deploy to Sevalla](#deploy-to-sevalla) without the dashboard. Run the steps in order in one shell: each step is one API call, and saves the IDs that later steps need in shell variables. Each step also names the [CLI](https://docs.sevalla.com/developer-tools/cli.md) command and the [MCP server](https://docs.sevalla.com/ai-agents/overview.md) tool that do the same. A variable that holds `null` means the call failed: run the `curl` command without its `jq` part to read the error.

You need:

* An [API key](https://docs.sevalla.com/developer-tools/api-keys.md) with the **Company developer** role, or the `APP:CREATE`, `APP:READ`, `APP:UPDATE`, `DATABASE:CREATE`, `DATABASE:READ` and `DATABASE:UPDATE` permissions, in the `SEVALLA_API_KEY` environment variable. The CLI reads the key from `SEVALLA_API_TOKEN`.
* The repository on GitHub, GitLab or Bitbucket, and a Git account that can read it connected to your Sevalla user, which only the dashboard can do. See [Git providers](https://docs.sevalla.com/git/overview.md). Creating the application checks the access: if Sevalla can't read the repository, the call returns `400` with `Cannot access repository https://github.com/acme/storefront: Repository not found or you don't have access.`, followed by where to grant the access.
* `openssl`, to generate the database password and `APP_KEY`.
* `curl` and `jq`.

**1. Find the location**

[List clusters](https://api-docs.sevalla.com/v3/resources/list-clusters) (`listClustersV3`) returns every location with its `id`, its name in `display_name` and its region ID in `location`. Pick one by its region ID, here `eu-frankfurt-1` for Frankfurt, Germany. [Locations](https://docs.sevalla.com/platform/locations.md) lists them all.

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

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

**2. Find the database size**

[List database resource types](https://api-docs.sevalla.com/v3/resources/list-database-resource-types) (`listDatabaseResourceTypesV3`) returns the ID of each database size. Pick **DB1**, the smallest, or a larger one:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
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 db-types`. MCP: `get_platform_options`.

**3. Find the newest PostgreSQL version**

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

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
PG_VERSION=$(curl -s "https://api.sevalla.com/v3/resources/database-versions?cluster_id=$CLUSTER_ID&type=postgresql" \
  -H "Authorization: Bearer $SEVALLA_API_KEY" \
  | jq -r '.[0].versions | last')
echo "$PG_VERSION"
```

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

CLI: `sevalla resources db-versions --cluster "$CLUSTER_ID" --type postgresql`. MCP: `create_database` picks the newest version when you leave out `version`.

**4. Create the database**

[Create database](https://api-docs.sevalla.com/v3/databases/create-database) (`createDatabaseV3`) creates `storefront-db` with the database `storefront`, the user `storefront` and a password you generate. The API doesn't return the password later, and the application's `DB_PASSWORD` needs it, so keep it in `DB_PASSWORD`:

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

CLI: `sevalla databases create --name storefront-db --type postgresql --db-version "$PG_VERSION" --cluster "$CLUSTER_ID" --resource-type "$DB_SIZE_ID" --db-name storefront --db-user storefront --db-password "$DB_PASSWORD"`. MCP: `create_database` generates the password itself and never returns it, so the application can't get `DB_PASSWORD` from it: create the database with the API or the CLI.

**5. Wait for the database**

[Get database](https://api-docs.sevalla.com/v3/databases/get-database) (`getDatabaseV3`) returns the database's `status`, `creating` until it's `ready`, and its internal hostname and port, which the application's variables need. Poll it until the database is ready:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
for i in $(seq 1 120); do
  DB=$(curl -s "https://api.sevalla.com/v3/databases/$DB_ID" -H "Authorization: Bearer $SEVALLA_API_KEY")
  STATUS=$(echo "$DB" | jq -r '.status // empty')
  case $STATUS in ready|error) break ;; esac
  sleep 15
done
echo "$STATUS"
DB_HOST=$(echo "$DB" | jq -r .internal_hostname)
DB_PORT=$(echo "$DB" | jq -r .internal_port)
```

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

`internal_port` is a string, such as `"5432"`. CLI: `sevalla databases get "$DB_ID"`. MCP: `get_database`.

**6. Create the application**

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

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
APP_ID=$(curl -s -X POST https://api.sevalla.com/v3/applications \
  -H "Authorization: Bearer $SEVALLA_API_KEY" \
  -H "Content-Type: application/json" \
  -d @- <<EOF | 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"
}
EOF
)
```

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

**7. Find the web process**

[List processes](https://api-docs.sevalla.com/v3/applications/list-processes) (`listProcessesV3`) returns the web process that Create application added:

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

CLI: `sevalla apps processes list --app-id "$APP_ID"`. MCP: `get_application`, in `processes`.

**8. Find the H1 size**

The API creates the web process with the **S1** size, where the dashboard picks **H1**. [List process resource types](https://api-docs.sevalla.com/v3/resources/list-process-resource-types) (`listProcessResourceTypesV3`) returns the ID of each size:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
H1_ID=$(curl -s https://api.sevalla.com/v3/resources/process-resource-types \
  -H "Authorization: Bearer $SEVALLA_API_KEY" \
  | jq -r '.[] | select(.name == "h1") | .id')
```

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

**9. Set the web process size and readiness probe**

[Update process](https://api-docs.sevalla.com/v3/applications/update-process) (`updateProcessV3`) sets the size and adds the readiness probe from [Deploy changes](#deploy-changes), so a new instance gets requests only once AdonisJS answers on `/`. The probe's values are the dashboard's defaults. Send `timeoutSeconds`: a probe set through the API without it waits 120 seconds for an answer, not the dashboard's 1.

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
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 @- <<EOF | jq -c '{size: .resource_type_name, readiness: .readiness_probe.httpGet.path}'
{
  "resource_type_id": "$H1_ID",
  "readiness_probe": {
    "httpGet": {"path": "/", "port": 8080},
    "initialDelaySeconds": 0,
    "periodSeconds": 10,
    "timeoutSeconds": 1,
    "successThreshold": 1,
    "failureThreshold": 3
  }
}
EOF
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{"size":"h1","readiness":"/"}
```

CLI: `sevalla apps processes update "$WEB_ID" --app-id "$APP_ID" --resource-type "$H1_ID"` sets the size; the CLI has no option for probes. MCP: `sevalla_api_update` with `updateProcessV3` and the same body.

**10. Find the domain**

[List application domains](https://api-docs.sevalla.com/v3/applications/list-application-domains) (`listApplicationDomainsV3`) returns the application's domains. `APP_URL` needs the primary one. Sevalla adds the domain a few seconds after it creates the application, so the loop asks until the domain is there:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
for i in $(seq 1 30); do DOMAIN=$(curl -s "https://api.sevalla.com/v3/applications/$APP_ID/domains" \
    -H "Authorization: Bearer $SEVALLA_API_KEY" \
    | jq -r '.data[] | select(.is_primary) | .name'); [ -n "$DOMAIN" ] && break;
  sleep 2
done
echo "$DOMAIN"
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
storefront-x7k2m.sevalla.app
```

CLI: `sevalla apps domains list --app-id "$APP_ID"`. MCP: `get_application`, in `domains`.

**11. Connect the database**

[Create internal connection](https://api-docs.sevalla.com/v3/applications/create-internal-connection) (`createApplicationInternalConnectionV3`) lets the application reach `storefront-db` over the private network. Unlike the dashboard, it adds no environment variables: the next step adds them.

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s -X POST "https://api.sevalla.com/v3/applications/$APP_ID/internal-connections" \
  -H "Authorization: Bearer $SEVALLA_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"target_id\":\"$DB_ID\",\"target_type\":\"database\"}" \
  | jq -r 'has("id")'
```

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

CLI: `sevalla apps internal-connections create "$APP_ID" --target-id "$DB_ID" --target-type database`. 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`) adds one variable per call. The first six are the variables the dashboard adds with the internal connection, with the same names and formats. `APP_KEY` gets a new random key, sent once and never printed. Every variable is read when the server or the `Migrate` job starts, so they need to be available at runtime only. The `add_var` function sends each one, with `jq` building the JSON body:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
add_var() {
  curl -s -X POST "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 DB_HOST "$DB_HOST"
add_var DB_PORT "$DB_PORT"
add_var DB_DATABASE storefront
add_var DB_USERNAME storefront
add_var DB_PASSWORD "$DB_PASSWORD"
add_var DB_URL "postgres://storefront:$DB_PASSWORD@$DB_HOST:$DB_PORT/storefront"
add_var APP_KEY "$(openssl rand -base64 32)"
add_var APP_URL "https://$DOMAIN"
add_var LOG_LEVEL info
add_var SESSION_DRIVER cookie
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
DB_HOST
DB_PORT
DB_DATABASE
DB_USERNAME
DB_PASSWORD
DB_URL
APP_KEY
APP_URL
LOG_LEVEL
SESSION_DRIVER
```

CLI: `sevalla apps env-vars create "$APP_ID" --key <key> --value <value>` for each variable, which makes it available during both the build and runtime. MCP: `set_environment_variables` with all the variables in one call, `runtime` set to `true` and `buildtime` set to `false`.

**13. Create the migration job**

[Create process](https://api-docs.sevalla.com/v3/applications/create-process) (`createProcessV3`) adds the `Migrate` job, which runs the Lucid migrations before each deployment. The API needs the size in `resource_type_id`, the `scaling_strategy`, and the `job_start_policy` that the dashboard calls **Before deployment**:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s -X POST "https://api.sevalla.com/v3/applications/$APP_ID/processes" \
  -H "Authorization: Bearer $SEVALLA_API_KEY" \
  -H "Content-Type: application/json" \
  -d @- <<EOF | jq -r '.display_name, .job_start_policy'
{
  "display_name": "Migrate",
  "type": "job",
  "entrypoint": "node build/ace.js migration:run --force",
  "resource_type_id": "$H1_ID",
  "scaling_strategy": {"type": "manual", "config": {"instanceCount": 1}},
  "job_start_policy": "beforeDeployment"
}
EOF
```

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

CLI: `sevalla apps processes create "$APP_ID" --name Migrate --type job --resource-type "$H1_ID" --command "node build/ace.js migration:run --force" --job-start-policy beforeDeployment`. MCP: `sevalla_api_create` with `createProcessV3` and the same body.

**14. 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 -r .auto_deploy
```

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

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

**15. Choose the Dockerfile build strategy**

Skip this step to build with Ingot. To build from the [Dockerfile](#deploy-with-a-dockerfile) instead, [Update application](https://api-docs.sevalla.com/v3/applications/update-application) (`updateApplicationV3`) sets the build strategy:

```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 '{"build_type":"dockerfile","dockerfile_path":"Dockerfile","docker_context":"."}' \
  | jq -r .build_type
```

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

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

**16. Deploy**

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

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
DEPLOYMENT_ID=$(curl -s -X POST "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`.

**17. Wait for the deployment**

[Get deployment](https://api-docs.sevalla.com/v3/applications/get-deployment) (`getDeploymentV3`) returns the deployment's `status`: `waiting`, `inProgress`, then `success`, `failed`, `cancelled` or `skipped`. Poll it until the deployment ends:

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

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

The loop asks every 15 seconds, for up to 30 minutes, and keeps asking through an empty or failed reply. `success` means the application is deployed. CLI: `sevalla apps deployments get "$DEPLOYMENT_ID" --app-id "$APP_ID"`. MCP: `get_deployment`.

**18. Read the build log**

[Get application deployment logs](https://api-docs.sevalla.com/v3/applications/get-application-deployment-logs) (`getApplicationDeploymentLogsV3`) returns the build log, one line per entry. With Ingot, the `Start` row of the build plan shows the start command, and the rollout reports the `Migrate` job:

```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' | grep -o -e '│ Start.*' -e 'Migrate has [a-z ]*' | tr -s ' '
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
│ Start node build/bin/server.js AdonisJS │
Migrate has been successful
```

With a Dockerfile, the log shows `📦 Building and pushing docker image using Dockerfile...` in place of the build plan. The `Migrate has been successful` line shows with both build strategies. When the job fails, the log ends with `Migrate has failed`, the deployment fails, and the runtime logs hold the job's error. For any other failure, the end of this log says why. CLI: `sevalla apps deployments logs "$DEPLOYMENT_ID" --app-id "$APP_ID"`. MCP: `get_deployment_logs`.

**19. Read the Migrate job output**

[Get application runtime logs](https://api-docs.sevalla.com/v3/applications/get-application-runtime-logs) (`getApplicationRuntimeLogsV3`) returns what the processes print, the `Migrate` job included. Lucid ends a run with `Migrated in` and the time it took, or with `Already up to date` when there was nothing to migrate:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s "https://api.sevalla.com/v3/applications/$APP_ID/runtime-logs?limit=500" \
  -H "Authorization: Bearer $SEVALLA_API_KEY" \
  | jq -r '.[].message' | grep -o -e 'Migrated in [0-9]* ms' -e 'Already up to date' | tail -1
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
Migrated in 241 ms
```

When the job fails, the same logs hold its error, such as `EnvValidationException: Validation failed for one or more environment variables`. CLI: `sevalla apps logs runtime "$APP_ID" --search "Migrated in"`. MCP: `search_runtime_logs` with `search` set to `Migrated in`.

**20. Check the application**

Request the home page, then create an account the way `/signup` does in a browser. A new account saves a user in PostgreSQL and opens `/dashboard`. Use a new email address each time:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s -o /dev/null -w '%{http_code}\n' "https://$DOMAIN/"
JAR=$(mktemp)
CSRF=$(curl -s -c "$JAR" "https://$DOMAIN/signup" | grep -o "name='_csrf' value='[^']*'" | cut -d"'" -f4)
curl -s -L -b "$JAR" -c "$JAR" "https://$DOMAIN/signup" \
  --data-urlencode "_csrf=$CSRF" \
  --data-urlencode "fullName=Ada Lovelace" \
  --data-urlencode "email=ada@example.com" \
  --data-urlencode "password=correct-horse-battery" \
  --data-urlencode "passwordConfirmation=correct-horse-battery" \
  | grep -o 'Your dashboard is ready'
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
200
Your dashboard is ready
```


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