The compose surface
app — builds the Rails image, publishes $BIND_ADDRESS:$PORT (default 0.0.0.0:3000) to the container’s 3000, and healthchecks /up with curl every 30s.
db — postgres:16-alpine, role and database created from your .env values, healthchecked with pg_isready before app starts.
pgdata — named volume; podman compose down keeps it, down -v wipes it.
- Both services use
restart: unless-stopped on the snakenet network, and the entrypoint waits for Postgres itself rather than trusting compose ordering.
- Fedora:
pgdata:/var/lib/postgresql/data:Z gives the database a private SELinux label. No privileged mode or disabled security labels.
- Tunables, all optional in
.env: RAILS_LOG_LEVEL, RAILS_FORCE_SSL, RAILS_MAX_THREADS, WEB_CONCURRENCY, DATABASE_PORT.
# compose.yaml — read by both Podman and Docker compose
# Native commands, from the repository root:
# podman compose up --build -d
# docker compose up --build -d # optional alternative; same configuration
#
# Fedora/SELinux: application files are copied INTO the image, not bind-mounted
# from your home directory. Database storage uses a private :Z-labeled named
# volume. SELinux stays enforcing; do not use privileged or label=disable.
services:
app:
build:
context: .
dockerfile: Dockerfile
image: next-level-snake:local
restart: unless-stopped
networks:
- snakenet
ports:
- "${BIND_ADDRESS:-0.0.0.0}:${PORT:-3000}:3000"
environment:
RAILS_ENV: production
RAILS_LOG_LEVEL: ${RAILS_LOG_LEVEL:-info}
PORT: 3000
SECRET_KEY_BASE: ${SECRET_KEY_BASE:?Set SECRET_KEY_BASE in .env with openssl rand -hex 64}
DATABASE_HOST: db
DATABASE_PORT: 5432
DATABASE_USER: ${DATABASE_USER:-snake}
DATABASE_PASSWORD: ${DATABASE_PASSWORD:-snake}
DATABASE_NAME: ${DATABASE_NAME:-snake_production}
RAILS_FORCE_SSL: ${RAILS_FORCE_SSL:-false}
RAILS_MAX_THREADS: ${RAILS_MAX_THREADS:-5}
WEB_CONCURRENCY: ${WEB_CONCURRENCY:-0}
# Absolute public origin used for invitation links and QR codes when the
# app is served through a proxy or Cloudflare Tunnel (e.g.
# https://snake.example.com). Empty means "use the request's own origin".
MULTIPLAYER_PUBLIC_ORIGIN: ${MULTIPLAYER_PUBLIC_ORIGIN:-}
# Comma-separated origins allowed to open the /cable WebSocket when a
# proxy rewrites Origin. Same-origin already works without this.
ACTION_CABLE_ALLOWED_ORIGINS: ${ACTION_CABLE_ALLOWED_ORIGINS:-}
depends_on:
db:
condition: service_healthy
# The entrypoint ALSO waits for Postgres; it does not rely solely on the
# compose provider supporting health-based dependency ordering.
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:3000/up"]
interval: 30s
timeout: 5s
retries: 3
start_period: 60s
db:
image: docker.io/library/postgres:16-alpine
restart: unless-stopped
networks:
- snakenet
environment:
POSTGRES_USER: ${DATABASE_USER:-snake}
POSTGRES_PASSWORD: ${DATABASE_PASSWORD:-snake}
POSTGRES_DB: ${DATABASE_NAME:-snake_production}
volumes:
# Private SELinux relabeling for this database container. Docker on
# non-SELinux hosts also accepts this suffix, so no override is required.
- pgdata:/var/lib/postgresql/data:Z
healthcheck:
test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""]
interval: 5s
timeout: 3s
retries: 12
# Official Cloudflare Tunnel connector (remotely-managed, token-based).
# DISABLED by default: it belongs to the "tunnel" Compose profile and only
# starts when .env sets COMPOSE_PROFILES=tunnel. The connector dials OUT to
# Cloudflare, so no inbound port, port-forward or firewall rule is needed.
# The token is deliberately guarded with :- (not :?) because Compose
# interpolates the whole file even for profile-disabled services; a hard
# guard here would break plain `compose up` for people not using the tunnel.
cloudflared:
image: docker.io/cloudflare/cloudflared:latest
restart: unless-stopped
profiles:
- tunnel
command: tunnel --no-autoupdate run
environment:
TUNNEL_TOKEN: ${CLOUDFLARE_TUNNEL_TOKEN:-}
# Local-only metrics endpoint; also what the health probe asks "ready?".
TUNNEL_METRICS: 127.0.0.1:20241
networks:
- snakenet
depends_on:
app:
condition: service_healthy
healthcheck:
# The cloudflared binary probes its own metrics server; the distroless
# image has no shell, curl or wget, so this is the supported check.
test: ["CMD", "cloudflared", "tunnel", "--metrics", "127.0.0.1:20241", "ready"]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
networks:
snakenet:
volumes:
pgdata: