Back to Blog
Tutorial7 min read2026-07-12

How to Deploy Rails 7 with PostgreSQL on PandaStack

Deploy a Rails 7 app end to end: precompile assets without leaking your master key, a production Dockerfile, safe migrations, and DATABASE_URL wiring.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

Rails 7 is pleasantly boring to deploy once you know the four things it needs in production: precompiled assets, a SECRET_KEY_BASE (or master key), a database it can reach, and migrations run at the right moment. Get those four right and Puma does the rest. Here's the whole path, including the parts that usually bite people the first time.

What Rails 7 expects in production

Puma is the default server and listens on port 3000. The generated config/puma.rb already reads the port from the environment:

port ENV.fetch("PORT") { 3000 }

so any platform that injects PORT just works. The other production defaults worth knowing: Rails logs to a file unless you set RAILS_LOG_TO_STDOUT, and on Rails 7.0 the app won't serve its own static files unless RAILS_SERVE_STATIC_FILES is set (check your config/environments/production.rb — if the public_file_server line references that variable, set it to true when there's no nginx in front, which is the normal container setup).

Precompiling assets without your master key

rails assets:precompile boots the app, and booting in production requires a secret key base. You do not want your real RAILS_MASTER_KEY baked into a Docker build layer. Rails gives you an escape hatch, and it differs by minor version:

# Rails 7.1+
SECRET_KEY_BASE_DUMMY=1 bundle exec rails assets:precompile

# Rails 7.0
SECRET_KEY_BASE=dummy bundle exec rails assets:precompile

Both let the precompile step boot with a throwaway secret. The real key arrives at runtime as an environment variable.

The Dockerfile

Rails 7.1 generates a production Dockerfile when you run rails new. If you're on 7.0 or upgraded an older app, here's a trimmed equivalent that follows the same shape — multi-stage, non-root, Postgres client libraries only in the final image:

FROM ruby:3.3-slim AS base
WORKDIR /rails
ENV RAILS_ENV=production \
    BUNDLE_DEPLOYMENT=1 \
    BUNDLE_WITHOUT=development:test

FROM base AS build
RUN apt-get update -qq && \
    apt-get install -y --no-install-recommends build-essential git libpq-dev pkg-config
COPY Gemfile Gemfile.lock ./
RUN bundle install
COPY . .
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile

FROM base
RUN apt-get update -qq && \
    apt-get install -y --no-install-recommends libpq5 curl && \
    rm -rf /var/lib/apt/lists/*
COPY --from=build /usr/local/bundle /usr/local/bundle
COPY --from=build /rails /rails
RUN useradd rails --home /rails --shell /bin/bash && chown -R rails:rails /rails
USER rails
EXPOSE 3000
CMD ["./bin/rails", "server"]

If you use Active Storage with image variants, add libvips to the final stage. On 7.1, prefer the generated Dockerfile — it also precompiles bootsnap and ships a bin/docker-entrypoint that prepares the database on boot.

Database config: DATABASE_URL just works

This is the one place Rails needs almost nothing from you. Active Record natively reads DATABASE_URL and merges it with config/database.yml, so a production section this small is complete:

production:
  <<: *default
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>

Make sure the pg gem is in your Gemfile and do the connection-pool math: each Puma process opens up to RAILS_MAX_THREADS connections, multiplied by WEB_CONCURRENCY workers. Two workers at five threads is ten connections per container. That matters on shared plans — PandaStack's free-tier database allows 50 connections, Pro allows 300 — so keep the product of those two numbers honest before you scale replicas.

Migrations: use db:prepare, and know why races are (mostly) safe

The forward-only command is:

bundle exec rails db:migrate

But in containers, db:prepare is the better default — it creates and loads the schema on a fresh database and just migrates on an existing one, so first deploy and hundredth deploy use the same command:

bundle exec rails db:prepare

Run migrations as a step before the new version takes traffic, not buried inside request handling. If two containers start simultaneously and both attempt migration, Active Record takes a Postgres advisory lock first, so one waits rather than both half-applying — you get an ugly log line, not a corrupted schema. It's still cleaner to run migrations once per deploy than once per replica.

Environment variables that matter

RAILS_ENV=production
RAILS_MASTER_KEY=<contents of config/master.key>
RAILS_LOG_TO_STDOUT=true
RAILS_SERVE_STATIC_FILES=true   # Rails 7.0; harmless on 7.1
WEB_CONCURRENCY=2               # Puma workers
RAILS_MAX_THREADS=5             # threads per worker

RAILS_MASTER_KEY decrypts config/credentials.yml.enc. Never commit config/master.key; the generated .gitignore already excludes it, and it belongs in your platform's environment variable settings, nowhere else.

Deploying on PandaStack

The workflow, end to end:

  1. 1Provision the database. Create a managed PostgreSQL instance (14.x or 16.x are both available) from the dashboard. Backups are scheduled daily automatically, retained 7 days on the free plan, 15 on Pro, 30 on Premium.
  2. 2Connect the repo. Add your Rails app as a container app. With the Dockerfile above in the repo root, PandaStack builds it as-is — images are built with rootless BuildKit in ephemeral Kubernetes job pods, so there's no privileged Docker daemon anywhere in the path.
  3. 3Attach the database. Because the database is attached to the app, DATABASE_URL is injected automatically. Rails picks it up with zero config changes — this is the platform feature that pairs best with Active Record's native URL support.
  4. 4Set the environment variables from the list above in the app's settings. RAILS_MASTER_KEY is the one you cannot skip.
  5. 5Push. Every git push triggers a build and deploy, and the build logs stream live in the dashboard, so when the asset precompile fails on a missing Node dependency you see it in real time instead of five minutes later.

For migrations, either rely on 7.1's bin/docker-entrypoint running db:prepare at boot (fine at low replica counts thanks to the advisory lock), or run the migration as an explicit one-off step per deploy once your app grows.

One caveat worth knowing up front: on the free tier, idle apps scale to zero and cold-start on the next request. That's perfect for a side project or staging environment and wrong for a latency-sensitive production app — paid tiers keep the app warm on stable nodes.

Health checks

Rails 7.1 ships a built-in health endpoint at /up that returns 200 when the app boots without exceptions — point your platform health check there. On 7.0, add a trivial equivalent:

# config/routes.rb
get "up" => proc { [200, {}, ["ok"]] }

A health check that touches the database will take your app out of rotation during a database blip and make a small incident bigger; keep it boot-level unless you have a specific reason.

The short version

Precompile with a dummy secret, ship the master key as an env var, let DATABASE_URL do its native thing, run db:prepare before traffic, and log to stdout. That's the whole trick. If you want to try the flow where the database shows up already wired in, it's at [pandastack.io](https://pandastack.io).

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also