Skip to main content

Building Your Project’s Image

Every create-spree-app project builds into a single production image: the web server and background jobs run in one container, and the only dependency is a PostgreSQL database (the single-node topology). If you haven’t ejected yet, do that first:
This generates the app source — including its production Dockerfile — in server/. Make your customizations, then build:
Or with plain Docker, from the project root:
Both produce the same image — and it’s exactly what Render and Railway build from your repository. Migrations run automatically on boot.

Running the Image

One app container plus Postgres is a complete deployment:
docker-compose.yml
Start everything:
The database is created and migrated on first boot. The app is available at http://localhost:3000.
PostgreSQL 18 images keep data in a version-specific directory under /var/lib/postgresql, so the volume above must be empty on first boot. If you already have a volume with PostgreSQL 17 (or older) data, migrate it first — dump and restore into a fresh PostgreSQL 18 volume, or use pg_upgrade.

Scaling out the worker

Background jobs run inside the web container by default. When job load deserves its own process, split it out — same image, no rebuild: set SOLID_QUEUE_IN_PUMA: "false" on web and add a worker service:

Published ports and firewalls

On Linux, every ports: entry is implemented with Docker’s own iptables NAT and FORWARD rules, and those rules are evaluated before the host’s INPUT chain. A host firewall such as ufw or firewalld never sees that traffic: ufw default deny incoming with ports: "5432:5432" in a compose file still leaves Postgres open to the whole internet, and a database or cache published that way is found by scanners within minutes. Docker Engine 28.0 tightened what unpublished ports leak to the local network, but a port published on all interfaces is as reachable on Docker 28 as it was before.
Never add a ports: entry to postgres (or Redis, or Meilisearch) on an internet-facing host. The compose file above runs Postgres with POSTGRES_HOST_AUTH_METHOD: trust — passwordless — which is only safe because the container has no published port and is reachable solely from the compose network. spree upgrade does not rewrite compose files, so projects created before Spree 5.5.2 should check their port bindings.
What this means for a production host:
  • Terminate TLS inside compose and don’t publish web at all. Add a Caddy (or nginx) service that proxies to web:3000 and publishes only 80 and 443, as the EC2 guide does. Then the only published ports are the ones you meant to open.
  • If a reverse proxy runs on the host instead, bind the app to loopback so the proxy is the only way in:
    With a plain "3000:3000" the app also answers directly on port 3000, over plain HTTP, no matter what ufw says.
  • Debug ports stay on loopback. A Postgres port for psql, a Mailpit UI, a Meilisearch dashboard: publish them as 127.0.0.1:<port>:<port>, or better, don’t publish them and use docker compose exec instead. The spree-starter compose files already do this.
  • A cloud security group is unaffected. AWS security groups, Hetzner Cloud firewalls, DigitalOcean firewalls and the like filter traffic before it reaches the host, so Docker’s rules can’t bypass them. They are a reliable place to allow only 80 and 443.
  • To filter published ports on the host anyway, put the rules in Docker’s DOCKER-USER chain, which Docker evaluates before its own forwarding rules. Setting "iptables": false in the daemon config is not a practical alternative — containers lose outbound NAT.
  • Run Docker Engine 28.0.1 or newer. Older engines let hosts on the same LAN reach container ports that were never published by routing straight to the container’s IP. curl -fsSL https://get.docker.com | sh installs a current engine; a distribution’s docker.io package may not.
Check what a host actually exposes with docker ps — every container on the host, where docker compose ps covers one project. The PORTS column shows 0.0.0.0: for an all-interfaces publish and 127.0.0.1: for loopback. ss -tlnp on the host is a second opinion, but it lists only host sockets, so it misses NAT-only publishes when userland-proxy is turned off.

Required Environment Variables

See Environment Variables for the full list.

Official Docker Image

To run Spree without any customizations, pull the prebuilt multi-arch image (linux/amd64 and linux/arm64) published to the GitHub Container Registry on every release:
Browse all tags on the GitHub Packages page. The image drops into the compose file above in place of your custom tag.