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

> Deploy a Loco 1.2 REST API to Sevalla: Ingot builds it, a job runs the SeaORM migrations on PostgreSQL before each deployment, and production settings come from environment variables.

A Loco application runs on Sevalla as a Rust application: [Ingot](https://docs.sevalla.com/applications/builds/ingot.md), Sevalla's builder, compiles it with Cargo in release mode from your Git repository, and the web process runs `storefront-cli start`, Loco's command-line binary, which reads `config/production.yaml` and listens on the port that Sevalla sets in the `PORT` environment variable. This guide deploys Loco's REST API starter, with user sign-up and a products API stored in a Sevalla PostgreSQL database, and runs the SeaORM migrations in a job before each deployment.

## At a glance

| | |
| - | - |
| Source | A Git repository |
| Build | [Ingot](https://docs.sevalla.com/applications/builds/ingot.md) (Rust 1.99, Cargo), or the [Dockerfile](#deploy-with-a-dockerfile) |
| Start command | `storefront-cli start`, from the `Procfile` |
| Port | The `PORT` environment variable, `8080` by default |
| Processes | Web process; `Migrate` job (`storefront-cli db migrate`, **Before deployment**) |
| Services | PostgreSQL over an internal connection |
| Disk | None |
| Health check | `GET /_readiness` returns `200` when the database answers (readiness probe) |
| Size | **H1** for the web process and the job |

### Environment variables

| Variable | Required | Available during | Secret | Value |
| - | - | - | - | - |
| `LOCO_ENV` | Yes | Runtime | No | `production`, so Loco reads `config/production.yaml` |
| `DATABASE_URL` | Yes | Runtime | Yes | `DB_URL` from the internal connection, renamed: `postgres://<user>:<password>@<host>:<port>/<database>` |
| `JWT_SECRET` | Yes | Runtime | Yes | A base64 key that signs the sign-in tokens. Generate one with `openssl rand -base64 48` |
| `HOST` | Yes | Runtime | No | The application's address, such as `https://storefront-x7k2m.sevalla.app`, for links in emails |
| `DB_AUTO_MIGRATE` | Yes | Runtime | No | `false`: the `Migrate` job runs the migrations, not each instance at start |
| `DB_CONNECT_TIMEOUT` | Yes | Runtime | No | `5000`: how long, in milliseconds, Loco waits for a database connection. The starter's 500 is too short for the first connection of a new instance |
| `PORT` | No | 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).
* Rust 1.99 on your computer, installed with [rustup](https://rustup.rs), with `rustfmt`.
* The Loco command-line tool and SeaORM's, which Loco's generators run: `cargo install --locked loco sea-orm-cli`. On Debian and Ubuntu, `sea-orm-cli` compiles only with the `pkg-config` and `libssl-dev` packages installed.
* PostgreSQL on your computer, which Loco's generators connect to. With Docker: `docker run -d -p 5432:5432 -e POSTGRES_USER=loco -e POSTGRES_PASSWORD=loco -e POSTGRES_DB=storefront_development postgres:18`.

## Create the project

Create a Loco application with the REST API starter, on PostgreSQL:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
loco new --name storefront --db postgres --bg async --assets none
cd storefront
```

The three options pick the starter without prompts: PostgreSQL, background jobs that run inside the server process, and no front end. The starter has user sign-up and sign-in with JSON Web Tokens, a `users` migration, a mailer for the welcome and password-reset emails, and a `.gitignore` that keeps `Cargo.lock` in the repository. Its binary is `storefront-cli`, which starts the server, runs migrations and runs tasks.

Generate a products API, with its migration, model and controller:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
cargo loco generate scaffold product name:string! price:int! --api
```

The generator connects to the development database, `postgres://loco:loco@localhost:5432/storefront_development`, runs the new migration and writes the SeaORM entities. The routes under `/api/products` need a token from `/api/auth/login`.

## Prepare the project for Sevalla

The starter's `config/production.yaml` reads each setting that differs between environments from an environment variable, and stops at start when one without a default is missing. Its `mailer` section needs an SMTP server in `MAILER_HOST`, `MAILER_USER` and `MAILER_PASSWORD`. To deploy without sending email, delete the `mailer` section, from `# Mailer Configuration.` to the line before `# Initializers Configuration`. Sign-up still works, and the runtime logs show `attempting to send email but no email sender configured` instead of sending the welcome email. Put the section back when you have an SMTP server.

In the same file, change the logger's `format` from `json` to `compact`:

```yaml config/production.yaml theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
  format: compact
```

Sevalla shows a JSON log line's `message` field as the line's text, and Loco's JSON lines keep their text in `fields.message`, so the **Logs** tab would show them as empty lines. The compact format writes one line of text per event, with its level in color.

Then add a `Procfile`, so the web process starts the server, and a `rust-toolchain.toml` that pins the Rust version:

```text Procfile theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
web: storefront-cli start
```

```toml rust-toolchain.toml theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
[toolchain]
channel = "1.99"
```

| File | Why Sevalla needs it |
| - | - |
| `Cargo.toml` | [Ingot](https://docs.sevalla.com/applications/builds/ingot.md) recognizes a Rust project by it, and builds the `storefront` package of its workspace, with the `migration` package it depends on. `storefront-cli` is the binary Ingot installs in `/usr/local/bin`. |
| `Cargo.lock` | The exact version of every crate. Ingot builds with `--locked`, so the build uses those versions. |
| `Procfile` | `storefront-cli` without a command prints its help and exits. The `web` line makes Ingot start the server with `storefront-cli start`. |
| `config/production.yaml` | Loco reads it when `LOCO_ENV` is `production`. Ingot copies the `config` folder into `/app`, the binary's working directory. |
| `rust-toolchain.toml` | Ingot builds on the Rust 1.99 line. See [Pin the Rust version](https://docs.sevalla.com/guides/rust/overview.md#pin-the-rust-version). |

Ingot needs no other configuration. It builds with `cargo auditable build --release --locked`, which records the crates in the binary so vulnerability scanners can read them, and runs the binary under `tini`. Loco connects to PostgreSQL with SQLx and rustls, so the image needs no OpenSSL. The production configuration binds the server to `0.0.0.0` on `PORT`.

`loco new` doesn't create a Git repository. Create one, 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 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 `orders-db` as the **Service** and turn on **Add environment variables to the application**. In the list of variables, rename `DB_URL` to `DATABASE_URL`, the name `config/production.yaml` reads, then click **Add internal connection**. 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 these variables, clear **Available during buildtime**, and click **Add environment variables**:

| Variable | Value |
| - | - |
| `LOCO_ENV` | `production` |
| `JWT_SECRET` | The key that signs sign-in tokens, in base64. Generate one with `openssl rand -base64 48`, which prints 64 characters. |
| `HOST` | The application's address, for example `https://storefront-x7k2m.sevalla.app`. The domain is under the application's name in the page header. |
| `DB_AUTO_MIGRATE` | `false` |
| `DB_CONNECT_TIMEOUT` | `5000` |

**5. Create the migration job**

Click the **Processes** tab, then click **Create process**. Choose **Job**, enter `Migrate` as the **Name** and `storefront-cli db migrate` as the **Custom start command**. Keep the **Start policy** at **Before deployment** and the **Resource** at **H1**, then click **Create process**.

**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 database needs no TLS settings. The internal connection stays on Sevalla's private network, and `DATABASE_URL` starts with `postgres://`, which SeaORM accepts.

## 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): Rust 1.99 from `rust-toolchain.toml`, the workspace package, the start command from the `Procfile` and the `config` folder.

   ```text Build log theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   ╭─ ■ Rust 1.99 application ────────────────────────────────────────────────╮
   │ Rust           1.99                           rust-toolchain.toml "1.99" │
   │ Packages       storefront                     Cargo.toml workspace       │
   │ Start          storefront-cli start           Procfile                   │
   │ Crate list     in the binaries, for scanners  cargo-auditable 0.7.7      │
   │ Runtime files  config                         application root           │
   ├─ Image ──────────────────────────────────────────────────────────────────┤
   │ Build          rust:1.99-slim-trixie                                     │
   │ Runtime        debian:trixie-slim                                        │
   │ User           1000:1000                                                 │
   │ Init           tini                                                      │
   │ Port           $PORT, default 8080                                       │
   ╰─────────────────────────────────────────────────────────── Ingot v0.4.1 ─╯
   ```

   Later, during the rollout, the build log shows `Migrate has been successful`. If the job fails, the deployment fails and the previous version keeps running.
2. Wait for the deployment's status to change to **Deployed**. The application's status next to its name changes to **Running**.
3. Click the domain under the application's name, for example `storefront-x7k2m.sevalla.app`, and add `/_readiness` to the address. The response is `{"ok":true}`: the server is running and reaches the database.

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

The application's [runtime logs](https://docs.sevalla.com/applications/logs.md) show `environment: production` and `listening on http://0.0.0.0:8080` under Loco's banner, then one line per log event. Choose the `Migrate` process on the **Logs** tab to read the migrations it applied, such as `Migration 'm20220101_000001_users' has been applied`. If the deployment fails, read its build log, then see [Troubleshoot](#troubleshoot).

To check the deployment from a terminal or an AI agent, read its `status` with [Get deployment](https://api-docs.sevalla.com/v3/applications/get-deployment) (`getDeploymentV3`) until it's `success`, and its build log with [Get application deployment logs](https://api-docs.sevalla.com/v3/applications/get-application-deployment-logs) (`getApplicationDeploymentLogsV3`), as in [Deploy with the API](#deploy-with-the-api). Then sign up a user, sign in, and create and list a product. Each request after the sign-in sends the token, and the product is stored in PostgreSQL:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s -w '\n' -X POST https://storefront-x7k2m.sevalla.app/api/auth/register \
  -H 'Content-Type: application/json' \
  -d '{"name":"Ada","email":"ada@example.com","password":"correct-horse-battery"}'
TOKEN=$(curl -s -X POST https://storefront-x7k2m.sevalla.app/api/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"ada@example.com","password":"correct-horse-battery"}' | jq -r .token)
curl -s -X POST https://storefront-x7k2m.sevalla.app/api/products \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"name":"Espresso beans","price":1450}' | jq -c '{id, name, price}'
curl -s https://storefront-x7k2m.sevalla.app/api/products \
  -H "Authorization: Bearer $TOKEN" | jq -c '{total_items, names: [.items[].name]}'
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
null
{"id":1,"name":"Espresso beans","price":1450}
{"total_items":1,"names":["Espresso beans"]}
```

`/api/auth/register` answers `null` whether or not it created the user, so a second sign-up with the same email changes nothing. Without the token, `/api/products` answers `401`.

[Get application runtime logs](https://api-docs.sevalla.com/v3/applications/get-application-runtime-logs) (`getApplicationRuntimeLogsV3`) with `search` finds the start of the server:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s "https://api.sevalla.com/v3/applications/$APP_ID/runtime-logs?search=listening" \
  -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r '.[].message'
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
listening on http://0.0.0.0:8080
```

## Shut down gracefully

When Sevalla stops an instance, during a deployment, a restart or a scale-down, it waits 5 seconds, sends `SIGTERM`, and stops the instance if it's still running 30 seconds after the shutdown began. See [How processes run](https://docs.sevalla.com/applications/processes.md#how-processes-run).

Loco handles `SIGTERM` itself: the server stops accepting connections, finishes the requests in progress, then exits. On every deployment, the old instance's [runtime logs](https://docs.sevalla.com/applications/logs.md) end with a line such as:

```text Runtime logs theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
2026-10-30T08:57:01.990006Z  INFO loco_rs::app: shutting down...
```

## Add a health check

Loco answers three monitoring routes: `/_ping` and `/_health` return `{"ok":true}` while the server runs, and `/_readiness` also checks the database, and answers `503` with `{"ok":false}` when it can't reach it. A readiness probe on `/_readiness` makes each new instance wait until it reaches PostgreSQL before it gets traffic.

**1. Open the web process**

On the application's **Processes** tab, click **⋯** on the **Web** process, then click **Update process**.

**2. Turn on the readiness probe**

Under **Health checks**, turn on **Readiness probe** and set the **Path** to `/_readiness`. Keep the **Port** at the web process's port, `8080` unless you changed it.

**3. Save and deploy**

Click **Update process**, then click **Deploy** in the undeployed changes bar at the bottom of the page.

The deployment's status changes to **Deployed** once the new instance answers the probe. See [Health checks](https://docs.sevalla.com/applications/processes.md#health-checks) for every probe setting.

[Deploy with the API](#deploy-with-the-api) sets the same probe. [Get process](https://api-docs.sevalla.com/v3/applications/get-process) (`getProcessV3`) returns it in `readiness_probe`:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s https://api.sevalla.com/v3/applications/$APP_ID/processes/$WEB_ID \
  -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -c .readiness_probe
```

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

## 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. Loco generates one with `cargo loco generate deployment docker`; this one adds a cached dependency layer, a user other than root, and the start command. 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 rust:1.99-slim-trixie AS build
WORKDIR /src
COPY Cargo.toml Cargo.lock ./
COPY migration/Cargo.toml migration/
# Compile the dependencies alone, so this layer stays cached until a Cargo.toml or Cargo.lock changes
RUN mkdir -p src/bin migration/src \
    && echo "fn main() {}" > src/bin/main.rs \
    && touch src/lib.rs migration/src/lib.rs \
    && cargo build --release --locked \
    && rm -rf src migration/src
COPY migration/src migration/src
COPY src src
# touch: the copied files are older than the placeholders' build, so Cargo would skip them
RUN touch src/lib.rs src/bin/main.rs migration/src/lib.rs \
    && cargo build --release --locked

FROM debian:trixie-slim
# hadolint ignore=DL3008
RUN apt-get update \
    && apt-get install -y --no-install-recommends ca-certificates \
    && rm -rf /var/lib/apt/lists/* \
    && useradd --uid 1000 --create-home app
WORKDIR /app
COPY --from=build /src/target/release/storefront-cli /usr/local/bin/storefront-cli
COPY config ./config
USER 1000:1000
EXPOSE 8080
CMD ["storefront-cli", "start"]
```

| Line or choice | Why, and what changed from the generated Dockerfile |
| - | - |
| `rust:1.99-slim-trixie` | The Rust 1.99 line that `rust-toolchain.toml` pins, on Debian 13, the image Ingot builds with too. A line tag, not a digest, so each build gets Rust's patch releases and Debian's security fixes. The generated file pins one release, `rust:1.94.0-slim`. |
| The placeholder sources | Cargo compiles every dependency of both packages from the `Cargo.toml` files and `Cargo.lock` alone, in a layer that Docker reuses until one of them changes. A code change then compiles only your two packages. The generated file copies everything and compiles every crate on each build. |
| `--locked` | Builds the versions in `Cargo.lock`, and fails if a `Cargo.toml` no longer matches it. |
| Only the manifests, `migration/src` and `src` copied | What Cargo needs. `rust-toolchain.toml` stays out: in the folder, it would make rustup download the Rust 1.99 toolchain again instead of using the image's. |
| `debian:trixie-slim` | The binary links to the GNU C library, so it runs on the Debian release it was built on, where the generated file uses Debian 12. The image has `/bin/sh` for the [web terminal](https://docs.sevalla.com/applications/web-terminal.md) and custom start commands, such as the `Migrate` job's, and `ca-certificates` adds the CA certificates for outgoing HTTPS. `# hadolint ignore=DL3008` tells hadolint to skip its version-pinning check, so each build gets Debian's current package. |
| `/usr/local/bin/storefront-cli` and `config` in `/app` | The binary is on the `PATH`, so the job's `storefront-cli db migrate` runs as with Ingot, and Loco finds `config/production.yaml` in its working directory. `LOCO_ENV` and the other variables come from the application's environment variables, so the image holds no secrets. |
| `useradd --uid 1000` and `USER 1000:1000` | The server runs as a user with no privileges, where the generated file runs as root. The binary and the configuration stay owned by root, so the server can't change them. |
| `CMD ["storefront-cli", "start"]` | Starts the server, where the generated `ENTRYPOINT` runs the binary without a command. Loco handles `SIGTERM`, so the server runs as process 1 without `tini`. |
| `EXPOSE 8080` | Documents Sevalla's default `PORT`. `config/production.yaml` reads `PORT`. |

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

The `.dockerignore` keeps the Git history, the GitHub workflows, local environment files, logs and your local `target` folder of build output out of the build.

Commit and push both files. Ingot doesn't build from your `Dockerfile`, and it adds the patterns in `.dockerignore` to its own, so you can keep both files in a repository that builds with Ingot. 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 still shows `Migrate has been successful`: the job runs `storefront-cli db migrate` from the new image. The checks in [Verify the deployment](#verify-the-deployment) answer the same, and the deployment's **Dockerfile** tab shows your file. With **Cache enabled** in the build strategy, the next deployment of a code change shows the dependency step as `CACHED` in the build log. See [Dockerfile](https://docs.sevalla.com/applications/builds/dockerfile.md).

With the API, [Update application](https://api-docs.sevalla.com/v3/applications/update-application) (`updateApplicationV3`) switches the build strategy. Then deploy and wait as in [Deploy with the API](#deploy-with-the-api):

```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 -c '{build_type, dockerfile_path, docker_context}'
```

```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{"build_type":"dockerfile","dockerfile_path":"Dockerfile","docker_context":"."}
```

The CLI does the same with `sevalla apps update $APP_ID --data '{"build_type":"dockerfile","dockerfile_path":"Dockerfile","docker_context":"."}'`. The new deployment's build log names the Dockerfile build:

```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?search=Building%20and%20pushing" \
  -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r '.[].message'
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
📦 Building and pushing docker image using Dockerfile...
```

## 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, generate a migration locally, such as `cargo loco generate migration add_sku_to_products sku:string`, and commit it in `migration/src` with the code; the job applies it on the next deployment. When you add a crate, commit `Cargo.toml` and `Cargo.lock`. See [Deployments](https://docs.sevalla.com/applications/deployments.md).

## Use environment variables

Add variables on the application's **Environment variables** tab; they apply at the next deployment. Loco renders `config/production.yaml` each time `storefront-cli` starts, so `get_env(name="X")` there reads the variable from the environment, and `get_env(name="X", default="...")` falls back to the default. Read your own variables in code with `std::env::var`. 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 `shop.example.com`, add the domain on the application's **Domains** tab and create the DNS records that Sevalla shows. Then set `HOST` to `https://shop.example.com`, so links in emails use it, and deploy. 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 `Migrate` job or the server stops with ``environment variable `MAILER_HOST` not found and no `default` was given`` | `config/production.yaml` still has the starter's `mailer` section, which needs an SMTP server | Delete the `mailer` section, or set `MAILER_HOST`, `MAILER_USER` and `MAILER_PASSWORD`, then deploy. |
| ``environment variable `DATABASE_URL` not found and no `default` was given``, or the same for `JWT_SECRET` or `HOST` | The variable isn't set, or isn't **Available during runtime** | Add the variable, then deploy. |
| The runtime logs show `environment: development` and `listening on http://localhost:8080`, and the deployment never gets a ready instance | `LOCO_ENV` isn't `production`, so Loco reads `config/development.yaml`, which listens on `localhost` only | Set `LOCO_ENV` to `production`, then deploy. |
| `/api/auth/login` answers `401` with `You do not have permission to access this resource` for the right password, and the runtime logs show `unauthorized!` | Loco reads `JWT_SECRET` as base64, and can't sign a token with a key that isn't, such as `testsecret` | Set `JWT_SECRET` to the output of `openssl rand -base64 48`, then deploy. Tokens signed with the old key stop working. |
| The deployment fails at `Migrate has failed`, or the web process restarts, and the runtime logs show `Error: DB(Conn(SqlxError(PoolTimedOut)))` | Loco didn't connect to PostgreSQL within `DB_CONNECT_TIMEOUT`, which defaults to 500 milliseconds in the starter's `config/production.yaml`. A new instance's first connection can take longer. | Set `DB_CONNECT_TIMEOUT` to `5000`, then deploy. If it still fails, check the internal connection and `DATABASE_URL`. |
| The deployment fails at `Migrate has failed` | The migration raised an error, or the job can't reach the database | On the **Logs** tab, choose the `Migrate` process to read its output. Check the internal connection and `DATABASE_URL`. |

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

## Deploy with the API

These calls make the same deployment as [Deploy to Sevalla](#deploy-to-sevalla) and [Add a health check](#add-a-health-check), in an order that a script or an AI agent runs as it is. Each step names the [Sevalla API](https://docs.sevalla.com/developer-tools/api.md) operation, then 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. The sequence creates two services, a database and an application; the API allows 20 creations of each per hour. 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 a Git provider that's connected to your Sevalla account. See [Git providers](https://docs.sevalla.com/git/overview.md). Creating the application checks it: when 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 access.
* `curl`, `jq` and `openssl`.

**1. Choose the location**

[List clusters](https://api-docs.sevalla.com/v3/resources/list-clusters) (`listClustersV3`) returns the locations your company can use, each with its `id`, the `display_name` that the dashboard shows and its region ID in `location`. Keep the ID of the one you want by its region ID, such as `eu-frankfurt-1` for Frankfurt, Germany. [Locations](https://docs.sevalla.com/platform/locations.md) lists them all. The database and the application must be in the same location:

```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 newest PostgreSQL version**

[List database versions](https://api-docs.sevalla.com/v3/resources/list-database-versions) (`listDatabaseVersionsV3`) returns the versions you can create in the location:

```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 | max_by(tonumber)')
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 `version` out.

**3. Find the DB1 size**

[List database resource types](https://api-docs.sevalla.com/v3/resources/list-database-resource-types) (`listDatabaseResourceTypesV3`) returns the database sizes by `name`. **DB1**, the smallest, is the dashboard's default:

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

**4. Create the database**

[Create database](https://api-docs.sevalla.com/v3/databases/create-database) (`createDatabaseV3`) needs a password: generate one and keep it, because [Get database](https://api-docs.sevalla.com/v3/databases/get-database) (`getDatabaseV3`) doesn't return it. The call returns at once, with the status `creating`:

```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 '{"display_name":"orders-db","type":"postgresql","version":"'"$PG_VERSION"'","cluster_id":"'"$CLUSTER_ID"'","resource_type_id":"'"$DB_SIZE_ID"'","db_name":"orders","db_user":"storefront","db_password":"'"$DB_PASSWORD"'"}' \
  | jq -r .id)
```

CLI: `sevalla databases create --name orders-db --type postgresql --db-version $PG_VERSION --cluster $CLUSTER_ID --resource-type $DB_SIZE_ID --db-name orders --db-user storefront --db-password $DB_PASSWORD`. MCP: `create_database` with `connect_to_application_id` and `env_var_name` set to `DATABASE_URL` also connects the application and stores the URL, with the scheme `postgresql://`, which SeaORM accepts as well as `postgres://`.

**5. Wait for the database**

[Get database](https://api-docs.sevalla.com/v3/databases/get-database) (`getDatabaseV3`) returns the status, `creating` and then `ready`, and the internal hostname and port that the application connects to. Poll it for up to 10 minutes:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
for i in $(seq 1 60); 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')
  [ "$STATUS" = ready ] && break
  sleep 10
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
```

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 a web process. It builds with Ingot (`build_type` is `ingot`) and isn't deployed yet:

```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 '{"display_name":"storefront","cluster_id":"'"$CLUSTER_ID"'","source":"privateGit","git_type":"github","repo_url":"https://github.com/acme/storefront","default_branch":"main"}' \
  | jq -r .id)
```

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 domain**

[List application domains](https://api-docs.sevalla.com/v3/applications/list-application-domains) (`listApplicationDomainsV3`) returns the domain that Sevalla gives the application. It appears a few seconds after the application is created, so ask until it's there:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
for i in $(seq 1 24); do
  HOST=$(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 "$HOST" ] && break
  sleep 5
done
echo "$HOST"
```

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

**8. Connect the database**

[Create internal connection](https://api-docs.sevalla.com/v3/applications/create-internal-connection) (`createApplicationInternalConnectionV3`) connects the application to the database over the private network. Unlike the dashboard, it doesn't add environment variables:

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

CLI: `sevalla apps internal-connections create $APP_ID --target-id $DB_ID --target-type database`. MCP: `sevalla_api_create` with `createApplicationInternalConnectionV3`.

**9. Add the environment variables**

[Create environment variable](https://api-docs.sevalla.com/v3/applications/create-environment-variable) (`createEnvironmentVariableV3`) adds the database URL in the format that the dashboard's internal connection uses, a new base64 `JWT_SECRET` and the other settings, available at runtime only:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
for VAR in "DATABASE_URL=postgres://storefront:$DB_PASSWORD@$DB_HOST:$DB_PORT/orders" "LOCO_ENV=production" "JWT_SECRET=$(openssl rand -base64 48)" "HOST=https://$HOST" "DB_AUTO_MIGRATE=false" "DB_CONNECT_TIMEOUT=5000"; do
  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 -nc --arg kv "$VAR" '{key: ($kv | sub("=.*"; "")), value: ($kv | sub("^[^=]*="; "")), is_runtime: true, is_buildtime: false}')" \
    | jq -c '{key, is_runtime, is_buildtime}'
done
```

```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{"key":"DATABASE_URL","is_runtime":true,"is_buildtime":false}
{"key":"LOCO_ENV","is_runtime":true,"is_buildtime":false}
{"key":"JWT_SECRET","is_runtime":true,"is_buildtime":false}
{"key":"HOST","is_runtime":true,"is_buildtime":false}
{"key":"DB_AUTO_MIGRATE","is_runtime":true,"is_buildtime":false}
{"key":"DB_CONNECT_TIMEOUT","is_runtime":true,"is_buildtime":false}
```

CLI: `sevalla apps env-vars create $APP_ID --key LOCO_ENV --value production`, once per variable; it always makes a variable available during the build and runtime. MCP: `set_environment_variables` with `buildtime` set to `false`.

**10. Find the web process**

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

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

**11. Find the H1 size**

[List process resource types](https://api-docs.sevalla.com/v3/resources/list-process-resource-types) (`listProcessResourceTypesV3`) returns the sizes by `name`, in lowercase:

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

**12. Create the migration job**

[Create process](https://api-docs.sevalla.com/v3/applications/create-process) (`createProcessV3`) creates the `Migrate` job. The API needs the size, a `scaling_strategy` and the `job_start_policy`, which the dashboard fills in for you:

```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 '{"display_name":"Migrate","type":"job","entrypoint":"storefront-cli db migrate","resource_type_id":"'"$H1_ID"'","scaling_strategy":{"type":"manual","config":{"instanceCount":1}},"job_start_policy":"beforeDeployment"}' \
  | jq -c '{display_name, type, entrypoint, job_start_policy, resource_type_name}'
```

```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{"display_name":"Migrate","type":"job","entrypoint":"storefront-cli db migrate","job_start_policy":"beforeDeployment","resource_type_name":"h1"}
```

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

**13. Set the size and the readiness probe**

The API creates the web process at **S1**, where the dashboard's default is **H1**. [Update process](https://api-docs.sevalla.com/v3/applications/update-process) (`updateProcessV3`) sets the size and adds the readiness probe from [Add a health check](#add-a-health-check), with the dashboard's default timings. Send `timeoutSeconds`: a probe without it gets 120 seconds, where the dashboard sets 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 '{"resource_type_id":"'"$H1_ID"'","readiness_probe":{"httpGet":{"path":"/_readiness","port":8080,"scheme":"HTTP"},"initialDelaySeconds":0,"periodSeconds":10,"timeoutSeconds":1,"successThreshold":1,"failureThreshold":3}}' \
  | jq -c '{resource_type_name, readiness_probe}'
```

CLI: `sevalla apps processes update $WEB_ID --app-id $APP_ID --resource-type $H1_ID` sets the size; the CLI can't set probes. MCP: `scale_process` with `size_id` for the size, and `sevalla_api_update` with `updateProcessV3` and the same body for the probe.

**14. Turn on auto-deploy**

[Update application](https://api-docs.sevalla.com/v3/applications/update-application) (`updateApplicationV3`) turns on automatic deployments for 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`.

**15. Deploy**

[Trigger deployment](https://api-docs.sevalla.com/v3/applications/trigger-deployment) (`triggerDeploymentV3`) deploys the latest commit on 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 '{"branch":"main"}' | jq -r .id)
```

CLI: `sevalla apps deployments trigger $APP_ID --branch main`. MCP: `trigger_deployment`.

**16. Wait for the deployment**

[Get deployment](https://api-docs.sevalla.com/v3/applications/get-deployment) (`getDeploymentV3`) returns the deployment's `status`: `waiting` and `inProgress` while it runs, then `success`, `failed`, `cancelled` or `skipped`. Poll it until the status is final, for up to 30 minutes:

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

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

**17. Read the build log**

[Get application deployment logs](https://api-docs.sevalla.com/v3/applications/get-application-deployment-logs) (`getApplicationDeploymentLogsV3`) returns the build log, oldest line first: the build plan from [Verify the deployment](#verify-the-deployment), the build and the rollout. When the status is `failed`, the last lines say why. With `search`, it finds the job's result:

```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?search=Migrate%20has" \
  -H "Authorization: Bearer $SEVALLA_API_KEY" | jq -r '.[].message'
```

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

Without `search`, add `?limit=5000` to read the whole build log. CLI: `sevalla apps deployments logs $DEPLOYMENT_ID --app-id $APP_ID`. MCP: `get_deployment_logs`.

**18. Check the application**

Request the readiness route from the application's domain:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s -w '\n' https://$HOST/_readiness
```

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

Then run the other checks in [Verify the deployment](#verify-the-deployment).


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