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

> Deploy a Hanami 3 application to Sevalla with PostgreSQL: Ingot compiles the assets and starts Puma, a job runs the migrations before each deployment, and a Dockerfile is optional.

A Hanami application runs on Sevalla as an application built from your Git repository: [Ingot](https://docs.sevalla.com/applications/builds/ingot.md) installs the gems with Bundler, compiles the assets with Node.js and `hanami assets compile`, sets `HANAMI_ENV` to `production`, and starts Puma with the `config.ru` that `hanami new` writes, on the port in the `PORT` environment variable. A job runs the migrations before each deployment, and the data lives in a Sevalla PostgreSQL database that the application reaches over an internal connection.

## At a glance

| | |
| - | - |
| Source | A Git repository |
| Build | [Ingot](https://docs.sevalla.com/applications/builds/ingot.md) (Ruby 4.0, Bundler, Node.js 24 for the assets), or the [Dockerfile](#deploy-with-a-dockerfile) |
| Start command | `bundle exec puma --bind "tcp://0.0.0.0:$PORT"`, which Ingot sets when it finds `config.ru` and Puma |
| Port | The `PORT` environment variable, `8080` by default |
| Processes | Web process; `Migrate` job (`bundle exec hanami db migrate --no-dump`, **Before deployment**) |
| Services | PostgreSQL 18 over an internal connection |
| Disk | None |
| Health check | `GET /health` returns `200` (readiness probe) |
| Size | **H1** for the web process and the job, **DB1** for the database |

### Environment variables

| Variable | Required | Available during | Secret | Value |
| - | - | - | - | - |
| `DATABASE_URL` | Yes | Runtime | Yes | Added by the internal connection as `DB_URL` and renamed: `postgres://<user>:<password>@<host>:<port>/<database>` |
| `HANAMI_SERVE_ASSETS` | Yes | Runtime | No | `true`, so Hanami serves the compiled assets in production |
| `HANAMI_MAX_THREADS` | No | Runtime | No | The number of Puma threads, `5` by default |
| `HANAMI_WEB_CONCURRENCY` | No | Runtime | No | The number of Puma processes. `0` by default: one process, without Puma's cluster mode |
| `PORT` | No | Runtime | No | Set by Sevalla to the web process port |

The internal connection also adds `DB_HOST`, `DB_PORT`, `DB_DATABASE`, `DB_USERNAME` and `DB_PASSWORD`, which the application doesn't read. Ingot sets `HANAMI_ENV` and `RACK_ENV` to `production` in the image.

## 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).
* Ruby 4.0, Node.js and PostgreSQL on your computer. Hanami compiles its assets with esbuild, which runs on Node.js.

## Create the project

Install Hanami, then create an application named `storefront` that uses PostgreSQL:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
gem install hanami
hanami new storefront --database=postgres
cd storefront
```

`hanami new` installs Hanami 3.0 and Puma 8.0 with Bundler, installs the asset tools with npm, and runs `git init`. Generate a migration, a relation and a repository for products, an action and a view that list them, and an action that adds one:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
bundle exec hanami generate migration create_products
bundle exec hanami generate relation products
bundle exec hanami generate repo product
bundle exec hanami generate action products.index
bundle exec hanami generate action products.create --skip-view
```

Fill in the migration that the first command created in `config/db/migrate`, with a timestamp in its name:

```ruby config/db/migrate/20261030073914_create_products.rb theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
# frozen_string_literal: true

ROM::SQL.migration do
  change do
    create_table :products do
      primary_key :id
      column :name, :text, null: false
      column :created_at, :timestamp, null: false, default: Sequel::CURRENT_TIMESTAMP
    end
  end
end
```

Add the queries to the repository:

```ruby app/repos/product_repo.rb theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
# frozen_string_literal: true

module Storefront
  module Repos
    class ProductRepo < Storefront::DB::Repo
      def all
        products.order(:id).to_a
      end

      def create(attributes)
        products.changeset(:create, attributes).commit
      end
    end
  end
end
```

List the products in the view and its template:

```ruby app/views/products/index.rb theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
# frozen_string_literal: true

module Storefront
  module Views
    module Products
      class Index < Storefront::View
        include Deps["repos.product_repo"]

        expose :products do
          product_repo.all
        end
      end
    end
  end
end
```

```erb app/templates/products/index.html.erb theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
<h1>Products</h1>
<ul>
  <% products.each do |product| %>
    <li><%= product.name %></li>
  <% end %>
</ul>
```

Add a product from a JSON request:

```ruby app/actions/products/create.rb theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
# frozen_string_literal: true

module Storefront
  module Actions
    module Products
      class Create < Storefront::Action
        include Deps["repos.product_repo"]

        config.formats.accept :json

        params do
          required(:name).filled(:string)
        end

        def handle(request, response)
          halt 422, request.params.errors.to_h.to_json unless request.params.valid?

          product = product_repo.create(name: request.params[:name])
          response.status = 201
          response.body = {id: product.id, name: product.name}.to_json
        end
      end
    end
  end
end
```

`config.formats.accept :json` parses the JSON body into `request.params` and answers requests of another content type with `415`. Then replace the routes that the generators added, so the products are the home page and `/health` answers the readiness probe:

```ruby config/routes.rb theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
# frozen_string_literal: true

module Storefront
  class Routes < Hanami::Routes
    root to: "products.index"
    get "/health", to: ->(env) { [200, {"content-type" => "text/plain"}, ["OK"]] }
    get "/products", to: "products.index"
    post "/products", to: "products.create"
  end
end
```

`/health` is a Rack endpoint: it answers `OK` without loading an action, a view or the database.

## Prepare the project for Sevalla

### Pin the Ruby version

Ingot builds on Ruby 4.0, its default line, unless the project sets a version. Pin the line in the `Gemfile`, under the `source` line, so the build doesn't change when a new Ingot release moves its default:

```ruby Gemfile theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
source "https://rubygems.org"

ruby "~> 4.0.0"
```

Then run `bundle install` to record the Ruby version in `Gemfile.lock`. See [Pin the Ruby version](https://docs.sevalla.com/guides/ruby/overview.md#pin-the-ruby-version).

### Serve the assets

`hanami assets compile` writes the CSS, JavaScript and images, with a hash in each name, to `public/assets`, and the layout links them, for example `/assets/app-GVDAEYEC.css`. In production, Hanami serves those files only when `HANAMI_SERVE_ASSETS` is `true`: without it, every page loads, and every asset returns `404`. Sevalla has no separate file server for an application, so set the variable when you [deploy](#deploy-to-sevalla). Sevalla's [CDN](https://docs.sevalla.com/applications/caching.md) can cache the files at Cloudflare's edge.

### Run the migrations without a dump

`bundle exec hanami db migrate` runs the migrations, then dumps the database's structure to `config/db/structure.sql` with `pg_dump`. The image has no `pg_dump`, so on Sevalla the job fails after migrating, with `No such file or directory - pg_dump (Errno::ENOENT)`. The `--no-dump` option skips the dump. Keep dumping on your computer, and commit `config/db/structure.sql`.

The job uses `hanami db migrate`, not `hanami db prepare`: `prepare` first checks that the database exists with `psql`, which the image doesn't have either.

### How Ingot builds and starts the project

The project needs no build or start configuration:

| File | Why Sevalla needs it |
| - | - |
| `Gemfile` | `ruby "~> 4.0.0"` sets the Ruby line. Ingot detects Hanami and Puma from the gems. |
| `Gemfile.lock` | Ingot installs exactly these gems, with the Bundler release that wrote the file. |
| `package.json` and `package-lock.json` | Ingot installs esbuild and Hanami's asset tools with `npm ci`, then compiles the assets. |
| `config.ru` | Tells Puma which application to run. |
| `config/puma.rb` | Puma reads `HANAMI_MAX_THREADS` and `HANAMI_WEB_CONCURRENCY` from it. Ingot's `--bind` replaces the port it sets, `2300`. |
| `config/db/migrate/*.rb` | The migrations that the `Migrate` job runs. |

From these files, Ingot:

* Installs the gems with `BUNDLE_DEPLOYMENT` set, so the build fails if `Gemfile.lock` doesn't match the `Gemfile`, and leaves out the `development` and `test` groups.
* Adds Node.js 24 to the build, runs `npm ci` and `bundle exec hanami assets compile`, then removes `node_modules`. Node.js isn't in the final image, and the build needs no `DATABASE_URL`.
* Sets `HANAMI_ENV=production`.
* Starts the web process with `bundle exec puma --bind "tcp://0.0.0.0:$PORT"`: Puma listens on all network interfaces and on the port in `PORT`, which Sevalla sets to the web process's port (`8080` by default).
* Doesn't touch the database. The `Migrate` job runs the migrations once per deployment, before the new version starts.

Puma runs 1 process with 5 threads, which fits the smallest size, **H1**. On a larger size, set `HANAMI_WEB_CONCURRENCY` to the number of Puma processes, for example `2`. Hanami reads `HANAMI_WEB_CONCURRENCY`, not `WEB_CONCURRENCY`.

Run the application locally to check it, then open `http://localhost:2300`:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
bundle exec hanami db prepare
bundle exec hanami server
```

`db prepare` creates the local databases from `DATABASE_URL` in the `.env` file that `hanami new` wrote, runs the migration and writes `config/db/structure.sql`. 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
```

`hanami new` already ran `git init`, and its `.gitignore` keeps `node_modules`, the compiled assets in `public` and `.env*.local` files out of the repository. The `.env` file holds the local `DATABASE_URL`, and production doesn't load it: Hanami reads `.env` files with the `dotenv` gem, which is in the `development` and `test` groups.

## 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`, then click **Add internal connection**. See [Connect an application](https://docs.sevalla.com/databases/connect.md#connect-an-application).

**4. Add the environment variable**

Click the **Environment variables** tab, then click **Add environment variables**. Add `HANAMI_SERVE_ASSETS` with the value `true` and click **Add environment variables**.

**5. Create the migration job**

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

**6. Turn on the readiness probe**

On the **Processes** tab, click **⋯** on the **Web** process, then click **Update process**. Under **Health checks**, turn on **Readiness probe** and set the **Path** to `/health`, then click **Update process**. Each new instance gets traffic only once Hanami answers on `/health`.

**7. Deploy**

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

**8. 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 job runs in the image of the new deployment, with the application's environment variables, so it reaches the database over the internal connection. If it fails, the deployment fails and the previous version keeps running.

## Verify the deployment

1. On the **Deployments** tab, wait for the deployment's status to change to **Deployed**. The application's status next to its name changes to **Running**. Open the deployment to read its build log. Ingot's build plan comes first:

   ```text Build log theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   ╭─ ■ Hanami · Ruby 4.0 ──────────────────────────────────────────────────────────────────╮
   │ Ruby                 4.0                                            Gemfile "~> 4.0.0" │
   │ Bundler              4.0.20                                         Gemfile.lock       │
   │ Framework            Hanami                                         Gemfile.lock       │
   │ Node.js              24                                             default            │
   │ JavaScript packages  npm                                            package-lock.json  │
   │ Start                bundle exec puma --bind "tcp://0.0.0.0:$PORT"  config.ru          │
   ├─ Image ────────────────────────────────────────────────────────────────────────────────┤
   │ Build                ruby:4.0-trixie                                                   │
   │ Runtime              ruby:4.0-slim-trixie                                              │
   │ User                 1000:1000                                                         │
   │ Init                 tini                                                              │
   │ Port                 $PORT, default 8080                                               │
   ╰───────────────────────────────────────────────────────────────────────── Ingot v0.4.1 ─╯
   ```

   Later, the build log lists the compiled assets, such as `public/assets/app-GVDAEYEC.css`, and, during the rollout, `Migrate has been successful`.
2. Click the domain under the application's name. The **Products** page opens, with an empty list.

![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 [runtime logs](https://docs.sevalla.com/applications/logs.md) show Puma's start, with `Environment: production` and `Listening on http://0.0.0.0:8080`. Hanami's mailer also logs `No SMTP configuration found for storefront in production` at start: the application sends no email, so you can ignore it, or set the `SMTP_*` variables that `.env` lists when you add mailers. To run commands on the running application, click the **Web terminal** tab. See [Web terminal](https://docs.sevalla.com/applications/web-terminal.md).

### Check from a terminal

These checks need no dashboard. The API calls use `API`, `H`, `APP_ID` and `DEPLOYMENT_ID` as set in [Deploy with the API](#deploy-with-the-api).

1. The deployment's status, from [Get deployment](https://api-docs.sevalla.com/v3/applications/get-deployment) (`getDeploymentV3`):

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -s "$API/applications/$APP_ID/deployments/$DEPLOYMENT_ID" -H "$H" | jq -r .status
   ```

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

2. The rollout's results in the build log, from [Get application deployment logs](https://api-docs.sevalla.com/v3/applications/get-application-deployment-logs) (`getApplicationDeploymentLogsV3`). The messages carry terminal color codes, which `gsub` removes, and `select` drops the lines of the image build, which start with `#`:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -s "$API/applications/$APP_ID/deployments/$DEPLOYMENT_ID/logs?search=has%20been" -H "$H" \
     | jq -r '.[].message | gsub("\u001b\\[[0-9;]*m"; "") | select(startswith("#") | not)'
   ```

   ```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   ✅ Migrate has been successful
   🚀 Web has been deployed successfully.
   ```

3. The health route that the readiness probe requests, with your domain:

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

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

4. A product added through the JSON action, then the home page that lists it, which need the database:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -s -w '\n' -X POST https://storefront-x7k2m.sevalla.app/products \
     -H "Content-Type: application/json" -d '{"name":"Coffee mug"}'
   curl -s https://storefront-x7k2m.sevalla.app/ | grep -o '<li>.*</li>'
   ```

   ```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   {"id":1,"name":"Coffee mug"}
   <li>Coffee mug</li>
   ```

5. The stylesheet the page links, which `HANAMI_SERVE_ASSETS` serves:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   CSS=$(curl -s https://storefront-x7k2m.sevalla.app/ | grep -o '/assets/app-[A-Z0-9]*\.css')
   curl -s -o /dev/null -w '%{http_code} %{content_type}\n' https://storefront-x7k2m.sevalla.app$CSS
   ```

   ```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   200 text/css
   ```

A request without a name returns `422` with `{"name":["must be filled"]}`.

## Deploy with a Dockerfile

Ingot needs no Dockerfile. Use one 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 ruby:4.0-slim-trixie AS base

WORKDIR /app

# jemalloc keeps the memory of Puma's threads from fragmenting.
# hadolint ignore=DL3008
RUN apt-get update -qq && \
    apt-get install --no-install-recommends -y libjemalloc2 && \
    rm -rf /var/lib/apt/lists /var/cache/apt/archives

ENV HANAMI_ENV="production" \
    HANAMI_SERVE_ASSETS="true" \
    BUNDLE_DEPLOYMENT="1" \
    BUNDLE_PATH="/usr/local/bundle" \
    BUNDLE_WITHOUT="development:test:cli" \
    LD_PRELOAD="libjemalloc.so.2" \
    PORT="8080"


FROM base AS build

# hadolint ignore=DL3008
RUN apt-get update -qq && \
    apt-get install --no-install-recommends -y build-essential && \
    rm -rf /var/lib/apt/lists /var/cache/apt/archives

# Node.js and npm, only to compile the assets.
COPY --from=node:24-trixie-slim /usr/local/bin/node /usr/local/bin/
COPY --from=node:24-trixie-slim /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/npm
RUN ln -s ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm

COPY Gemfile Gemfile.lock ./
RUN bundle install && \
    rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git

COPY package.json package-lock.json ./
RUN npm ci

COPY . .
RUN bundle exec hanami assets compile && \
    rm -rf node_modules


FROM base

RUN groupadd --system --gid 1000 app && \
    useradd app --uid 1000 --gid 1000 --create-home --shell /bin/bash

# Gems and the application with its compiled assets, owned by root.
COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
COPY --from=build /app /app

USER 1000:1000

EXPOSE 8080
CMD ["/bin/sh", "-c", "exec bundle exec puma --bind tcp://0.0.0.0:${PORT}"]
```

| Line or choice | Why |
| - | - |
| `ruby:4.0-slim-trixie` | The Ruby line the `Gemfile` pins, on Debian 13. A line tag, not a digest, so every build picks up the line's security fixes. |
| Three stages | `base` holds the runtime settings, `build` adds the compilers, Node.js and npm, installs the gems and compiles the assets, and the final stage copies only the gems and the application, so neither a compiler nor Node.js ships in the image. |
| `node:24-trixie-slim` | Node.js 24, the line Ingot uses, copied from the official image for the build stage only. |
| `Gemfile`, `Gemfile.lock`, `package.json` and `package-lock.json` before the code | A change to the code reuses the cached gem and npm layers. `BUNDLE_DEPLOYMENT` and `npm ci` install exactly what the lock files list. |
| `BUNDLE_WITHOUT="development:test:cli"` | Leaves out the gems of Hanami's `cli` group, such as `hanami-rspec` and `hanami-reloader`, which production doesn't need. `hanami db migrate` still runs. |
| `HANAMI_SERVE_ASSETS="true"` | The image serves its compiled assets wherever it runs, as the environment variable does with Ingot. |
| User `1000:1000`, code owned by root | The application can't change its own files. Hanami writes nothing to disk in production, and the job runs with `--no-dump`. |
| `CMD` with `exec` | `/bin/sh` passes `PORT` to Puma's `--bind`, because `config/puma.rb` reads `HANAMI_PORT`, not `PORT`. `exec` replaces the shell with Puma, which runs as process 1, gets `SIGTERM` when Sevalla replaces an instance, and logs `Gracefully stopping, waiting for requests to finish`. |

```text .dockerignore theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
.git
.bundle
.env*
vendor/bundle
node_modules
public/assets
log
tmp
spec
Dockerfile
.dockerignore
```

Commit and push both files. Ingot doesn't use the `Dockerfile`, and it applies the `.dockerignore` patterns to its own build (its build plan shows `Ignored 11 patterns from .dockerignore`), so the repository still 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 build log shows `📦 Building and pushing docker image using Dockerfile...` instead of Ingot's build plan, then `Migrate has been successful`. The checks in [Check from a terminal](#check-from-a-terminal) return the same results, and the deployment's **Dockerfile** tab shows the file the image was built from. See [Dockerfile](https://docs.sevalla.com/applications/builds/dockerfile.md).

## Deploy changes

With **Auto-deploy** on, every push to the branch starts a deployment; otherwise, click **Deploy now** for each change. Each deployment builds the image, runs the `Migrate` job, then replaces the web process's instances. For a schema change, generate a migration with `bundle exec hanami generate migration`, run `bundle exec hanami db migrate` locally, and commit the migration with `config/db/structure.sql`. When you add a gem or an npm package, commit `Gemfile.lock` or `package-lock.json` with it. See [Deployments](https://docs.sevalla.com/applications/deployments.md).

## Use environment variables

Read settings with `ENV.fetch("NAME")`, or define them in `config/settings.rb`. Add them on the application's **Environment variables** tab: they apply at the next deployment. `hanami assets compile` runs during the build without the runtime variables, and doesn't need the database. 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. 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 deployment fails at `Migrate has failed`, and the job's output shows `No such file or directory - pg_dump (Errno::ENOENT)` | The job runs `hanami db migrate` without `--no-dump`. The migrations ran, then the structure dump failed. | Update the job's **Custom start command** to `bundle exec hanami db migrate --no-dump`, then deploy. |
| The pages load without styles, and `/assets/...` returns `404` | `HANAMI_SERVE_ASSETS` isn't `true`, so Hanami doesn't serve the compiled assets in production. | Add `HANAMI_SERVE_ASSETS` with the value `true`, then deploy. |
| `POST /products` returns `415` | The request isn't JSON. | Send the body as JSON with `Content-Type: application/json`. |
| The job's output shows `A database_url for gateway default is required to start :db. (Hanami::ComponentLoadError)` | `DATABASE_URL` isn't set. | On the **Networking** tab, check the internal connection; on the **Environment variables** tab, check that the variable is named `DATABASE_URL`. Then deploy. |

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

## Deploy with the API

The same deployment as [Deploy to Sevalla](#deploy-to-sevalla), as calls to the [Sevalla API](https://docs.sevalla.com/developer-tools/api.md) that a script or an AI agent runs in order. Each step names the matching [CLI](https://docs.sevalla.com/developer-tools/cli.md) command and [Sevalla MCP server](https://docs.sevalla.com/ai-agents/overview.md) tool. The sequence needs:

* An [API key](https://docs.sevalla.com/developer-tools/api-keys.md) with the **Company developer** role, or the capabilities to create, read and update applications and databases, in `SEVALLA_API_KEY`.
* The repository pushed to GitHub, and a GitHub account with access to it connected to Sevalla. Connecting one is a dashboard step, and the API has no call that reports it: if no connected account can read the repository, the step that creates the application fails with `400` and a message that starts with `Cannot access repository` and says where to connect one.
* `curl`, `jq` and `openssl`.

Set the base URL, the authorization header and the repository:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
API=https://api.sevalla.com/v3
H="Authorization: Bearer $SEVALLA_API_KEY"
REPO_URL=https://github.com/acme/storefront
BRANCH=main
```

**1. Choose the location**

[List clusters](https://api-docs.sevalla.com/v3/resources/list-clusters) (`listClustersV3`) returns every location your company can use, with its region ID in `location`. Pick one by region ID, here Frankfurt, Germany (`eu-frankfurt-1`); [Locations](https://docs.sevalla.com/platform/locations.md) lists the others. Display names differ between environments, so don't select by them:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
CLUSTER_ID=$(curl -s "$API/resources/clusters" -H "$H" \
  | jq -r '.[] | select(.location == "eu-frankfurt-1") | .id')
```

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

**2. Look up the sizes and the PostgreSQL version**

[List database resource types](https://api-docs.sevalla.com/v3/resources/list-database-resource-types) (`listDatabaseResourceTypesV3`), [List process resource types](https://api-docs.sevalla.com/v3/resources/list-process-resource-types) (`listProcessResourceTypesV3`) and [List database versions](https://api-docs.sevalla.com/v3/resources/list-database-versions) (`listDatabaseVersionsV3`) give the IDs of **DB1** and **H1** and the newest PostgreSQL version in the location:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
DB1_ID=$(curl -s "$API/resources/database-resource-types" -H "$H" | jq -r '.[] | select(.name == "db1") | .id')
H1_ID=$(curl -s "$API/resources/process-resource-types" -H "$H" | jq -r '.[] | select(.name == "h1") | .id')
PG_VERSION=$(curl -s "$API/resources/database-versions?cluster_id=$CLUSTER_ID" -H "$H" \
  | jq -r '.[] | select(.type == "postgresql") | .versions[-1]')
```

CLI: `sevalla resources db-types`, `sevalla resources process-types` and `sevalla resources db-versions --cluster $CLUSTER_ID`. MCP: `get_platform_options` for the sizes, and `sevalla_api_read` with `listDatabaseVersionsV3` for the version.

**3. Create the database**

[Create database](https://api-docs.sevalla.com/v3/databases/create-database) (`createDatabaseV3`). You choose the password, and the API never returns it, so keep it in a variable for the environment variables:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
DB_PASSWORD=$(openssl rand -hex 16)
DB_ID=$(curl -s -X POST "$API/databases" -H "$H" -H "Content-Type: application/json" \
  -d "$(jq -n --arg cluster "$CLUSTER_ID" --arg size "$DB1_ID" --arg version "$PG_VERSION" --arg password "$DB_PASSWORD" \
    '{display_name: "orders-db", type: "postgresql", version: $version, cluster_id: $cluster,
      resource_type_id: $size, db_name: "storefront", db_user: "storefront", db_password: $password}')" \
  | jq -r .id)
```

The database starts as `creating`. Poll [Get database](https://api-docs.sevalla.com/v3/databases/get-database) (`getDatabaseV3`) until it's `ready`. On `error`, delete the database and create it again:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
for i in $(seq 1 60); do
  DB_STATUS=$(curl -s "$API/databases/$DB_ID" -H "$H" | jq -r '.status // empty')
  case $DB_STATUS in ready|error) break ;; esac
  sleep 10
done
echo "$DB_STATUS"
```

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

CLI: `sevalla databases create --name orders-db --type postgresql --db-version $PG_VERSION --cluster $CLUSTER_ID --resource-type $DB1_ID --db-name storefront --db-user storefront --db-password $DB_PASSWORD`. MCP: `create_database` generates a password that it never returns. Create the application first, then pass `connect_to_application_id` and `env_var_name: "DATABASE_URL"`: the tool connects the database and stores its URL, in the form `postgresql://<user>:<password>@<host>:<port>/<database>`, which Hanami accepts like `postgres://`. Then skip the connection step, and leave the connection's variables out of the environment variables step.

**4. Create the application**

[Create application](https://api-docs.sevalla.com/v3/applications/create-application) (`createApplicationV3`) creates it with a web process and doesn't deploy it. [List application domains](https://api-docs.sevalla.com/v3/applications/list-application-domains) (`listApplicationDomainsV3`) returns its `sevalla.app` domain as the primary one, for the checks:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
APP_ID=$(curl -s -X POST "$API/applications" -H "$H" -H "Content-Type: application/json" \
  -d "$(jq -n --arg cluster "$CLUSTER_ID" --arg repo "$REPO_URL" --arg branch "$BRANCH" \
    '{display_name: "storefront", cluster_id: $cluster, source: "privateGit", git_type: "github",
      repo_url: $repo, default_branch: $branch}')" \
  | jq -r .id)
for i in $(seq 1 30); do
  APP_HOST=$(curl -s "$API/applications/$APP_ID/domains" -H "$H" | jq -r '.data[]? | select(.is_primary) | .name')
  [ -n "$APP_HOST" ] && break
  sleep 2
done
echo "$APP_HOST"
```

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

Sevalla adds the domain a few seconds after it creates the application, so the loop waits for it.

CLI: `sevalla apps create --name storefront --cluster $CLUSTER_ID --source privateGit --git-type github --repository $REPO_URL --branch $BRANCH`, then `sevalla apps domains list --app-id $APP_ID`. MCP: `create_application` with `deploy: false`, then `get_application`.

**5. Size the web process and add the readiness probe**

The API creates the web process at **S1**, not the dashboard's **H1**. [List processes](https://api-docs.sevalla.com/v3/applications/list-processes) (`listProcessesV3`) finds it, and [Update process](https://api-docs.sevalla.com/v3/applications/update-process) (`updateProcessV3`) sets **H1** and the readiness probe on `/health`. Send `timeoutSeconds`: a probe set through the API without it waits 120 seconds, not the dashboard's 1:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
WEB_ID=$(curl -s "$API/applications/$APP_ID/processes" -H "$H" | jq -r '.data[] | select(.type == "web") | .id')
curl -s -X PATCH "$API/applications/$APP_ID/processes/$WEB_ID" -H "$H" -H "Content-Type: application/json" \
  -d "$(jq -n --arg size "$H1_ID" \
    '{resource_type_id: $size, readiness_probe: {httpGet: {path: "/health", port: 8080},
      initialDelaySeconds: 0, periodSeconds: 10, timeoutSeconds: 1, successThreshold: 1, failureThreshold: 3}}')" \
  | jq -c '{resource_type_name, readiness_probe}'
```

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

CLI: `sevalla apps processes update $WEB_ID --app-id $APP_ID --resource-type $H1_ID`; the CLI has no option for the probe. MCP: `scale_process` with `size_id` and `apply_now: false` for the size, and `sevalla_api_update` with `updateProcessV3` and the same body for the probe.

**6. Connect the database**

[Create internal connection](https://api-docs.sevalla.com/v3/applications/create-internal-connection) (`createApplicationInternalConnectionV3`):

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s -X POST "$API/applications/$APP_ID/internal-connections" -H "$H" -H "Content-Type: application/json" \
  -d "$(jq -n --arg db "$DB_ID" '{target_id: $db, target_type: "database"}')" | jq -r .id
```

It prints the connection's ID. If it prints nothing or `null`, the connection wasn't created: run the call again, because without it the application can't reach the database. Unlike the dashboard, the API doesn't add the connection's environment variables. The next step adds them.

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

**7. Add the environment variables**

[Create environment variable](https://api-docs.sevalla.com/v3/applications/create-environment-variable) (`createEnvironmentVariableV3`) adds one variable per call. The loop adds the connection's variables in the dashboard's names and format, from [Get database](https://api-docs.sevalla.com/v3/databases/get-database), with `DB_URL` renamed to `DATABASE_URL`, then `HANAMI_SERVE_ASSETS`, all for runtime only:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
DB_HOST=$(curl -s "$API/databases/$DB_ID" -H "$H" | jq -r .internal_hostname)
DB_PORT=$(curl -s "$API/databases/$DB_ID" -H "$H" | jq -r .internal_port)
for pair in "DB_HOST=$DB_HOST" "DB_PORT=$DB_PORT" "DB_DATABASE=storefront" "DB_USERNAME=storefront" \
  "DB_PASSWORD=$DB_PASSWORD" "DATABASE_URL=postgres://storefront:$DB_PASSWORD@$DB_HOST:$DB_PORT/storefront" \
  "HANAMI_SERVE_ASSETS=true"; do
  curl -s -X POST "$API/applications/$APP_ID/env-vars" -H "$H" -H "Content-Type: application/json" \
    -d "$(jq -n --arg key "${pair%%=*}" --arg value "${pair#*=}" '{key: $key, value: $value, is_runtime: true, is_buildtime: false}')" \
    | jq -r .key
done
```

```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
DB_HOST
DB_PORT
DB_DATABASE
DB_USERNAME
DB_PASSWORD
DATABASE_URL
HANAMI_SERVE_ASSETS
```

CLI: `sevalla apps env-vars create $APP_ID --key HANAMI_SERVE_ASSETS --value true`, once per variable; the CLI makes each variable available during build and runtime, with no option for runtime only. MCP: `set_environment_variables` with `runtime: true` and `buildtime: false` for each variable.

**8. Create the migration job**

[Create process](https://api-docs.sevalla.com/v3/applications/create-process) (`createProcessV3`) needs the size, a `scaling_strategy` and, for a job, its `job_start_policy`:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s -X POST "$API/applications/$APP_ID/processes" -H "$H" -H "Content-Type: application/json" \
  -d "$(jq -n --arg size "$H1_ID" \
    '{display_name: "Migrate", type: "job", entrypoint: "bundle exec hanami db migrate --no-dump", resource_type_id: $size,
      job_start_policy: "beforeDeployment", scaling_strategy: {type: "manual", config: {instanceCount: 1}}}')" \
  | jq -c '{display_name, job_start_policy}'
```

```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{"display_name":"Migrate","job_start_policy":"beforeDeployment"}
```

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

**9. Deploy**

[Trigger deployment](https://api-docs.sevalla.com/v3/applications/trigger-deployment) (`triggerDeploymentV3`):

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
DEPLOYMENT_ID=$(curl -s -X POST "$API/applications/$APP_ID/deployments" -H "$H" -H "Content-Type: application/json" \
  -d "$(jq -n --arg branch "$BRANCH" '{branch: $branch}')" | jq -r .id)
```

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

**10. Wait for the deployment**

Poll [Get deployment](https://api-docs.sevalla.com/v3/applications/get-deployment) (`getDeploymentV3`) until the status is final: `success`, `failed`, `cancelled` or `skipped`. The loop keeps polling through a failed request and stops after 30 minutes:

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

On `failed`, read the whole build log with [Get application deployment logs](https://api-docs.sevalla.com/v3/applications/get-application-deployment-logs) (`getApplicationDeploymentLogsV3`), `curl -s "$API/applications/$APP_ID/deployments/$DEPLOYMENT_ID/logs?limit=5000" -H "$H" | jq -r '.[].message'`, and see [Troubleshoot](#troubleshoot). CLI: `sevalla apps deployments get $DEPLOYMENT_ID --app-id $APP_ID` and `sevalla apps deployments logs $DEPLOYMENT_ID --app-id $APP_ID`. MCP: `get_deployment` and `get_deployment_logs`.

**11. Turn on auto-deploy**

[Update application](https://api-docs.sevalla.com/v3/applications/update-application) (`updateApplicationV3`):

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl -s -X PATCH "$API/applications/$APP_ID" -H "$H" -H "Content-Type: application/json" \
  -d '{"auto_deploy": true}' | jq -r .auto_deploy
```

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

To build from the [Dockerfile](#deploy-with-a-dockerfile) instead of Ingot, send `{"build_type": "dockerfile", "dockerfile_path": "Dockerfile", "docker_context": "."}` with the same call before you deploy. CLI: `sevalla apps update $APP_ID --data '{"auto_deploy": true}'`. MCP: `sevalla_api_update` with `updateApplicationV3` and the same body.

Then run the checks in [Check from a terminal](#check-from-a-terminal), with `https://$APP_HOST` as the domain.


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