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

> Deploy a Gleam web application built with Wisp and Mist to Sevalla: Ingot exports an Erlang shipment, the server listens on PORT, and a Dockerfile runs it on Erlang/OTP 29.

A Wisp application runs on Sevalla as a Gleam application on the Erlang virtual machine (the BEAM): [Ingot](https://docs.sevalla.com/applications/builds/ingot.md), Sevalla's builder, compiles it from your Git repository with `gleam export erlang-shipment`, and the web process runs the shipment's start script, `/app/entrypoint.sh run`, on Erlang/OTP 29. Wisp handles the requests, and Mist, the web server under it, listens on the port that Sevalla sets in the `PORT` environment variable.

## At a glance

| | |
| - | - |
| Source | A Git repository |
| Build | [Ingot](https://docs.sevalla.com/applications/builds/ingot.md) (Gleam 1 on Erlang/OTP 29, an Erlang shipment), or the [Dockerfile](#deploy-with-a-dockerfile) |
| Start command | `/app/entrypoint.sh run`, the Erlang shipment's start script |
| Port | The `PORT` environment variable, `8080` by default |
| Processes | Web process |
| Services | None |
| Disk | None |
| Health check | `GET /healthz` returns `200` (readiness probe) |
| Size | **H1** for the web process |

### Environment variables

| Variable | Required | Available during | Secret | Value |
| - | - | - | - | - |
| `SECRET_KEY_BASE` | Yes | Runtime | Yes | 64 random characters. Generate them with `openssl rand -base64 48` |
| `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).
* Gleam 1.19 on your computer, with Erlang/OTP 29 and rebar3, which compiles the Erlang packages that Mist depends on. See [Installing Gleam](https://gleam.run/getting-started/installing/).

## Create the project

Create a project and add Wisp, Mist and the packages the server uses:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
gleam new storefront
cd storefront
gleam add wisp mist gleam_http gleam_erlang gleam_json envoy
```

`gleam new` creates `gleam.toml`, `src/storefront.gleam`, a test, a `.gitignore` that lists `/build`, and a Git repository. `gleam add` adds Wisp 2, Mist 6, `gleam_json` for JSON and `envoy` to read environment variables, and writes the exact version of every package to `manifest.toml`.

Replace `src/storefront.gleam` with a JSON API and a health endpoint:

```gleam src/storefront.gleam theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import envoy
import gleam/erlang/process
import gleam/int
import gleam/json
import gleam/list
import gleam/result
import mist
import wisp.{type Request, type Response}
import wisp/wisp_mist

type Product {
  Product(id: Int, name: String, price: Float)
}

const products = [
  Product(id: 1, name: "Espresso beans", price: 14.5),
  Product(id: 2, name: "Pour-over kettle", price: 39.0),
]

pub fn main() -> Nil {
  wisp.configure_logger()

  let port =
    envoy.get("PORT")
    |> result.try(int.parse)
    |> result.unwrap(8080)
  // Wisp signs cookies with this key: keep it the same across deployments
  let assert Ok(secret_key_base) = envoy.get("SECRET_KEY_BASE")
    as "SECRET_KEY_BASE is not set"

  let assert Ok(_) =
    wisp_mist.handler(handle_request, secret_key_base)
    |> mist.new
    |> mist.bind("0.0.0.0")
    |> mist.port(port)
    |> mist.start

  process.sleep_forever()
}

fn handle_request(req: Request) -> Response {
  use <- wisp.log_request(req)
  use <- wisp.rescue_crashes
  use req <- wisp.handle_head(req)

  case wisp.path_segments(req) {
    ["healthz"] -> json_response(json.object([#("status", json.string("ok"))]))
    ["api", "products"] -> json_response(json.array(products, product_json))
    ["api", "products", id] ->
      case int.parse(id) |> result.try(find_product) {
        Ok(product) -> json_response(product_json(product))
        Error(_) ->
          json.object([#("error", json.string("product not found"))])
          |> json.to_string
          |> wisp.json_response(404)
      }
    _ -> wisp.not_found()
  }
}

fn find_product(id: Int) -> Result(Product, Nil) {
  list.find(products, fn(product) { product.id == id })
}

fn product_json(product: Product) -> json.Json {
  json.object([
    #("id", json.int(product.id)),
    #("name", json.string(product.name)),
    #("price", json.float(product.price)),
  ])
}

fn json_response(body: json.Json) -> Response {
  body |> json.to_string |> wisp.json_response(200)
}
```

* Mist listens on `127.0.0.1` unless you bind it elsewhere. `mist.bind("0.0.0.0")` makes it listen on all network interfaces, so Sevalla's proxy can reach it.
* `PORT` is the web process's port, which Sevalla sets (`8080` by default). On your computer, the server uses port 8080.
* `SECRET_KEY_BASE` is the key Wisp signs and encrypts cookies with. The server stops at start with `SECRET_KEY_BASE is not set` when it's missing, so a deployment without it never serves requests. Every instance and every deployment needs the same key, or cookies that an earlier one set stop working.
* `wisp.log_request` logs each request, such as `INFO 200 GET /api/products`, to the [runtime logs](https://docs.sevalla.com/applications/logs.md).

## Prepare the project for Sevalla

Set the Gleam version that the project needs in `gleam.toml`, under `version`:

```toml gleam.toml theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
name = "storefront"
version = "1.0.0"
gleam = ">= 1.19.0"
```

| File | Why Sevalla needs it |
| - | - |
| `gleam.toml` | [Ingot](https://docs.sevalla.com/applications/builds/ingot.md) recognizes a Gleam project by it, and reads the `gleam` requirement. Ingot builds with the newest Gleam release it knows that meets it. See [Pin the Gleam version](https://docs.sevalla.com/guides/gleam/overview.md#pin-the-gleam-version). |
| `manifest.toml` | The exact version of every package. Ingot downloads them with `gleam.toml` and `manifest.toml` alone, in a layer that's reused while only your code changes. Commit it. |

The project needs no other configuration. Ingot builds it with `gleam export erlang-shipment`, which compiles the project and its packages to Erlang bytecode in `build/erlang-shipment`, with `entrypoint.sh`, the script that starts the `main` function of the module named after the project. The image runs the shipment on `erlang:29-slim`, the Erlang/OTP release that the Gleam image compiled it with, without Gleam itself.

Run the server on your computer with a key for the session, and open `http://localhost:8080/api/products` to check it:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
SECRET_KEY_BASE=$(openssl rand -base64 48) gleam run
```

`gleam new` already created a Git repository, so commit everything and push it to your Git provider:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
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 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 a **Data center**. 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.

**2. Add the secret key**

Click the **Environment variables** tab, then click **Add environment variables**. Enter `SECRET_KEY_BASE` as the key and 64 random characters as the value: `openssl rand -base64 48` prints them. Clear **Available during buildtime**, so the key stays out of the build, then click **Add environment variables**.

**3. Deploy**

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

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

## 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): Gleam from the requirement in `gleam.toml`, the shipment's start script, and the Gleam and Erlang images.

   ```text Build log theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   ╭─ ■ Gleam 1 application ───────────────────────────────────────╮
   │ Gleam    1                       gleam.toml gleam ">= 1.19.0" │
   │ Start    /app/entrypoint.sh run  Erlang shipment              │
   ├─ Image ───────────────────────────────────────────────────────┤
   │ Build    ghcr.io/gleam-lang/gleam:v1.19.2-erlang              │
   │ Runtime  erlang:29-slim                                       │
   │ User     1000:1000                                            │
   │ Init     tini                                                 │
   │ Port     $PORT, default 8080                                  │
   ╰──────────────────────────────────────────────── Ingot v0.4.1 ─╯
   ```

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 `/api/products` to the address. The response is:

   ```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   [{"id":1,"name":"Espresso beans","price":14.5},{"id":2,"name":"Pour-over kettle","price":39.0}]
   ```

   `/api/products/2` returns one product, `/api/products/9` returns `{"error":"product not found"}` with status `404`, and `/healthz` returns `{"status":"ok"}`.

![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 `Listening on http://0.0.0.0:8080`, then one line per request, such as `INFO 200 GET /api/products`. Wisp colors the level with terminal escape codes, which the API returns as part of each log `message`. 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 request the endpoints:

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

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{"id":2,"name":"Pour-over kettle","price":39.0}
404
{"status":"ok"}
```

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

The Erlang virtual machine handles `SIGTERM` itself: it stops the running applications in order, then exits, so the server needs no code for it. `entrypoint.sh` replaces itself with the Erlang runtime, which receives the signal from `tini` in Ingot's image, or directly as process 1 in the [Dockerfile](#deploy-with-a-dockerfile). On every deployment, the old instance's [runtime logs](https://docs.sevalla.com/applications/logs.md) end with the runtime's notice, in the format that `wisp.configure_logger` gives Erlang's log messages:

```text Runtime logs theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
NTCE args=[] label=ErrorLogger(InfoMsg) format=charlist.from_string("SIGTERM received - shutting down~n")
```

## Add a health check

A readiness probe makes each new instance wait for `/healthz` to answer before it gets traffic, so a deployment switches over only to instances that serve requests. An instance that stops at start, for example without `SECRET_KEY_BASE`, never passes the probe, and the previous deployment keeps serving.

**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 `/healthz`. 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":"/healthz","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. 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 ghcr.io/gleam-lang/gleam:v1.19.2-erlang AS build
WORKDIR /src
COPY gleam.toml manifest.toml ./
RUN gleam deps download
COPY src ./src
RUN gleam export erlang-shipment

FROM erlang:29-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
ENV LANG=C.UTF-8
WORKDIR /app
COPY --from=build /src/build/erlang-shipment ./
USER 1000:1000
EXPOSE 8080
CMD ["/app/entrypoint.sh", "run"]
```

| Line or choice | Why |
| - | - |
| `ghcr.io/gleam-lang/gleam:v1.19.2-erlang` | The Gleam team's official image, with Gleam 1.19.2, Erlang/OTP 29 and rebar3, which compiles Mist's Erlang dependency. Gleam publishes images for exact releases only, so change the tag when you update Gleam. The build stage isn't part of the final image. |
| Two stages | The `build` stage has Gleam, the compilers and the source. The final stage gets only the compiled shipment. |
| `gleam.toml` and `manifest.toml` first | `gleam deps download` runs in a layer that Docker reuses until either file changes, so a code change only compiles the project again. |
| `erlang:29-slim` | The official Erlang image of the OTP release that the Gleam image compiles with. Code compiled by an OTP release runs on its later patch releases, and the `29-slim` line tag gets them, with Debian's security fixes, on every build. The image has `/bin/sh` for `entrypoint.sh`, the [web terminal](https://docs.sevalla.com/applications/web-terminal.md) and custom start commands. |
| `ca-certificates` | The image leaves out the CA certificates that outgoing HTTPS requests need. `# hadolint ignore=DL3008` tells hadolint to skip its version-pinning check, so each build gets Debian's current package. |
| `ENV LANG=C.UTF-8` | Erlang reads file names as UTF-8 only in a UTF-8 locale, which the image doesn't set. |
| `useradd --uid 1000` and `USER 1000:1000` | The server runs as a user with no privileges. The shipment stays owned by root, so the server can't change it. |
| No init process | `entrypoint.sh` replaces itself with the Erlang runtime, which runs as process 1 and handles `SIGTERM` itself. |
| `EXPOSE 8080` | Documents Sevalla's default `PORT`. The server reads `PORT` when it starts. |

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

The `.dockerignore` keeps the Git history, the GitHub workflow, local environment files, logs and your local `build` folder 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 its **Dockerfile** tab shows your file. When the deployment's status is **Deployed**, `/api/products` returns the same JSON. With **Cache enabled** in the build strategy, the next deployment of a code change shows the `gleam deps download` 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, then replaces the web process's instances. When you add a package with `gleam add`, commit `gleam.toml` and `manifest.toml` with the code. See [Deployments](https://docs.sevalla.com/applications/deployments.md).

## Use environment variables

Add configuration on the application's **Environment variables** tab and read it with `envoy.get`, as the server reads `PORT` and `SECRET_KEY_BASE`. The server reads them when it starts, and variables apply at the next deployment. Variables marked **Available during buildtime** also reach the build; Gleam code reads none of them while it compiles. The Erlang runtime reads `ERL_FLAGS` when it starts: `+S 1` runs it on 1 scheduler thread, to match an **H1** instance, where it would otherwise start one per CPU of the server. See [How Gleam applications run on Sevalla](https://docs.sevalla.com/guides/gleam/overview.md#how-gleam-applications-run-on-sevalla) and [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. 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 runtime logs show `runtime error: let assert` and `SECRET_KEY_BASE is not set`, and the instance restarts | `SECRET_KEY_BASE` isn't set, or isn't **Available during runtime** | Add the variable, then deploy. |
| The build log warns `! There is no manifest.toml, so dependency versions are chosen during the build. Commit manifest.toml for reproducible builds` | `manifest.toml` isn't in the repository | Commit `manifest.toml`. `gleam new` doesn't list it in `.gitignore`. |
| The build fails with `✗ gleam.toml targets JavaScript` | `target = "javascript"` in `gleam.toml`. Ingot runs Gleam applications on Erlang only. | Remove `target`, or set it to `"erlang"`. |

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

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. 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` and `APP: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:

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

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

**4. Add the secret key**

[Create environment variable](https://api-docs.sevalla.com/v3/applications/create-environment-variable) (`createEnvironmentVariableV3`) adds a new `SECRET_KEY_BASE`, available at runtime only. `openssl rand -base64 48` prints 64 random characters:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
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 '{"key":"SECRET_KEY_BASE","value":"'"$(openssl rand -base64 48)"'","is_runtime":true,"is_buildtime":false}' \
  | jq -c '{key, is_runtime, is_buildtime}'
```

```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{"key":"SECRET_KEY_BASE","is_runtime":true,"is_buildtime":false}
```

CLI: `sevalla apps env-vars create $APP_ID --key SECRET_KEY_BASE --value "$(openssl rand -base64 48)"`, which makes the variable available during the build too. MCP: `set_environment_variables` with `buildtime` set to `false`.

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

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

**7. 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":"/healthz","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.

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

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

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

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

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

CLI: `sevalla apps deployments logs $DEPLOYMENT_ID --app-id $APP_ID`. MCP: `get_deployment_logs`.

**12. Check the application**

Request the products from the application's domain:

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

```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
[{"id":1,"name":"Espresso beans","price":14.5},{"id":2,"name":"Pour-over kettle","price":39.0}]
```

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.