Add Docker Compose production deployment with auto-generated secrets#2750
Add Docker Compose production deployment with auto-generated secrets#2750crueber wants to merge 2 commits intobasecamp:mainfrom
Conversation
- Extend bin/docker-entrypoint to auto-generate and persist SECRET_KEY_BASE and VAPID keys on first boot into a dedicated fizzy_secrets volume. On every subsequent boot the secrets file is sourced so Rails receives the same stable values — no manual key generation step required. - Add docker-compose.yml with named volumes (fizzy_storage for SQLite databases and Active Storage uploads, fizzy_secrets for the generated secrets file), a health check, and all required environment variables pre-configured. - Add .env.example documenting every operator-facing variable. - Add !/.env.example exception to .gitignore so the template is tracked. The upstream entrypoint only ran db:prepare; it had no mechanism to supply SECRET_KEY_BASE or VAPID keys, so the app would crash on first boot with a missing secret_key_base error and web push would be non-functional. The new entrypoint generates these values once, writes them atomically to a mounted volume, and sources them on every subsequent start.
There was a problem hiding this comment.
Pull request overview
This PR adds a self-contained, Docker Compose–based production deployment path for Fizzy, including first-boot secret generation/persistence so the app can start reliably without manual operator key creation.
Changes:
- Add
docker-compose.ymlfor a single-container production deployment (SQLite + Solid Queue in Puma) with persistent storage and healthcheck. - Extend
bin/docker-entrypointto generate and persistSECRET_KEY_BASEand VAPID keys on first boot, then load them on every boot. - Add
.env.exampleand update.gitignoreso operators have a complete configuration template.
Reviewed changes
Copilot reviewed 2 out of 4 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
docker-compose.yml |
Defines the production Compose service, volumes, environment wiring, and healthcheck. |
bin/docker-entrypoint |
Implements first-boot secret generation + persistent secrets sourcing on startup. |
.env.example |
Documents operator-facing configuration variables for Compose deployments. |
.gitignore |
Ensures .env.example is committed while keeping real .env* files ignored; adds worktree ignore. |
Tip
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| - fizzy_secrets:/rails/secrets | ||
| environment: | ||
| # .env.example has some examples for what needs to be in place for this. | ||
| - RAILS_ENV=$RAILS_ENV |
There was a problem hiding this comment.
RAILS_ENV=$RAILS_ENV will override the image’s RAILS_ENV=production with an empty string when RAILS_ENV isn’t set in the host/.env, which can cause Rails to boot in the wrong environment. Consider removing this line (let the image default stand) or using a default like production in the compose interpolation.
| - RAILS_ENV=$RAILS_ENV | |
| - RAILS_ENV=${RAILS_ENV:-production} |
There was a problem hiding this comment.
It could, but that should be part of your environment. So I don't see a need to update it. Up to ya'll if you want me to add this.
| environment: | ||
| # .env.example has some examples for what needs to be in place for this. | ||
| - RAILS_ENV=$RAILS_ENV | ||
| - FIZZY_HOST=$FIZZY_HOST |
There was a problem hiding this comment.
FIZZY_HOST is passed into the container here, but it doesn’t appear to be referenced anywhere in the codebase. Keeping unused operator-facing config increases confusion; consider removing it from compose (and .env.example if added later) or wiring it to an actual setting.
| - FIZZY_HOST=$FIZZY_HOST |
There was a problem hiding this comment.
This is fair. I left it because others had it. I am happy to remove it for whoever reviews this, as needed.
| - SMTP_USERNAME=$SMTP_USERNAME | ||
| - SMTP_PASSWORD=$SMTP_PASSWORD | ||
| - SMTP_AUTHENTICATION=$SMTP_AUTHENTICATION | ||
| - SMTP_TLS=$SMTP_TLS |
There was a problem hiding this comment.
.env.example documents SMTP_SSL_VERIFY_MODE, and production.rb reads it, but docker-compose.yml never passes it into the container. As a result operators can’t configure SSL verification mode when using compose; add it to the environment: list (or switch to env_file:).
| - SMTP_TLS=$SMTP_TLS | |
| - SMTP_TLS=$SMTP_TLS | |
| - SMTP_SSL_VERIFY_MODE=$SMTP_SSL_VERIFY_MODE |
There was a problem hiding this comment.
Fair. I didn't add it to mine, because I didn't need it, and I don't think most people will need it. I left it in as part of the examples though.
| - WEB_CONCURRENCY=2 | ||
| - JOB_CONCURRENCY=1 | ||
| - MULTI_TENANT=true |
There was a problem hiding this comment.
WEB_CONCURRENCY, JOB_CONCURRENCY, and MULTI_TENANT are hard-coded here, which prevents operators from changing them via .env (and MULTI_TENANT=true conflicts with .env.example/config/deploy.yml defaulting it to false). Prefer wiring these from env with sensible defaults so compose behaves as documented.
| - WEB_CONCURRENCY=2 | |
| - JOB_CONCURRENCY=1 | |
| - MULTI_TENANT=true | |
| - WEB_CONCURRENCY=${WEB_CONCURRENCY:-2} | |
| - JOB_CONCURRENCY=${JOB_CONCURRENCY:-1} | |
| - MULTI_TENANT=${MULTI_TENANT:-false} |
There was a problem hiding this comment.
Yeah, they could be moved to the .env.example file, but I didn't see any good reason to do so. So I hardcoded them.
There was a problem hiding this comment.
Here is an interesting discussion why one might want to change them: #2350 (comment)
| # Generated secrets.env (SECRET_KEY_BASE, VAPID keys) live here | ||
| # Uses a dedicated /rails/secrets path to avoid overwriting baked-in config files | ||
| # IMPORTANT: back up this volume — losing it invalidates all sessions and web push subscriptions | ||
| - fizzy_secrets:/rails/secrets | ||
| environment: |
There was a problem hiding this comment.
Mounting a named volume at /rails/secrets can be unwritable for the non-root rails user if the mountpoint directory doesn’t exist in the image at build time (Docker will create it as root-owned). Ensure /rails/secrets exists and is owned by uid 1000 in the image so the first-boot secret generation can write secrets.env.
There was a problem hiding this comment.
This is the only got'ja that is left in the build. You have to chown the mount volume once they're created. But I'm used to that with self hosting. Could just switch it to use root, instead. Personally, I'd just leave it. YMMV.
|
Good idea, just a heads-up: The recommended name for the Compose file is now
https://docs.docker.com/compose/intro/compose-application-model/ |
What this adds
A self-contained Docker Compose production deployment for self-hosters. Three files:
bin/docker-entrypoint— extended to auto-generate and persist secrets on first bootdocker-compose.yml— wires the image, named volumes, ports, and environment.env.example— documents every operator-facing configuration variableWhy the existing entrypoint wasn't enough
The upstream entrypoint only ran
db:prepare. It had no mechanism to supplySECRET_KEY_BASEor VAPID keys, so deploying withdocker compose upwould immediately crash:Rails requires
SECRET_KEY_BASEto be present and stable — it's used to sign all session cookies and encrypted values. If it changes between restarts, every logged-in user is silently signed out. If it's absent entirely, the app refuses to boot.VAPID keys (
VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY) have the same stability requirement: they're the cryptographic identity used to authenticate web push subscriptions. If the keys change, all existing push subscriptions break silently and users stop receiving notifications.Telling operators to generate these manually (e.g. with
rails secret) and paste them into an.envfile puts the burden in the wrong place and is easy to get wrong — especially the VAPID key pair, which requires theweb-pushgem and a specific format.How the new entrypoint solves it
On first boot (when
/rails/secrets/secrets.envdoes not exist):SECRET_KEY_BASEviabundle exec rails secretbundle exec ruby -e "require 'web-push'; ..."/rails/secrets/secrets.envatomically (temp file +mv) to prevent partial writesOn every subsequent boot: sources the existing file so Rails receives the same stable values.
The secrets file lives on a dedicated
fizzy_secretsnamed volume (mounted at/rails/secrets) — separate from/rails/storage(databases + uploads) so the two backup concerns are cleanly separated.docker-compose.yml highlights
fizzy_storage→/rails/storage— all SQLite databases and Active Storage uploadsfizzy_secrets→/rails/secrets— the generatedsecrets.envDISABLE_SSL=true— suppresses Rails'assume_ssl/force_sslso the app plays nicely behind an external reverse proxy (Traefik, Caddy, Nginx, etc.) without redirect loopsSOLID_QUEUE_IN_PUMA=true— no separate worker container neededACTIVE_STORAGE_SERVICE=local— uploads go into the storage volumeDATABASE_ADAPTER=sqlite— no external database required/upwith a 60-second start period to accommodate first-boot secret generation and db:prepareQuick start
cp .env.example .env # set BASE_URL and optionally SMTP_* vars docker compose up -dThat's it. No
rails secret, no manual VAPID generation, no credentials file.