# How to deploy a Rails 8 app to a VPS

Source: https://tenkai.cloud/help/deploy-rails

> Use the Dockerfile Rails 8 generates without Kamal, keep its cache, queue and cable tables in one PostgreSQL database and migrate before traffic.

This guide deploys a Rails 8 app to your own server with the Dockerfile Rails generates, and without Kamal. The changes that matter are few, a port for Thruster, one database for Solid Cache, Solid Queue and Solid Cable, and migrations that run once before the new version takes traffic, and the last sections deploy it with Tenkai and PostgreSQL.

We tested it on 2 November 2026 with a new app from `rails new --database=postgresql`, on Rails 8.1.4, Ruby 4.0.9 and PostgreSQL 18, so adapt the configuration to your own app.

## Prerequisites

You need a Rails 8 app that uses PostgreSQL, in a Git repository with a CI such as GitHub Actions. Docker is useful to test the image on your machine, and the Tenkai part needs a [project on a ready cluster](https://tenkai.cloud/help/projects-and-clusters).

`rails new` writes a production `Dockerfile`, a `.dockerignore` and `bin/docker-entrypoint`, and this guide changes them only a little. Keep `config/master.key` out of Git, which the generated `.gitignore` already does, because it decrypts your credentials.

## Make Thruster listen on 8080

The image starts Thruster, which serves your assets and passes the other requests to Puma. Thruster listens on `HTTP_PORT`, 80 by default, so set it in the `ENV` line of the `base` stage and change the `EXPOSE` line to match.

```dockerfile
ENV RAILS_ENV="production" \
    BUNDLE_DEPLOYMENT="1" \
    BUNDLE_PATH="/usr/local/bundle" \
    BUNDLE_WITHOUT="development" \
    LD_PRELOAD="/usr/local/lib/libjemalloc.so" \
    HTTP_PORT="8080"
```

```dockerfile
EXPOSE 8080
```

Puma keeps port 3000 inside the container, behind Thruster, even when Tenkai sets `PORT`.

## Keep everything in one database

A new Rails 8 app expects four production databases, one for your data and one each for Solid Cache, Solid Queue and Solid Cable. A managed database usually gives you one, and its user cannot create others, so point them all at it. Replace the `production` section of `config/database.yml`.

```yaml
production:
  <<: *default
  url: <%= ENV["DATABASE_URL"] %>
```

Then remove what sends each Solid gem to its own database.

- In `config/cache.yml`, delete the `database: cache` line under `production`.
- In `config/cable.yml`, delete the `connects_to` block under `production`.
- In `config/environments/production.rb`, delete the line `config.solid_queue.connects_to = { database: { writing: :queue } }`.

The tables of the three gems are described in `db/cache_schema.rb`, `db/queue_schema.rb` and `db/cable_schema.rb`, which only their own databases load. Create them in your database with a migration, such as `db/migrate/20261102000000_create_solid_tables.rb`.

```ruby
class CreateSolidTables < ActiveRecord::Migration[8.1]
  def up
    %w[cache queue cable].each { |name| load Rails.root.join("db/#{name}_schema.rb") }
  end

  def down
    raise ActiveRecord::IrreversibleMigration
  end
end
```

## Run the migrations once, before traffic

`bin/docker-entrypoint` runs `./bin/rails db:prepare` each time a container starts the server, so each replica migrates as it starts. Delete the `if` block of `bin/docker-entrypoint`, which leaves it as follows.

```sh
#!/bin/bash -e

exec "${@}"
```

You will run `./bin/rails db:prepare` once for each deployment instead, which creates the schema on the first run and applies the pending migrations on the next ones. The previous version still serves while it runs, so add columns in one deployment and remove the old ones in a later one.

## Health check and HTTPS

Rails answers `/up` with `200` once the app has booted, which makes it your health check. Tenkai's proxy terminates HTTPS, so uncomment `config.assume_ssl = true` and `config.force_ssl = true` in `config/environments/production.rb`, which `rails new` leaves commented unless you pass `--skip-kamal`. `config.assume_ssl` treats every request as HTTPS, so `config.force_ssl` never redirects the health check, and cookies stay secure.

If you set `config.hosts` in `config/environments/production.rb`, also uncomment the `config.host_authorization` line that excludes `/up`, because a health check that calls the container directly sends its own name as the host.

## Test the image locally

Try the image with a PostgreSQL container.

```sh
docker build -t hello-rails .
docker network create hello-rails
docker run -d --name hello-rails-db --network hello-rails \
	-e POSTGRES_PASSWORD=secret -e POSTGRES_DB=app postgres:18
cat > .env.docker <<EOF
RAILS_MASTER_KEY=$(cat config/master.key)
DATABASE_URL=postgresql://postgres:secret@hello-rails-db:5432/app
EOF
docker run --rm --network hello-rails --env-file .env.docker hello-rails ./bin/rails db:prepare
docker run --rm --name hello-rails --network hello-rails -p 8080:8080 --env-file .env.docker hello-rails
```

Give PostgreSQL a few seconds to start before `db:prepare`. Visit `http://localhost:8080/up` and expect a green page, then remove the containers and the network. Keep `.env.docker` out of Git, since it holds your test secrets.

## Publish the image from your CI

Build the image in your CI on each push and publish it to a container registry, so your servers spend nothing on builds and your code can stay private. [Build an image in GitHub Actions and publish it to GHCR](https://tenkai.cloud/help/publish-image-ghcr) gives a complete workflow for this Dockerfile, built for your cluster's CPU architecture, which publishes an image such as `ghcr.io/acme/hello-rails:main` on each push to `main`.

## Deploy it with Tenkai

1. **Create the database.** [Create a PostgreSQL managed service](https://tenkai.cloud/help/managed-services#create-the-service) and wait until it is running.
2. **Create the application.** Choose **Add resource**, then **Application**, **Web application** and **Container image**. Enter the image reference with its moving tag, such as `ghcr.io/acme/hello-rails:main`, and for a private package choose a GHCR credential that **Can pull** it, as [pull a private GHCR image](https://tenkai.cloud/help/pull-private-ghcr-image) explains.
3. **Add the master key.** In the **Variables** tab, add `RAILS_MASTER_KEY` as a **Sensitive** variable, with the content of `config/master.key`.
4. **Bind the database.** [Bind the PostgreSQL service](https://tenkai.cloud/help/managed-services#bind-an-application) to the application, which gives it `DATABASE_URL`.
5. **Set the command.** Choose **More actions**, then **Change configuration…**, and enter `./bin/rails db:prepare` under **Pre-deploy commands**.
6. **Add the health check.** In the **Configuration** tab, add a [health check](https://tenkai.cloud/help/blue-green-deployments#the-health-check) on `/up`.

Each push then publishes a new image, and a last step of your workflow can deploy it by calling the application's [deploy hook](https://tenkai.cloud/help/deploy-hook).

## Deploy and verify

Choose **Deploy**, and once the release is active, open `/up` on its default route over HTTPS. When something fails, the deployment Details and the **Logs** tab name the cause.

- `ActiveSupport::MessageEncryptor::InvalidMessage` means `RAILS_MASTER_KEY` does not match your credentials file.
- A pre-deploy command that fails with `permission denied to create database` still points a Solid gem at its own database.
- A deployment that waits for the port and fails means Thruster does not listen on 8080, so check `HTTP_PORT`.

Once it works, [connect your own domain](https://tenkai.cloud/help/custom-domain).

## Run Solid Queue

Active Job hands its jobs to Solid Queue, which needs a process to run them. Create a [worker](https://tenkai.cloud/help/workers) from the same repository with `./bin/jobs` as its entrypoint, the same `RAILS_MASTER_KEY` and the same binding. For a small app with one replica, you can instead add `SOLID_QUEUE_IN_PUMA=1` to the web application's variables and run the jobs inside Puma.

Files written with Active Storage's `local` service disappear with each deployment, so use its S3 service with an S3-compatible bucket, such as a [bucket on your cluster](https://tenkai.cloud/help/object-storage).
