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

> Deploy a Rails 8.1 application to Sevalla with PostgreSQL: Ingot builds it and starts Puma, a job runs db:prepare before each deployment, and a Dockerfile is optional.

A Rails 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, precompiles the assets and the code during the build, and starts Puma with `bin/rails server` in the web process, on the port in the `PORT` environment variable. A job runs `bin/rails db:prepare` 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), or the [Dockerfile](#deploy-with-a-dockerfile) |
| Start command | `/app/bin/rails server --binding 0.0.0.0`, which Ingot sets for Rails |
| Port | The `PORT` environment variable, `8080` by default |
| Processes | Web process; `Migrate` job (`bin/rails db:prepare`, **Before deployment**) |
| Services | PostgreSQL 18 over an internal connection |
| Disk | None |
| Health check | `GET /up` 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>` |
| `SECRET_KEY_BASE` | Yes | Runtime | Yes | Generate with `bin/rails secret` or `openssl rand -hex 64`: 128 hexadecimal characters |
| `ALLOWED_HOSTS` | No | Runtime | No | The application's domain without the scheme, for example `storefront-x7k2m.sevalla.app`. Separate hosts with commas. When it's empty, Rails accepts every host. |
| `WEB_CONCURRENCY` | No | Runtime | No | The number of Puma processes, `1` by default |
| `RAILS_MAX_THREADS` | No | Runtime | No | The number of threads per Puma process, `3` by default |
| `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 `RAILS_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 and PostgreSQL on your computer, and Rails 8.1: install it with `gem install rails`.

## Create the project

Create a Rails application named `storefront` that uses PostgreSQL, then generate a `Product` resource with its pages, model and migration:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
rails new storefront --database=postgresql --skip-kamal --skip-thruster --no-rc
cd storefront
bin/rails generate scaffold Product name:string description:text
```

| Option | Why |
| - | - |
| `--database=postgresql` | Configures Active Record for PostgreSQL and adds the `pg` gem |
| `--skip-kamal` | Sevalla deploys the application, so Kamal's deploy configuration isn't needed |
| `--skip-thruster` | Sevalla's proxy and [CDN](https://docs.sevalla.com/applications/caching.md) compress and cache responses, so Puma serves requests directly |
| `--no-rc` | Ignores options in your `~/.railsrc`, so you get the same files as this guide |

`rails new` also writes a `Dockerfile` and a `.dockerignore`. Ingot doesn't use the `Dockerfile`; [Deploy with a Dockerfile](#deploy-with-a-dockerfile) replaces it with one that runs on Sevalla.

Show the products on the home page. In `config/routes.rb`, replace the commented `# root "posts#index"` line with:

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

## 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`. `~> 4.0.0` means any 4.0 release: Ingot builds on the newest one, and Bundler refuses to run the application on another line. Keep the `.ruby-version` file that `rails new` wrote for your version manager. See [Pin the Ruby version](https://docs.sevalla.com/guides/ruby/overview.md#pin-the-ruby-version).

### Use one database

A Sevalla database is one PostgreSQL database with one user that can't create other databases. Rails 8 keeps its cache (Solid Cache), its job queue (Solid Queue) and Action Cable's messages (Solid Cable) in three more databases in production, so put their tables in the application's database instead:

1. In `config/database.yml`, replace the `production` section at the end of the file:

   ```yaml config/database.yml theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   production:
     <<: *default
     url: <%= ENV["DATABASE_URL"] %>
   ```

2. In `config/environments/production.rb`, delete the line `config.solid_queue.connects_to = { database: { writing: :queue } }`.

3. In `config/cache.yml`, delete the line `database: cache` under `production`.

4. In `config/cable.yml`, delete the three `connects_to` lines under `production`.

5. Move the tables from the three schema files into a migration of the application's database, then delete the files:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   bin/rails generate migration CreateSolidTables
   migration=$(ls db/migrate/*_create_solid_tables.rb)
   {
     echo "class CreateSolidTables < ActiveRecord::Migration[8.1]"
     echo "  def change"
     for name in cache queue cable; do sed -e '1d' -e '$d' -e 's/^./  &/' "db/${name}_schema.rb"; echo; done
     echo "  end"
     echo "end"
   } > "$migration"
   rm db/cache_schema.rb db/queue_schema.rb db/cable_schema.rb
   ```

   The commands copy each file's `create_table` and `add_foreign_key` statements into the migration's `change` method, which is what the Solid Queue and Solid Cable READMEs describe for a single database.

Keeping the four configurations and pointing them all at the same database doesn't work: `bin/rails db:prepare` checks each one for a `schema_migrations` table, which they then share, and fails on a new database with `PG::DuplicateTable: ERROR:  relation "products" already exists`.

`DATABASE_URL` is the variable you name when you [connect the database](#deploy-to-sevalla). Development and test keep their own local databases from the same file.

### Answer only for your domains

Rails' production configuration already works behind Sevalla's proxy, which terminates HTTPS:

* `config.assume_ssl = true` treats every request as HTTPS, so redirects, cookies and the CSRF check of forms use `https://` URLs.
* `config.force_ssl = true` sends the `Strict-Transport-Security` header and marks cookies as secure. Because of `assume_ssl`, it never redirects, so the readiness probe on `/up` gets a `200`.
* `config.logger` writes to standard output, which Sevalla collects as the [runtime logs](https://docs.sevalla.com/applications/logs.md).

To reject requests for other host names, as Django's `ALLOWED_HOSTS` does, replace the commented `config.hosts` and `config.host_authorization` lines in `config/environments/production.rb` with:

```ruby config/environments/production.rb theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
  # Answer only requests for the hosts in ALLOWED_HOSTS, and the readiness probe on /up from any host.
  config.hosts = ENV.fetch("ALLOWED_HOSTS", "").split(",")
  config.host_authorization = { exclude: ->(request) { request.path == "/up" } }
```

The readiness probe sends the instance's IP address as its host name, so `/up` is excluded. Requests for any other host get `403` and a `Blocked hosts` line in the runtime logs. When `ALLOWED_HOSTS` is empty or not set, Rails accepts every host.

### 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 Rails and Puma from the gems. |
| `Gemfile.lock` | Ingot installs exactly these gems, with the Bundler release that wrote the file. |
| `config/database.yml` | Production connects with `DATABASE_URL`. |
| `db/migrate/*_create_solid_tables.rb` | Creates Solid Cache, Solid Queue and Solid Cable's tables in the one database. |
| `config/environments/production.rb` | HTTPS behind the proxy, logs to standard output, and the allowed hosts. |
| `config/puma.rb` | Puma reads `PORT` and runs `RAILS_MAX_THREADS` threads, 3 by default. |

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.
* Runs `bootsnap precompile` and `SECRET_KEY_BASE_DUMMY=1 bin/rails assets:precompile`, so the assets are part of the image and the real secret isn't needed during the build.
* Adds the Debian packages the gems need at runtime, such as `libvips` for Active Storage image variants, and sets `RAILS_ENV=production`.
* Starts the web process with `bin/rails server --binding 0.0.0.0`. Puma listens on the port in the `PORT` environment variable, which Sevalla sets to the web process's port (`8080` by default).
* Doesn't touch the database. The `Migrate` job runs `bin/rails db:prepare` once per deployment, before the new version starts.

Puma runs 1 process with 3 threads, which fits the smallest size, **H1** (0.3 GB of memory). On a larger size, set the `WEB_CONCURRENCY` environment variable to the number of Puma processes, for example `2`.

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

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
bin/rails db:prepare
bin/rails server
```

`db:prepare` creates the local database and runs the migrations, which writes `db/schema.rb`. Commit everything, `db/schema.rb` included, 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
```

`rails new` already ran `git init`, and its `.gitignore` keeps `config/master.key`, logs and temporary files out of the repository.

## 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 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 output of `bin/rails secret`, run in the project: 128 random hexadecimal characters. |
| `ALLOWED_HOSTS` | The application's domain without the scheme, for example `storefront-x7k2m.sevalla.app`. It's under the application's name in the page header. Separate hosts with commas. |

**5. Create the migration job**

Click the **Processes** tab, then click **Create process**. Choose **Job**, enter `Migrate` as the **Name** and `bin/rails db:prepare` 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 `/up`, then click **Update process**. Each new instance gets traffic only once Rails answers on `/up`.

**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 `db:prepare` rather than `db:migrate`. On the first deployment, the database is empty, and `db:prepare` loads `db/schema.rb` in one step and runs `db/seeds.rb`; on later deployments, it runs the pending migrations, as `db:migrate` does. It's what Rails' own container entrypoint runs, and the job that Ingot recommends for the application.

`SECRET_KEY_BASE` signs and encrypts Rails' cookies and sessions. Rails reads it before the credentials in `config/credentials.yml.enc`, so you don't need `config/master.key` on Sevalla, and every production secret lives in the application's environment variables. If you keep other secrets in the credentials file, add `RAILS_MASTER_KEY` with the content of `config/master.key` instead.

## 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"}}
   ╭─ ■ Rails · Ruby 4.0 ──────────────────────────────────────────────────────────────────────╮
   │ Ruby              4.0                                                  Gemfile "~> 4.0.0" │
   │ Bundler           4.0.20                                               Gemfile.lock       │
   │ Framework         Rails                                                Gemfile.lock       │
   │ Native libraries  libvips42t64 (ruby-vips), imagemagick (mini_magick)  Gemfile.lock       │
   │ Start             /app/bin/rails server --binding 0.0.0.0              Rails              │
   │ Ignored           24 patterns from .dockerignore                       .dockerignore      │
   ├─ 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 ─╯

   ! Rails needs its secret_key_base at runtime: set SECRET_KEY_BASE, or RAILS_MASTER_KEY if config/credentials.yml.enc holds it
   ```

   The warning is a reminder: `SECRET_KEY_BASE` is set. **Ignored** counts the patterns of the `.dockerignore` that `rails new` wrote, which Ingot applies to its own build. Later, the build log shows `assets:precompile` writing the assets, and, during the rollout, `Migrate has been successful`. If the job fails, the deployment fails and nothing changes for visitors.
2. Click the domain under the application's name. The **Products** page opens. Click **New product**, enter a name, and click **Create Product**: the page shows `Product was successfully created.`, which shows that the database, the form's CSRF check over HTTPS and the redirect work.

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

To run Rails commands on the running application, such as `bin/rails console`, click the **Web terminal** tab. The shell opens in `/app` in the **Web** process. 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/up
   ```

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

4. A product created through the form, which needs the database, the CSRF check over HTTPS and the session cookie. The first command saves the form's token and cookie, the second sends the form and follows the redirect:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   TOKEN=$(curl -s -c cookies.txt https://storefront-x7k2m.sevalla.app/products/new \
     | grep -o 'name="authenticity_token" value="[^"]*"' | head -1 | cut -d '"' -f 4)
   curl -s -L -b cookies.txt https://storefront-x7k2m.sevalla.app/products \
     --data-urlencode "authenticity_token=$TOKEN" \
     --data-urlencode "product[name]=Coffee mug" --data-urlencode "product[description]=12 oz" \
     | grep -o "Product was successfully created."
   ```

   ```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   Product was successfully created.
   ```

## 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. Replace the `Dockerfile` that `rails new` wrote with this one:

```dockerfile Dockerfile theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
FROM ruby:4.0-slim-trixie AS base

WORKDIR /rails

# Runtime libraries: jemalloc for a smaller, steadier memory footprint, libvips for image variants.
# hadolint ignore=DL3008
RUN apt-get update -qq && \
    apt-get install --no-install-recommends -y libjemalloc2 libvips && \
    rm -rf /var/lib/apt/lists /var/cache/apt/archives

ENV RAILS_ENV="production" \
    BUNDLE_DEPLOYMENT="1" \
    BUNDLE_PATH="/usr/local/bundle" \
    BUNDLE_WITHOUT="development:test" \
    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 libyaml-dev pkg-config && \
    rm -rf /var/lib/apt/lists /var/cache/apt/archives

# Gems first, so a change to the code reuses this layer.
COPY vendor/* ./vendor/
COPY Gemfile Gemfile.lock ./
RUN bundle install && \
    rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \
    bundle exec bootsnap precompile -j 1 --gemfile

COPY . .

RUN bundle exec bootsnap precompile -j 1 app/ lib/ && \
    SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile && \
    rm -f tmp/local_secret.txt && \
    chown 1000:1000 log storage tmp tmp/pids


FROM base

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

# The code stays owned by root; the rails user can write only to log, storage, tmp and tmp/pids.
COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
COPY --from=build /rails /rails

USER 1000:1000

EXPOSE 8080
CMD ["./bin/rails", "server"]
```

It keeps Rails' three stages and changes what Sevalla needs:

| 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. It replaces Rails' `RUBY_VERSION` build argument, which named one exact release. |
| Three stages | `base` holds the runtime libraries and settings, `build` adds the compilers and installs the gems, and the final stage copies only the gems and the application, so no compiler ships in the image. |
| No PostgreSQL packages | The `pg` gem is locked with builds for Linux that include the PostgreSQL client library, so neither `libpq-dev` nor `postgresql-client` is needed. |
| `Gemfile` and `Gemfile.lock` before the code | A change to the code reuses the cached gem layer. `BUNDLE_DEPLOYMENT` installs exactly what `Gemfile.lock` lists; `BUNDLE_WITHOUT` skips the `development` and `test` groups. |
| `SECRET_KEY_BASE_DUMMY=1` | Compiles the assets without the real secret, which never reaches the build. The temporary secret Rails writes is deleted. |
| User `1000:1000`, code owned by root | The application can't change its own code. It can write only to `log`, `storage`, `tmp` and `tmp/pids`. |
| No `ENTRYPOINT` | Rails' `bin/docker-entrypoint` runs `db:prepare` each time the server starts; the `Migrate` job runs it once per deployment. Delete `bin/docker-entrypoint`. |
| `CMD ["./bin/rails", "server"]` | Puma runs as the container's main process, so it gets `SIGTERM` when Sevalla replaces an instance and finishes the requests in flight. |
| `PORT="8080"` and `EXPOSE 8080` | `config/puma.rb` reads `PORT`, which Sevalla sets to the web process's port at runtime. |

Rails' `.dockerignore` already keeps `.git`, `.env*`, `config/master.key`, logs, `tmp`, `storage` and compiled assets out of the build. Add the tests:

```text .dockerignore theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
# Ignore tests.
/test/
```

Commit and push the changes. Ingot doesn't use the `Dockerfile`, and it applies the `.dockerignore` patterns to its own build, 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`. Create a product on the application's domain as in [Verify the deployment](#verify-the-deployment). 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 locally with `bin/rails generate migration`, run `bin/rails db:migrate`, and commit the migration with `db/schema.rb`. When you add a gem, commit `Gemfile.lock` with it. See [Deployments](https://docs.sevalla.com/applications/deployments.md).

## Use environment variables

Read settings with `ENV.fetch("NAME")` in the code or in `config/*.yml` files with `<%= ENV["NAME"] %>`. Add them on the application's **Environment variables** tab: they apply at the next deployment. Rails reads `config/environments/production.rb` during the build too, so give `ENV.fetch` a default there, or mark the variable **Available during buildtime**. See [Environment variables](https://docs.sevalla.com/applications/environment-variables.md).

## Add a custom domain

[Add the domain](https://docs.sevalla.com/domains/overview.md) on the application's **Domains** tab, then add it to `ALLOWED_HOSTS`, for example `storefront-x7k2m.sevalla.app,shop.example.com`, and deploy. Custom domains need a web process larger than **H1**.

## Troubleshoot

| Symptom | Cause | Fix |
| - | - | - |
| Every page returns `403`, and the runtime logs show `Blocked hosts: <host>` | The domain isn't in `ALLOWED_HOSTS`. | Add the domain to `ALLOWED_HOSTS`, then deploy. |
| The deployment fails at `Migrate has failed`, and the job's output shows ``ArgumentError: Missing `secret_key_base` for 'production' environment`` | `SECRET_KEY_BASE` isn't set, or isn't **Available during runtime**. | Add the variable, then deploy. |
| The job's output shows `PG::DuplicateTable: ERROR:  relation "products" already exists` | `config/database.yml` still has the `cache`, `queue` and `cable` databases, all pointing at the one database. | Follow [Use one database](#use-one-database). |
| The job's output shows `ActiveRecord::ConnectionNotEstablished: connection to server on socket "/var/run/postgresql/.s.PGSQL.5432" failed` | `DATABASE_URL` isn't set, so Rails looks for a local PostgreSQL server. | 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). To run background jobs, store uploads or cache, see the [Rails guides](https://docs.sevalla.com/guides/ruby/rails/overview.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"
```

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 Rails 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 `ALLOWED_HOSTS`:

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

Sevalla adds the domain about 2 seconds after it creates the application, so the loop waits for it. Without the wait, the variable can be empty, and an empty `ALLOWED_HOSTS` makes Rails accept every host.

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 `/up`. 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: "/up", port: 8080},
      initialDelaySeconds: 0, periodSeconds: 10, timeoutSeconds: 1, successThreshold: 1, failureThreshold: 3}}')" \
  | jq -c '{resource_type_id, readiness_probe}'
```

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 a new `SECRET_KEY_BASE` and `ALLOWED_HOSTS`, 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" \
  "SECRET_KEY_BASE=$(openssl rand -hex 64)" "ALLOWED_HOSTS=$APP_HOST"; 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
```

It prints each key as it's added. `SECRET_KEY_BASE` is 64 random bytes in hexadecimal, the format `bin/rails secret` prints. The build doesn't need it: Ingot precompiles the assets with `SECRET_KEY_BASE_DUMMY=1`.

CLI: `sevalla apps env-vars create $APP_ID --key ALLOWED_HOSTS --value $APP_HOST`, 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: "bin/rails db:prepare", resource_type_id: $size,
      job_start_policy: "beforeDeployment", scaling_strategy: {type: "manual", config: {instanceCount: 1}}}')" \
  | jq -c '{display_name, job_start_policy}'
```

CLI: `sevalla apps processes create $APP_ID --name Migrate --type job --resource-type $H1_ID --command "bin/rails db:prepare" --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"
```

On `failed`, read the build log with [Get application deployment logs](https://api-docs.sevalla.com/v3/applications/get-application-deployment-logs) 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
```

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.