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

> Deploy a Phoenix 1.8 application to Sevalla: Ingot builds a Mix release, a job runs the Ecto migrations on PostgreSQL, and LiveView connects over WebSockets.

A Phoenix application runs on Sevalla as an application built from a Mix release: [Ingot](https://docs.sevalla.com/applications/builds/ingot.md), Sevalla's builder, compiles the project in the `prod` environment, builds its assets with `mix assets.deploy` and assembles a release that brings its own copy of the Erlang runtime. The web process starts it with `/app/bin/storefront start`, and Phoenix listens on the port that Sevalla sets in the `PORT` environment variable. This guide deploys a Phoenix 1.8 application with a LiveView catalog stored in a Sevalla PostgreSQL database, and runs the Ecto migrations in a job before each deployment.

## At a glance

| | |
| - | - |
| Source | A Git repository |
| Build | [Ingot](https://docs.sevalla.com/applications/builds/ingot.md) (Elixir 1.20 on Erlang/OTP 29, a Mix release), or the [Dockerfile](#deploy-with-a-dockerfile) |
| Start command | `/app/bin/storefront start` |
| Port | The `PORT` environment variable, `8080` by default |
| Processes | Web process; `Migrate` job (`/app/bin/migrate`, **Before deployment**) |
| Services | PostgreSQL over an internal connection |
| Disk | None |
| Health check | None |
| Size | **H1** for the web process and the job |

### Environment variables

| Variable | Required | Available during | Secret | Value |
| - | - | - | - | - |
| `DATABASE_URL` | Yes | Runtime | Yes | `DB_URL` from the internal connection, renamed: `postgres://<user>:<password>@<host>:<port>/<database>` |
| `SECRET_KEY_BASE` | Yes | Runtime | Yes | 64 random characters. Generate them with `mix phx.gen.secret` |
| `PHX_HOST` | Yes | Runtime | No | The application's domain without the scheme, such as `storefront-x7k2m.sevalla.app` |
| `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).
* Elixir 1.20 with Erlang/OTP 29 on your computer.

## Create the project

Install the Phoenix project generator, then create a project named `storefront` and fetch its dependencies:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
mix archive.install hex phx_new 1.8.16 --force
mix phx.new storefront --install
cd storefront
```

The project uses PostgreSQL through Ecto, Bandit as its web server, and esbuild and Tailwind for its assets. `mix phx.new` also creates a Git repository in the folder.

Generate a LiveView catalog of products, with its Ecto schema and migration:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
mix phx.gen.live Catalog Product products name:string price:decimal
```

Add the routes it prints to the `scope "/"` block in `lib/storefront_web/router.ex`:

```elixir lib/storefront_web/router.ex theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
  scope "/", StorefrontWeb do
    pipe_through :browser

    get "/", PageController, :home

    live "/products", ProductLive.Index, :index
    live "/products/new", ProductLive.Form, :new
    live "/products/:id", ProductLive.Show, :show
    live "/products/:id/edit", ProductLive.Form, :edit
  end
```

## Prepare the project for Sevalla

Add the release files that Phoenix generates for deployments:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
mix phx.gen.release
```

It adds `rel/overlays/bin/server`, which starts the release with `PHX_SERVER=true`, `rel/overlays/bin/migrate`, which runs the Ecto migrations without Mix, and the `Storefront.Release` module that `migrate` calls.

Then set the Elixir version in `mix.exs`. Phoenix's generator writes `"~> 1.17"`; raise it to the version you develop with:

```elixir mix.exs theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
      elixir: "~> 1.20",
```

Ingot builds on the newest Elixir line that the requirement allows, starting with its default line, so this project builds on Elixir 1.20 with Erlang/OTP 29. See [Pin the Elixir version](https://docs.sevalla.com/guides/elixir/overview.md#pin-the-elixir-version).

| File | Why Sevalla needs it |
| - | - |
| `mix.exs` | Ingot recognizes the Mix project, reads the Elixir requirement and the application's name, `storefront`, which names the release and its start script, and finds the `assets.deploy` alias that builds the assets. |
| `mix.lock` | Ingot fetches exactly the dependency versions you tested with `mix deps.get --only prod`. Without it, the build log warns that versions can change. |
| `config/runtime.exs` | Phoenix reads `DATABASE_URL`, `SECRET_KEY_BASE`, `PHX_HOST` and `PORT` here each time the release starts, so you set them as environment variables on Sevalla, not in the image. |
| `rel/overlays/bin/migrate` | The command the migration job runs. Ingot recommends it as a job when the file exists. |

Ingot needs no other configuration. In the image it builds, it:

* Sets `PHX_SERVER=true`, so the release starts the web server, and `LANG=C.UTF-8`, the UTF-8 locale Elixir expects.
* Starts the release with `/app/bin/storefront start`. `config/runtime.exs` reads `PORT`, which Sevalla sets to the web process's port (`8080` by default), and Bandit listens on all interfaces.
* Doesn't run migrations. A [job](https://docs.sevalla.com/applications/processes.md#jobs) runs them once per deployment, before the new version starts.

`config/prod.exs` turns on `force_ssl` with `rewrite_on: [:x_forwarded_proto]`. Sevalla's proxy ends HTTPS and sets `X-Forwarded-Proto`, so Phoenix treats requests to your domain as HTTPS, redirects `http://` addresses to `https://`, and sends a `strict-transport-security` header.

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 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/runtime.exs` 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 and click **Add environment variables**:

| Variable | Value |
| - | - |
| `SECRET_KEY_BASE` | The key that signs and encrypts cookies. Generate one with `mix phx.gen.secret`, which prints 64 random characters. |
| `PHX_HOST` | The application's domain without the scheme, for example `storefront-x7k2m.sevalla.app`. It's under the application's name in the page header. |

**5. Create the migration job**

Click the **Processes** tab, then click **Create process**. Choose **Job**, enter `Migrate` as the **Name** and `/app/bin/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 Create process sheet with Job selected, the Name and Custom start command fields, the Start policy set to Before deployment, and the H1 resource](https://assets.sevalla.com/screenshots/light/app-processes-job-create.png)

The database needs no SSL settings: leave `ssl: true` commented out in `config/runtime.exs`. The internal connection stays on Sevalla's private network, and PostgreSQL databases in Google Cloud locations don't accept TLS connections. `DATABASE_URL` starts with `postgres://`, which Ecto accepts as it does `ecto://`.

## 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): Elixir 1.20 from `mix.exs`, Erlang/OTP 29, Phoenix, the assets step and the release's start script.

   ```text Build log theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   ╭─ ■ Phoenix · Elixir 1.20 ──────────────────────────────────────╮
   │ Elixir     1.20                       mix.exs elixir "~> 1.20" │
   │ Erlang     29                         elixir:1.20-otp-29       │
   │ Framework  Phoenix                    mix.lock                 │
   │ Assets     mix assets.deploy          mix.exs aliases          │
   │ Start      /app/bin/storefront start  mix.exs app              │
   ├─ Image ────────────────────────────────────────────────────────┤
   │ Build      elixir:1.20-otp-29                                  │
   │ Runtime    debian:trixie-slim                                  │
   │ User       1000:1000                                           │
   │ Init       tini                                                │
   │ Port       $PORT, default 8080                                 │
   ╰───────────────────────────────────────────────── Ingot v0.4.1 ─╯

   ! Set DATABASE_URL and SECRET_KEY_BASE in the platform's environment: config/runtime.exs requires them when the application starts. Generate SECRET_KEY_BASE with mix phx.gen.secret
   ! Set PHX_HOST to the domain the application is served on: Phoenix builds URLs with it, and LiveView refuses WebSocket connections from other origins
   ```

   The two warnings are reminders that Ingot prints for every Phoenix project whose `config/runtime.exs` reads these variables, even when you've set them. Later, the build log shows `Release created at /release` and, during the rollout, `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`. Phoenix's welcome page opens with `Peace of mind from prototype to production.`
4. Add `/products/new` to the address, enter a name and a price, and click **Save Product**. The catalog opens with `Product created successfully` and the new product: the form ran over LiveView's WebSocket connection, and the product is in PostgreSQL.

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

On the **Logs** tab, each LiveView connection logs `CONNECTED TO Phoenix.LiveView.Socket` with `Transport: :websocket`. If the deployment fails, read its build log, then see [Troubleshoot Ingot builds](https://docs.sevalla.com/applications/builds/ingot.md#troubleshoot-ingot-builds).

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 https://storefront-x7k2m.sevalla.app/products | grep -o -m1 'Listing Products'
```

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

[Get application runtime logs](https://api-docs.sevalla.com/v3/applications/get-application-runtime-logs) (`getApplicationRuntimeLogsV3`) with `search` finds the migrations that the `Migrate` job ran:

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

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
05:17:18.752 [info] == Migrated 20261029185529 in 0.0s
```

The migration's version is the time you generated it, so yours differs.

## LiveView and WebSockets

Sevalla's proxy passes WebSocket connections through to the application with no settings. Phoenix accepts a LiveView connection only from a page on the host in `PHX_HOST` (the endpoint's `check_origin` default). A page served from another domain gets `403` for `/live/websocket`, its LiveViews don't connect, and the runtime logs show `Could not check origin for Phoenix.Socket transport.` with the origin of the request.

To check it from a terminal or an AI agent, open the WebSocket with `curl`, once with the application's own origin and once with another one. Phoenix answers `101` (switching to the WebSocket) and `403`:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
for ORIGIN in https://storefront-x7k2m.sevalla.app https://shop.example.com; do
  curl -s -o /dev/null -w '%{http_code}\n' --http1.1 --max-time 5 \
    -H "Connection: Upgrade" -H "Upgrade: websocket" \
    -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
    -H "Origin: $ORIGIN" "https://storefront-x7k2m.sevalla.app/live/websocket?vsn=2.0.0"
done
```

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

To serve LiveView on a [custom domain](#add-a-custom-domain) and on the `sevalla.app` domain, list both origins in `config/runtime.exs`, next to `url:`:

```elixir config/runtime.exs theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
  config :storefront, StorefrontWeb.Endpoint,
    url: [host: host, port: 443, scheme: "https"],
    check_origin: ["https://shop.example.com", "https://storefront-x7k2m.sevalla.app"],
```

Commit the change and deploy. Connections from other origins still get `403`.

## Run more than one instance

Every instance of the web process is a separate Erlang node with its own LiveView processes, and each browser keeps its WebSocket connection to one instance, so LiveView works with several instances. The nodes don't form a cluster: Phoenix's generated `DNSCluster` needs a DNS name that lists every instance's address, and the web process's internal hostname resolves to a single service address. Leave `DNS_CLUSTER_QUERY` unset. Messages that `Phoenix.PubSub` broadcasts reach the subscribers on the same instance only.

## Deploy with a Dockerfile

Ingot needs no Dockerfile. Write one when you want full control of the image: the base image, system packages and every layer. This Dockerfile starts from the one that `mix phx.gen.release --docker` generates. 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 elixir:1.20-otp-29 AS builder
WORKDIR /app
RUN mix local.hex --force && mix local.rebar --force
ENV MIX_ENV=prod

COPY mix.exs mix.lock ./
RUN mix deps.get --only prod && mkdir config
# Dependencies compile with the compile-time configuration
COPY config/config.exs config/prod.exs config/
RUN mix deps.compile && mix assets.setup

COPY priv priv
COPY lib lib
RUN mix compile
COPY assets assets
RUN mix assets.deploy
# Changes to config/runtime.exs don't require recompiling the code
COPY config/runtime.exs config/
COPY rel rel
RUN mix release

FROM debian:trixie-slim
# Debian drops old package versions when it ships fixes, so pinned versions would break rebuilds
# hadolint ignore=DL3008
RUN apt-get update \
  && apt-get install -y --no-install-recommends libssl3t64 libsctp1 ca-certificates \
  && rm -rf /var/lib/apt/lists/*
ENV LANG=C.UTF-8
WORKDIR /app
COPY --from=builder /app/_build/prod/rel/storefront ./
USER 65534:65534
EXPOSE 8080
CMD ["/app/bin/server"]
```

| Line or choice | Why, and what changed from the generated Dockerfile |
| - | - |
| `elixir:1.20-otp-29` | The official Elixir image that Ingot builds with too. It already has the compilers and Git that the generated Dockerfile installs with `apt-get`, and the `1.20-otp-29` tag moves to each patch release, where the generated file pins a dated `hexpm/elixir` image. |
| `debian:trixie-slim` | The final image: the Debian release the builder is built on, as the release's Erlang runtime needs. The line tag picks up Debian's security fixes on every rebuild; a digest or dated tag would freeze them. |
| Layer order | Kept from the generated file: `mix.exs`, `mix.lock` and the compile-time configuration come before the code, so a change to the code reuses the layers with the compiled dependencies. |
| Runtime packages | The OpenSSL library for Erlang's `crypto` application, the SCTP library Erlang's `socket` module looks for (without it, every start logs `Failed open sctp dynamic library: libsctp.so.1`), and root certificates. Debian slim already has the C++ and terminal libraries the runtime links against, so the `locales` package and `locale-gen` step are gone: `C.UTF-8` is built in. |
| `USER 65534:65534` | The generated file's `nobody` user, by number. The release's files belong to root, so the application can read them but not change them; it writes nothing to `/app` at runtime. |
| `CMD ["/app/bin/server"]` | The script sets `PHX_SERVER=true` and replaces itself with the release, so the Erlang runtime runs as process 1. It handles `SIGTERM` itself, logging `SIGTERM received - shutting down`, so it needs no init process such as `tini`. |
| `EXPOSE 8080` | Documents Sevalla's default port. `config/runtime.exs` reads `PORT`. |

```text .dockerignore theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
.git
.dockerignore
Dockerfile
/_build/
/deps/
/cover/
/doc/
/test/
/tmp/
.elixir_ls
*.ez
erl_crash.dump
/assets/node_modules/
/priv/static/assets/
/priv/static/cache_manifest.json
.env*
*.log
```

Commit and push both files. Ingot doesn't use the `Dockerfile`, so you can keep it in a repository that builds with Ingot, but it adds the `.dockerignore` patterns to its own (its build plan shows `Ignored 17 patterns from .dockerignore`), and the Ingot build passes with this file. 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 build log starts the build with `📦 Building and pushing docker image using Dockerfile...` instead of Ingot's build plan, and still shows `Migrate has been successful`: the job runs `/app/bin/migrate` from the new image. The welcome page and the LiveView catalog work as in [Verify the deployment](#verify-the-deployment), and the deployment's **Dockerfile** tab shows the file the image was built from. When you push a change to the code only, the build log shows `CACHED` for the steps up to the one the change affects, including `mix deps.get` and `mix deps.compile`.

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 --build-type dockerfile --dockerfile-path Dockerfile`. 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 release, runs the `Migrate` job, then replaces the web process's instances. For a schema change, run `mix ecto.gen.migration <name>` locally and commit the migration in `priv/repo/migrations` with the code. When you add a dependency, commit the updated `mix.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. Read them in `config/runtime.exs` with `System.get_env/1`, which runs each time the release starts. `config/config.exs` and `config/prod.exs` are evaluated while the release is built, so a variable they read is fixed in the image: the build log warns when they read one. 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 `PHX_HOST` to `shop.example.com`, so Phoenix builds its URLs with it, add both domains to `check_origin` as in [LiveView and WebSockets](#liveview-and-websockets), 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 |
| - | - | - |
| LiveView pages load but don't react, and the runtime logs show `Could not check origin for Phoenix.Socket transport.` | The page's domain isn't `PHX_HOST` or in `check_origin`. | Set `PHX_HOST` to the domain, or list every domain in `check_origin`, then deploy. |
| 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 that the internal connection exists and that `DATABASE_URL` is set. |
| The release stops at start with `environment variable DATABASE_URL is missing.` or `environment variable SECRET_KEY_BASE is missing.` | The variable isn't set, or isn't **Available during runtime**. | Add or update the variable, then deploy. |

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), 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`, `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 Ecto 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` once the database accepts connections, and the internal hostname and port that the application connects to:

```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)
DB_DATABASE=$(echo "$DB" | jq -r .db_name)
DB_USERNAME=$(echo "$DB" | jq -r .db_user)
```

```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 and returns the connection's `id`. 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 `SECRET_KEY_BASE` and the domain in `PHX_HOST`, available at runtime only. `openssl rand -base64 48` prints 64 random characters, as `mix phx.gen.secret` does:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
SECRET_KEY_BASE=$(openssl rand -base64 48)
for VAR in "DATABASE_URL=postgres://$DB_USERNAME:$DB_PASSWORD@$DB_HOST:$DB_PORT/$DB_DATABASE" "SECRET_KEY_BASE=$SECRET_KEY_BASE" "PHX_HOST=$HOST"; 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":"SECRET_KEY_BASE","is_runtime":true,"is_buildtime":false}
{"key":"PHX_HOST","is_runtime":true,"is_buildtime":false}
```

CLI: `sevalla apps env-vars create $APP_ID --key PHX_HOST --value $HOST`, 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":"/app/bin/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":"/app/bin/migrate","job_start_policy":"beforeDeployment","resource_type_name":"h1"}
```

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

**13. Set the size**

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:

```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"'"}' \
  | jq -r .resource_type_name
```

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

CLI: `sevalla apps processes update $WEB_ID --app-id $APP_ID --resource-type $H1_ID`. MCP: `scale_process` with `size_id`.

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

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

**18. Check the application**

Request the welcome page from the application's domain:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s https://$HOST/ | grep -o 'Peace of mind from prototype to production.'
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
Peace of mind from prototype to production.
```

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.