Skip to guide
Help center

How to deploy a Rails 8 app to a VPS

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

Updated

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.

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.

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

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.

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.

#!/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.

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

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.

Run Solid Queue

Active Job hands its jobs to Solid Queue, which needs a process to run them. Create a worker 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.