How to Deploy Ruby on Rails to Production (2026)
This guide covers everything you need to deploy a Rails 8 application to production: environment variables, PostgreSQL, Solid Queue (background jobs), Thruster (asset caching + HTTP/2), and HTTPS — all automated by Appliku. Appliku handles server setup so you can deploy in minutes, at 60–80% less than Heroku, on your own infrastructure.
In this tutorial you will learn how to start a Rails 8 project, prepare it for Appliku deployment using a Dockerfile and an appliku.yml manifest, and deploy it to your own VPS.
Rails Project
Starting a New Rails Project
To start a new Rails project you will need Ruby 4.0 or higher.
This tutorial assumes you are working in a Linux or Mac terminal. If you are on Windows, use WSL.
Install Rails and create your project:
gem install rails
rails new myapp
cd myapp
This generates a complete Rails 8 application with SQLite for development, Solid Cache, Solid Queue, Solid Cable, and a production-ready Dockerfile already included.
Open your project in your favorite code editor.
Rails just created config/master.key. This file is already in .gitignore and will never be committed. Without it your app cannot start in production.
Find it and copy it somewhere safe now:
cat config/master.key
You will paste this value into the Appliku dashboard as the RAILS_MASTER_KEY environment variable before your first deploy.
Adding PostgreSQL to the Gemfile
By default Rails 8 uses SQLite everywhere. For production on Appliku you need PostgreSQL. Add the pg gem scoped to the production group so it does not affect your local development setup.
Open Gemfile and add this line after the sqlite3 gem:
# PostgreSQL when DATABASE_URL is set (e.g. Appliku)
gem "pg", "~> 1.5", group: :production
Run bundle install to update Gemfile.lock.
Configuring config/database.yml
Rails 8 ships with an all-SQLite database.yml. Replace the production: block with a conditional that switches to PostgreSQL when DATABASE_URL is present (Appliku always sets it), and falls back to SQLite otherwise.
Open config/database.yml and replace everything after the test: block with:
<% if ENV["DATABASE_URL"].to_s.strip != "" %>
# Production on Appliku (and any host that sets DATABASE_URL): one PostgreSQL database
# for app + Solid Cache / Queue / Cable tables.
production:
primary:
url: <%= ENV["DATABASE_URL"] %>
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
cache:
url: <%= ENV["DATABASE_URL"] %>
migrations_paths: db/cache_migrate
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
queue:
url: <%= ENV["DATABASE_URL"] %>
migrations_paths: db/queue_migrate
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
cable:
url: <%= ENV["DATABASE_URL"] %>
migrations_paths: db/cable_migrate
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
<% else %>
# Store production database in the storage/ directory, which by default
# is mounted as a persistent Docker volume in config/deploy.yml.
production:
primary:
<<: *default
database: storage/production.sqlite3
cache:
<<: *default
database: storage/production_cache.sqlite3
migrations_paths: db/cache_migrate
queue:
<<: *default
database: storage/production_queue.sqlite3
migrations_paths: db/queue_migrate
cable:
<<: *default
database: storage/production_cable.sqlite3
migrations_paths: db/cable_migrate
<% end %>
All four databases (primary, cache, queue, cable) point to the same PostgreSQL instance — Solid Cache and Solid Queue each use their own schema within that database.
Configuring config/environments/production.rb
Two small additions are needed in the production environment config:
SSL termination — Appliku terminates TLS at its proxy and passes RAILS_ASSUME_SSL=1. Add this block so Rails knows it's behind SSL:
# Terminate TLS at the platform proxy (set RAILS_ASSUME_SSL=1 on Appliku).
if ENV["RAILS_ASSUME_SSL"] == "1"
config.assume_ssl = true
config.force_ssl = true
end
Host authorization — Appliku sets ALLOWED_HOSTS to a comma-separated list of your app's domains. Add this to register them with Rails' host authorization middleware:
# Appliku sets ALLOWED_HOSTS to your app domains (comma-separated).
ENV.fetch("ALLOWED_HOSTS", "").split(",").map(&:strip).reject(&:empty?).each do |host|
config.hosts << host
end
# Skip DNS rebinding protection for the default health check endpoint.
config.host_authorization = { exclude: ->(request) { request.path == "/up" } }
Rails 8 already configures Solid Cache and Solid Queue in production by default, so no further changes are needed there.
Complete config/environments/production.rb
require "active_support/core_ext/integer/time"
Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb.
# Code is not reloaded between requests.
config.enable_reloading = false
# Eager load code on boot for better performance and memory savings (ignored by Rake tasks).
config.eager_load = true
# Full error reports are disabled.
config.consider_all_requests_local = false
# Turn on fragment caching in view templates.
config.action_controller.perform_caching = true
# Cache assets for far-future expiry since they are all digest stamped.
config.public_file_server.headers = { "cache-control" => "public, max-age=#{1.year.to_i}" }
# Store uploaded files on the local file system (see config/storage.yml for options).
config.active_storage.service = :local
# Terminate TLS at the platform proxy (set RAILS_ASSUME_SSL=1 on Appliku).
if ENV["RAILS_ASSUME_SSL"] == "1"
config.assume_ssl = true
config.force_ssl = true
end
# Log to STDOUT with the current request id as a default log tag.
config.log_tags = [ :request_id ]
config.logger = ActiveSupport::TaggedLogging.logger(STDOUT)
# Change to "debug" to log everything (including potentially personally-identifiable information!).
config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info")
# Prevent health checks from clogging up the logs.
config.silence_healthcheck_path = "/up"
# Don't log any deprecations.
config.active_support.report_deprecations = false
# Replace the default in-process memory cache store with a durable alternative.
config.cache_store = :solid_cache_store
# Replace the default in-process and non-durable queuing backend for Active Job.
config.active_job.queue_adapter = :solid_queue
config.solid_queue.connects_to = { database: { writing: :queue } }
# Enable locale fallbacks for I18n (makes lookups for any locale fall back to
# the I18n.default_locale when a translation cannot be found).
config.i18n.fallbacks = true
# Do not dump schema after migrations.
config.active_record.dump_schema_after_migration = false
# Only use :id for inspections in production.
config.active_record.attributes_for_inspect = [ :id ]
# Appliku sets ALLOWED_HOSTS to your app domains (comma-separated).
ENV.fetch("ALLOWED_HOSTS", "").split(",").map(&:strip).reject(&:empty?).each do |host|
config.hosts << host
end
# Skip DNS rebinding protection for the default health check endpoint.
config.host_authorization = { exclude: ->(request) { request.path == "/up" } }
end
The Dockerfile
Rails 8 generates a production-ready Dockerfile. You can use it as-is — it already includes jemalloc for reduced memory usage and Thruster for HTTP asset caching and compression.
The generated Dockerfile looks like this:
# syntax=docker/dockerfile:1
# check=error=true
ARG RUBY_VERSION=4.0.2
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
# Rails app lives here
WORKDIR /rails
# Install base packages
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 && \
ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives
# Set production environment variables and enable jemalloc for reduced memory usage and latency.
ENV RAILS_ENV="production" \
BUNDLE_DEPLOYMENT="1" \
BUNDLE_PATH="/usr/local/bundle" \
BUNDLE_WITHOUT="development" \
LD_PRELOAD="/usr/local/lib/libjemalloc.so"
# Throw-away build stage to reduce size of final image
FROM base AS build
# Install packages needed to build gems
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y build-essential git libyaml-dev pkg-config && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives
# Install application gems
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 application code
COPY . .
# Precompile bootsnap code for faster boot times.
RUN bundle exec bootsnap precompile -j 1 app/ lib/
# Precompile assets for production without requiring secret RAILS_MASTER_KEY
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
# Final stage for app image
FROM base
# Run and own only the runtime files as a non-root user for security
RUN groupadd --system --gid 1000 rails && \
useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash
USER 1000:1000
# Copy built artifacts: gems, application
COPY --chown=rails:rails --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
COPY --chown=rails:rails --from=build /rails /rails
# Entrypoint prepares the database.
ENTRYPOINT ["/rails/bin/docker-entrypoint"]
# Start server via Thruster by default, this can be overwritten at runtime
EXPOSE 80
CMD ["./bin/thrust", "./bin/rails", "server"]
Key points:
- Multi-stage build — a
buildstage installs gems and compiles assets, then copies only the runtime artifacts to the final image, keeping it small. - jemalloc — loaded via
LD_PRELOADto reduce memory fragmentation and improve latency. - Thruster — a lightweight HTTP proxy that sits in front of Puma, adding asset caching, compression, and X-Sendfile acceleration without a separate Nginx process.
- Port 80 — Thruster listens on port 80 inside the container, which matches the
container_portinappliku.yml.
The appliku.yml Manifest
Create a file called appliku.yml in the root of your project:
# Appliku: https://appliku.com/llms.txt — build → release → web process.
# Dashboard: set RAILS_MASTER_KEY to the contents of config/master.key (repo secret, not committed).
build_settings:
build_image: dockerfile
dockerfile_path: Dockerfile
# Must match the HTTP port the container listens on (Thruster + Puma in this image use 80).
container_port: 80
environment_variables:
- name: DATABASE_URL
from_database:
name: db
property: private_connection_url
- name: ALLOWED_HOSTS
from_domains: true
- name: RAILS_MASTER_KEY
source: manual
- name: RAILS_ENV
value: production
- name: SOLID_QUEUE_IN_PUMA
value: "true"
- name: RAILS_ASSUME_SSL
value: "1"
services:
web:
command: "./bin/docker-entrypoint ./bin/thrust ./bin/rails server"
scale: 1
release:
command: "bundle exec rails db:prepare"
databases:
db:
type: postgresql_17
What each section does:
build_settings— tells Appliku to build from yourDockerfileand that the container will listen on port 80.DATABASE_URL— automatically populated from the provisioned PostgreSQL database. No manual copy-paste needed.ALLOWED_HOSTS— automatically set to all domains attached to your Appliku application, which feeds directly into Rails' host authorization.RAILS_MASTER_KEY— marked asmanualbecauseconfig/master.keyis intentionally not committed to git. You will set this in the Appliku dashboard after creating the app (see below).SOLID_QUEUE_IN_PUMA— runs Solid Queue inside the Puma web process so you don't need a separate worker dyno.RAILS_ASSUME_SSL— tells Rails it's behind an SSL-terminating proxy.services.web— the HTTP-serving process. Thedocker-entrypointscript runs any pending migrations before handing off to Thruster.services.release— runsdb:prepareon every deploy, which creates the database if it doesn't exist and runs all pending migrations (including Solid Cache/Queue/Cable tables).databases.db— provisions a PostgreSQL 17 database on the same server.
Pushing Code to GitHub
Create a .gitignore in the root of your project. The default Rails .gitignore already excludes the files you don't want committed, but make sure config/master.key is listed — it must never be committed:
# See https://www.toptal.com/developers/gitignore
/.bundle
/log/*
/tmp/*
!/log/.keep
!/tmp/.keep
/storage/*
!/storage/.keep
/public/assets
.byebug_history
.idea/
.vscode/
.DS_Store
# Ignore master key for decrypting credentials and more.
/config/master.key
Initialize git and push to GitHub:
git init -b master
git add .
git commit -m "Initial commit"
Go to GitHub.com, create a new repository, then copy the remote add command and push:
git remote add origin https://github.com/yourusername/myapp.git
git push -u origin master
Appliku Account
If you don't already have an Appliku account, create one at app.appliku.com.
Create a server in a cloud provider of your choice, then create an app.
Create Application
Go to the Applications menu, click Add Application, and select GitHub.
Give your application a name, select the repository and branch, choose your server, then click Create Application.
Appliku will detect the appliku.yml manifest and automatically configure the build settings, environment variables, and database for you.
Set RAILS_MASTER_KEY
In the Appliku dashboard, go to your application's Environment Variables tab and set RAILS_MASTER_KEY to the contents of config/master.key (you saved this at the start of the tutorial). Everything else is handled automatically by appliku.yml.
Deploy
Once RAILS_MASTER_KEY is set, trigger a deployment. Appliku will:
- Build your Docker image using the multi-stage
Dockerfile - Provision the PostgreSQL 17 database
- Run
bundle exec rails db:prepare(thereleaseprocess) — this creates all tables including Solid Cache, Solid Queue, and Solid Cable schemas - Start the
webprocess via Thruster + Puma
When the deployment completes, click Open App to verify it's running. The /up health check endpoint is always available.
A Hetzner VPS + Appliku costs ~$15/month vs $140+/month on Heroku — same git-push deploys, your own infrastructure. See the full comparison →