A Spree 6 production deployment is one Docker image + one database. The same image runs the Store/Admin API, serves the React admin at /dashboard, and — by default — processes background jobs in-process. Jobs (Solid Queue) and cache (Solid Cache) live in the database; Redis is optional. Everything is configured with environment variables.
Project layout reminder: the Rails app is server/ (older projects: backend/), the admin dashboard is apps/dashboard/. Classic flavor (plain Rails app with Spree gems at the repo root) uses the same env vars and Rails config — only the paths and the CLI wrapper differ.
Topology decision
| Shape | When | How |
|---|---|---|
| Combined (default) | Most stores; one container is the whole app | Web container runs Puma with the Solid Queue plugin (SOLID_QUEUE_IN_PUMA=true) |
| Split worker | Heavy imports, big catalogs, marketplaces syncing many sellers, multi-tenant | Same image, second service with command bin/jobs; set SOLID_QUEUE_IN_PUMA=false on web |
| Distributed | Independent scaling/release of surfaces | API behind a load balancer; dashboard as static files on a CDN; storefront (Next.js) on Vercel/Node host |
| Sidekiq | Only when a dedicated Solid Queue worker with raised JOB_THREADS/JOB_CONCURRENCY no longer keeps up |
Swap Active Job adapter to Sidekiq on Redis/Valkey (see below) |
Start combined; every step up is configuration, not code.
Building the image
spree eject # once — materializes server/ incl. its production Dockerfile
spree build --production # → <project>-spree:latest
spree build --production --tag registry.example.com/my-store:v42
# without the CLI, from the project root:
docker build . -f server/Dockerfile -t my-store
Build from the project root as context: the Dockerfile detects server/ (Rails) and apps/dashboard/ (your customized dashboard) and bakes your dashboard build into the image. Without apps/dashboard/, the stock dashboard is baked in. spree build --production recognizes the layout-aware Dockerfile by its .spree-custom-app marker (.spree-custom-dashboard in the first release); without either it falls back to building with server/ as the context. So if your server/Dockerfile predates this layout support, update it from the spree-starter template — otherwise you ship the stock dashboard over your customized one.
Uncustomized stores can run the official multi-arch image ghcr.io/spree/spree:<version> directly.
Environment variables
Strictly required: DATABASE_URL, SECRET_KEY_BASE. Practically required: RAILS_HOST and the three Active Record encryption keys. Full table: references/environment-variables.md.
| Variable | Why it matters |
|---|---|
DATABASE_URL |
postgres://…, mysql2://…, or sqlite3:db/production.sqlite3 |
SECRET_KEY_BASE |
openssl rand -hex 64. Keep stable — sessions, cookies, JWT fallback and secret-API-key digests depend on it |
ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY, ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY, ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT |
Encrypt webhook signing secrets, gateway customer IDs and OAuth identity tokens at rest. Unset → plaintext + a boot warning. Generate with spree encryption init --print or bin/rails db:encryption:init; a separate set per environment, backed up; never change them once data is encrypted. Workers need the same values as web. See spree-security |
RAILS_HOST |
Public host (no protocol) for email links, webhook payloads, and attachment URLs in API responses. Unset → URLs point at localhost. On Render, falls back to RENDER_EXTERNAL_HOSTNAME |
CDN_HOST |
Optional separate host for assets and images (sets config.asset_host and Spree.cdn_host) |
SOLID_QUEUE_IN_PUMA |
true (default) runs jobs in the web process; false when a bin/jobs worker runs |
JOB_THREADS / JOB_CONCURRENCY |
Worker threads (default 3) / worker processes (default 1) |
SPREE_IMPORT_JOB_CONCURRENCY |
Cap on concurrent CSV import group jobs (default 75% of JOB_THREADS, min 1; 0 = no cap) |
MISSION_CONTROL_USER / MISSION_CONTROL_PASSWORD |
HTTP Basic auth for /jobs. Unset in production → dashboard locked |
SMTP_HOST (+ SMTP_PORT, SMTP_USERNAME, SMTP_PASSWORD, SMTP_FROM_ADDRESS) |
Without SMTP_HOST, production sends no email. Any SMTP provider works, with nothing to install. STARTTLS is always attempted, so use port 587, not an implicit-TLS 465. Auth is plain, sent only when SMTP_USERNAME is set. Merchants override From/Reply-To per store in Settings → Emails |
AWS_* or CLOUDFLARE_* |
Object storage for uploads — required on ephemeral filesystems |
RAILS_FORCE_SSL / RAILS_ASSUME_SSL |
Both default true (behind a TLS-terminating proxy). Set both false only when there is no TLS anywhere |
SENTRY_DSN, OTEL_*, MEILISEARCH_URL |
Optional integrations (below) |
Puma: PORT (3000), RAILS_MAX_THREADS (3), WEB_CONCURRENCY (1; auto = one per core, ~1 GB RAM each).
Database
- PostgreSQL recommended; MySQL/MariaDB and SQLite are supported in production too (SQLite: single host only, small stores).
- Pool size must cover web threads plus in-process job threads: the template's
database.ymlusesRAILS_MAX_THREADS + JOB_THREADS + 3. If you hand-rolldatabase.yml, keep that formula or jobs in Puma will exhaust the pool. - Solid Queue, Solid Cache and Solid Cable tables live in the same database — back it up as one unit.
Migrations, seeds, and upgrades
- The image entrypoint (
bin/docker-entrypoint) runsbin/rails db:prepareonly when the container command is the Rails server — creates + seeds on first boot, migrates on later boots.bin/jobsworkers don't migrate. - First boot: run it once before scaling out — a release/init job running
bin/rails db:prepare, or a single web replica. Rails' migration advisory lock (PostgreSQL/MySQL) serializes migrations only, not database creation, schema load or seeding, so replicas racing on an empty database can collide or double-seed. - Later boots: several web replicas migrating at once is fine on PostgreSQL/MySQL (the advisory lock). If your platform has a release/pre-deploy phase and you want failed migrations to block the rollout, run
bin/rails db:migratethere instead. - After upgrading Spree gems, run the data backfills once per environment:
bin/rails spree:upgrade(idempotent;DRY_RUN=1to preview). The entrypoint does not run it — use a release command or one-off task. See thespree-upgradeskill. - No default admin exists. Seeding prints a one-time setup link; print it again with
bin/rails spree:setup:token.
Background jobs (Solid Queue)
-
config/puma.rbloadsplugin :solid_queuewhenSOLID_QUEUE_IN_PUMAistrue. -
config/queue.ymldefines worker queues in polling order (checkout-critical first, bulk catalog next, Active Storage housekeeping last) with a trailing"*"so any unlisted queue still runs. -
Spree jobs pick their queue from
Spree.queues.<name>(every entry defaults to:default); the template'sconfig/initializers/spree.rbmaps them tospree_*names. Seespree-performancefor tuning. -
config/recurring.ymlis Solid Queue's cron. The template schedules (production only):Spree::StockReservations::ExpireJob— every minute (releases expired checkout holds)Spree::Orders::FinalizeStaleDraftsJob— every 5 minutes (finishes orders whose payment succeeded but whose completion never committed; never refunds)Spree::Carts::ReapExpiredJob— hourly (deletes abandoned carts past expiry; carts with a live payment are kept)Spree::Collections::RegenerateTimeBasedJob— hourly (automatic collections with time-based rules)spree:price_history:prunerake task (via acommand:entry) — daily (EU Omnibus retention, per store)Spree::SellerPayouts::SweepDueJob— daily (marketplace payouts; run it daily regardless of seller payout intervals — due-ness is decided per seller)Spree::SellerTransfers::ExecutePendingDueJob— hourly (retries refused seller transfers)- Solid Queue finished-job and Solid Cable message cleanup — hourly
The marketplace jobs are no-ops on a store without sellers — keep them. If your project predates any entry, copy it from the spree-starter
config/recurring.yml. -
Split mode: add a service running
bin/jobswith the same env (DATABASE_URL,SECRET_KEY_BASE,RAILS_HOST), raiseJOB_THREADSthere, scale withJOB_CONCURRENCYor more replicas, and setSOLID_QUEUE_IN_PUMA=falseon web. On a split deployment setSPREE_IMPORT_JOB_CONCURRENCYexplicitly on web — the cap is computed by the process that enqueues the import.
Mission Control (/jobs)
Mounted at /jobs in the template's routes. Set MISSION_CONTROL_USER and MISSION_CONTROL_PASSWORD; in development they default to spree / spree123. It's Solid Queue-specific.
Swapping to Sidekiq (optional)
# Gemfile
gem "sidekiq"
gem "sidekiq-cron" # replaces config/recurring.yml
# config/application.rb
config.active_job.queue_adapter = :sidekiq
config/sidekiq.ymlmust list every queue you assigned viaSpree.queuesplusdefault,mailers, and theactive_storage_*queues — Sidekiq has no"*"catch-all, so an unlisted queue never runs.- Move
recurring.ymlschedules toconfig/schedule.yml(sidekiq-cron). sidekiq-cron schedules jobs, not commands, so wrap thespree:price_history:prunetask in a small job (Rails.application.load_tasksunless defined, thenRake::Task[...].tap(&:reenable).invoke). - Mount
Sidekiq::Webbehind auth instead of Mission Control; runbundle exec sidekiqas its own service withRAILS_MAX_THREADS >= SIDEKIQ_CONCURRENCY; dropSOLID_QUEUE_IN_PUMA.
Cache
Solid Cache (config.cache_store = :solid_cache_store) is the default — nothing to provision. For an in-memory cache: add the redis gem and set config.cache_store = :redis_cache_store, { url: ENV["REDIS_URL"] } in config/environments/production.rb. Works with Valkey.
The admin dashboard
- Single node (default): the
spree_dashboardgem serves the built dashboard at/dashboard(and an optional marketplace seller panel at/sellers) with SPA fallback. It serves the directory fromSpree::Dashboard.dist_pathorSPREE_DASHBOARD_DIST_PATH(seller panel:SPREE_SELLER_PANEL_DIST_PATH); unset → 404. The image build sets this up — same origin, no CORS, no API keys. - Static host / CDN:
cd apps/dashboard && VITE_SPREE_API_URL=https://api.example.com pnpm build, uploaddist/, rewrite/* → /index.html, HTTPS on both sides (the refresh cookie isSameSite=None; Secure), and add the dashboard origin under Settings → Allowed Origins.VITE_*values are baked into the bundle — never put secrets there; one build per environment. - API-only deployments can drop the
spree_dashboardgem.
File storage and CDN
- Uploads go through Active Storage. Local disk is the default and is lost on ephemeral platforms (Render, Heroku, Fargate, most K8s). Set
AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY(+AWS_REGION,AWS_BUCKET) orCLOUDFLARE_ENDPOINT+CLOUDFLARE_ACCESS_KEY_ID/CLOUDFLARE_SECRET_ACCESS_KEY(+CLOUDFLARE_BUCKET); the template auto-detects the service. Other backends: standardconfig/storage.yml. - Put a CDN in front of
/rails/active_storage/representations/and/assets. With a separate CDN hostname, setCDN_HOST.
Search
Default search is SQL (Spree::SearchProvider::Database). For large catalogs add Meilisearch:
# Gemfile
gem "spree_meilisearch"
# config/initializers/spree.rb
Spree.search_provider = 'SpreeMeilisearch::SearchProvider'
Env: MEILISEARCH_URL (default http://localhost:7700), MEILISEARCH_API_KEY. Build the index after the first deploy and after provider config changes: bin/rails spree:search:reindex (spree task search:reindex locally).
Observability
- Errors: set
SENTRY_DSN(template shipssentry-rails). - Tracing: add
gem 'spree_opentelemetry'and set standard OTel vars —OTEL_SERVICE_NAME,OTEL_EXPORTER_OTLP_ENDPOINT(orOTEL_EXPORTER_OTLP_TRACES_ENDPOINT), optionalOTEL_TRACES_SAMPLER=parentbased_traceidratio+OTEL_TRACES_SAMPLER_ARG. Dormant until an exporter var is set;OTEL_SDK_DISABLED=truealways wins. Spans cover HTTP, SQL, jobs, everySpree::Workflowrun/step/hook, event dispatch, webhook deliveries (W3C trace context propagated), and gateway calls. Span attributes carry no PII. - Sentry + OTel: keep Sentry for errors and don't also set Sentry's
traces_sample_rate(double instrumentation). To use Sentry as the trace backend, see the Sentry section of the observability doc (sentry-opentelemetry,SpreeOpenTelemetry.install!beforeSentry.init,OTEL_TRACES_EXPORTER=none). - Health check:
/up(Rails built-in; it does not touch the DB).
Platform notes
- Render:
create-spree-appmoves the starter's Blueprint to the project root asrender.yamland points every service'sdockerfilePath(including the commented worker) at./server/Dockerfile, with the repo root asdockerContext— one web service + Postgres. The Blueprint generatesSECRET_KEY_BASE, the threeACTIVE_RECORD_ENCRYPTION_*keys (copy them from the Environment tab into your secret manager) andMISSION_CONTROL_PASSWORD(userjobs). Uncomment the worker block for split mode. Filesystem is ephemeral — configure S3/R2. - AWS: single EC2 + RDS, or ECS Fargate (web and
bin/jobsas separate services from one image). - Kubernetes: one Deployment for web, optionally one for
bin/jobs; Secrets forSECRET_KEY_BASE/DATABASE_URL/storage creds; liveness on/up; migrations either via the entrypoint (server command) or a Job/init container runningdb:migrate. - VPS:
docker composewithweb+postgresis a complete deployment; add Caddy/nginx/Traefik for TLS.
Common problems
| Symptom | Cause / fix |
|---|---|
Image/attachment URLs point at https://localhost/... |
RAILS_HOST (or CDN_HOST) unset |
/dashboard returns 404 |
spree_dashboard gem missing or SPREE_DASHBOARD_DIST_PATH/Spree::Dashboard.dist_path unset (custom image built without the dashboard stage) |
| Static-host dashboard can't log in / CORS errors | Origin not in Settings → Allowed Origins, or not HTTPS on both sides |
/jobs always 401 |
MISSION_CONTROL_USER/PASSWORD unset in production |
| Jobs never run after splitting | Worker not running bin/jobs, or SOLID_QUEUE_IN_PUMA=false set on web with no worker |
| Jobs run on Solid Queue but not on Sidekiq | Queue missing from sidekiq.yml (no catch-all) |
ActiveRecord::ConnectionTimeoutError under load |
Pool smaller than RAILS_MAX_THREADS + JOB_THREADS |
| Uploads vanish after redeploy | Local disk storage on an ephemeral filesystem |
| No emails in production | SMTP_HOST unset |
| Search returns nothing on Meilisearch | Index never built — run spree:search:reindex |
| Checkout stock held forever / sellers never paid | recurring.yml entries missing or not in the production: key, or you moved to Sidekiq without porting them |
| Store data looks half-migrated after a gem bump | spree:upgrade not run |
Boot log: [Spree] Active Record encryption is not fully configured |
ACTIVE_RECORD_ENCRYPTION_* unset (or not read into config.active_record.encryption) — secrets are being stored in plaintext |
| Webhook secrets / gateway customers unreadable after a deploy | Encryption keys changed or missing on this environment, or encryption enabled without first encrypting existing rows (see spree-security) |
Where to read further
node_modules/@spree/docs/dist/developer/deployment/—quickstart.md,docker.md,environment_variables.md,background_jobs.md,caching.md,database.md,assets.md,cdn.md,emails.md,render.md,aws.md,aws_ecs.mdnode_modules/@spree/docs/dist/developer/dashboard/deployment.mdnode_modules/@spree/docs/dist/developer/providers/observability.md- https://spreecommerce.org/docs/developer/deployment/quickstart
- Related skills:
spree-performance(queue and worker tuning),spree-upgrade(release-phase upgrades),spree-security(secrets, encryption keys),spree-multi-tenant(root domain, cookies, hosts)