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:Dockerfile — in server/. Make your customizations, then build:
Running the Image
One app container plus Postgres is a complete deployment:docker-compose.yml
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: setSOLID_QUEUE_IN_PUMA: "false" on web and add a worker service:
Published ports and firewalls
On Linux, everyports: 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.
What this means for a production host:
-
Terminate TLS inside compose and don’t publish
webat all. Add a Caddy (or nginx) service that proxies toweb:3000and publishes only80and443, 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 whatufwsays. -
Debug ports stay on loopback. A Postgres port for
psql, a Mailpit UI, a Meilisearch dashboard: publish them as127.0.0.1:<port>:<port>, or better, don’t publish them and usedocker compose execinstead. 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
80and443. -
To filter published ports on the host anyway, put the rules in Docker’s
DOCKER-USERchain, which Docker evaluates before its own forwarding rules. Setting"iptables": falsein 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 | shinstalls a current engine; a distribution’sdocker.iopackage may not.
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.

