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 thedatabase: cacheline underproduction. - In
config/cable.yml, delete theconnects_toblock underproduction. - In
config/environments/production.rb, delete the lineconfig.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
- Create the database. Create a PostgreSQL managed service and wait until it is running.
- 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. - Add the master key. In the Variables tab, add
RAILS_MASTER_KEYas a Sensitive variable, with the content ofconfig/master.key. - Bind the database. Bind the PostgreSQL service to the application, which gives it
DATABASE_URL. - Set the command. Choose More actions, then Change configuration…, and enter
./bin/rails db:prepareunder Pre-deploy commands. - 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::InvalidMessagemeansRAILS_MASTER_KEYdoes not match your credentials file.- A pre-deploy command that fails with
permission denied to create databasestill 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.