# Mob, installed. Four containers on one machine, all of them pulled — there is
# no build step here and no source checkout is required, so a Linux VM with
# Docker on it needs nothing else installed.
#
#   cp .env.example .env      # then edit it; every value is explained there
#   docker compose up -d
#   docker compose logs -f web
#
# The two names in .env must already point at this machine before the first
# start: Caddy asks Let's Encrypt for a certificate for each of them as it
# boots, and that check runs against public DNS.
#
# This is a different file from the compose.yaml in the source tree, which is
# development infrastructure and is not meant to be deployed. See
# docs/operator/30-deploying.md for the Kamal path, which is what the project
# itself deploys with.
#
# WORKING ON MOB: that other file declares this same project name, so bringing
# this stack up on a machine already running it would not start a second stack —
# Compose would take the running containers for this project's own and converge
# them onto these definitions, which is to say tear your development environment
# down. Test this one under a name of its own:
#
#   docker compose -p mob-install up -d
#
# On the machine this is actually for, where nothing else is running, the name
# below is the right one and needs no thought.

name: mob

# Every service logs to stdout and Docker's json-file driver keeps every byte
# for the life of the container unless told otherwise. On a machine whose
# database and uploads share one disk, an uncapped log is the slow route to
# the one failure that takes both at once — the full disk (see
# docs/operator/50-backups.md, "The floor, regardless"). Capped here once and
# applied per service; the ceiling is per container, so the whole stack's
# logs stay bounded at a few hundred megabytes.
x-logging: &logging
  driver: json-file
  options:
    max-size: "10m"
    max-file: "5"

services:
  # TLS and the front door. Both public names are terminated here and proxied
  # onward in plain HTTP over compose's private network, which is why nothing
  # below publishes a port of its own.
  #
  # Certificates live in the caddy-data volume. Keep it: Let's Encrypt rate
  # limits issuance per name per week, so a volume thrown away a few times in
  # one afternoon leaves the site without a certificate and nothing to do but
  # wait.
  caddy:
    image: caddy:2-alpine
    restart: unless-stopped
    logging: *logging
    ports:
      - "80:80"   # required for the certificate challenge, not only redirects
      - "443:443"
      - "443:443/udp" # HTTP/3
    environment:
      APP_HOST: ${APP_HOST:?set APP_HOST in .env — the name people will type}
      MOB_STORAGE_PUBLIC_HOST: ${MOB_STORAGE_PUBLIC_HOST:?set MOB_STORAGE_PUBLIC_HOST in .env — the name attachments are served from}
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy-data:/data
      - caddy-config:/config
    depends_on:
      - web
      - minio

    # The app has to reach blob storage under its *public* name from inside
    # this network, and this alias is what makes that name resolve here.
    #
    # Active Storage signs browser URLs with STORAGE_PUBLIC_ENDPOINT, but it
    # also *reads* through it server-side: generating a thumbnail downloads the
    # original and uploads the variant. Left to the internet, that request goes
    # out to your public address and back — which many routers will not do
    # (NAT hairpin), and the failure is not an error anybody can see. The S3
    # client polls through a waiter, so each thumbnail holds a request thread
    # for the whole timeout until Puma has none left, and the site serves 504s
    # from a machine sitting at no load.
    #
    # Resolving the name to Caddy keeps it inside compose: the same request a
    # browser makes, against the certificate Caddy already holds for it, over
    # this network instead of through your router.
    networks:
      default:
        aliases:
          - ${MOB_STORAGE_PUBLIC_HOST}

  # The app. Runs database migrations and creates the uploads bucket on boot
  # (bin/docker-entrypoint), so a first start needs no setup command — bringing
  # the stack up is the whole installation.
  web:
    image: ghcr.io/wibbly/mob:${MOB_VERSION:-latest}
    restart: unless-stopped
    logging: *logging
    depends_on:
      postgres:
        condition: service_healthy
      minio:
        condition: service_started

    # /up is the app's own health endpoint, answered only once Rails is
    # actually serving. Docker never restarts an unhealthy container on its
    # own — restart: unless-stopped acts on exit, not on hang — so what this
    # buys is honesty: `docker compose ps` says unhealthy instead of up, and
    # anything that watches container health has a signal to act on. The
    # monitor that actually wakes somebody has to live off this machine; see
    # docs/operator/50-backups.md, "Watching it". start_period covers the
    # migrations the entrypoint runs before Puma listens.
    healthcheck:
      test: [ "CMD", "curl", "-fsS", "http://localhost/up" ]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 90s

    # Everything in .env reaches the app, which is deliberately the opposite of
    # the Kamal path: there, config/deploy.yml must name a variable or the
    # container never sees it, and a correctly-set variable going nowhere is a
    # documented trap (ARCHITECTURE.md, "Nothing reaches the container that
    # deploy.yml does not name"). Here, turning on SSO or backups or push
    # notifications means editing .env and nothing else.
    env_file: .env

    # These are settled by the shape of this stack rather than by the operator,
    # so they are fixed here and override anything .env happens to say.
    environment:
      RAILS_ENV: production

      # Container-internal addresses. The app reaches Postgres and the blob
      # store across compose's network, under their service names.
      MOB_DATABASE_HOST: postgres
      MOB_DATABASE_USERNAME: postgres
      STORAGE_ENDPOINT: http://minio:9000

      # ...and the two browser-facing ones, derived so they cannot disagree
      # with what Caddy holds a certificate for. APP_ORIGIN is checked at every
      # WebSocket handshake, and a mismatch presents as "live updates are
      # broken" rather than as a configuration error.
      APP_ORIGIN: https://${APP_HOST}
      STORAGE_PUBLIC_ENDPOINT: https://${MOB_STORAGE_PUBLIC_HOST}

      STORAGE_BUCKET: ${STORAGE_BUCKET:-mob-uploads}

      # Jobs run as threads inside Puma unless the jobs container below is
      # brought up, in which case this must be emptied — see .env.example.
      #
      # `-` and not `:-`: the difference is whether an explicitly empty value
      # counts. With `:-` it does not, so `MOB_JOBS_IN_PUMA=` in .env would fall
      # back to 1, and following the instruction to empty it would leave a
      # supervisor here *and* one in the jobs container, both draining the same
      # queue. That is the failure the single MOB_JOBS_HOST switch exists to
      # make impossible on the Kamal side.
      SOLID_QUEUE_IN_PUMA: ${MOB_JOBS_IN_PUMA-1}

  postgres:
    image: postgres:18
    restart: unless-stopped
    logging: *logging
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: ${MOB_DATABASE_PASSWORD:?set MOB_DATABASE_PASSWORD in .env}
      # Pinned because the Postgres 18 image moved its default PGDATA; without
      # this the cluster can land outside the volume and vanish on recreate.
      PGDATA: /var/lib/postgresql/data/pgdata
    volumes:
      - postgres-data:/var/lib/postgresql/data
    # Deliberately no published port: the database is reachable from the other
    # containers and from nowhere else. `docker compose exec postgres psql -U
    # postgres` is how you get a prompt.
    healthcheck:
      test: [ "CMD-SHELL", "pg_isready -U postgres" ]
      interval: 3s
      timeout: 3s
      retries: 20

  # Silo, a maintained community fork of MinIO — MinIO Inc. archived the
  # community edition in February 2026 and its last image will never take
  # another security fix. The fork keeps the S3 API, the MINIO_* variables and
  # the on-disk format. Pinned by digest, because a fork's `latest` is exactly
  # the tag you want to take deliberately.
  #
  # The binary is `silo` rather than `minio`, so the command starts with
  # `server` and the image's entrypoint translates it.
  minio:
    image: pgsty/silo:RELEASE.2026-08-06T00-00-00Z@sha256:29a498b24669cae1fed11c1a2fb2b3d73c68829a0a9c0b14e71b386671d38fac
    restart: unless-stopped
    logging: *logging
    command: server /data --console-address ":9001"
    # The store's own liveness route, probed with the curl the image ships.
    # Same purpose as web's check: visibility in `docker compose ps`, not
    # auto-repair. Deliberately not a depends_on condition for web — the app
    # already boots politely without storage (storage:prepare retries, then
    # warns), and gating the whole site on the store's health would take
    # everything down to protect attachments alone.
    healthcheck:
      test: [ "CMD", "curl", "-fsS", "http://localhost:9000/minio/health/live" ]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 15s
    environment:
      # The root credential *is* the S3 key the app signs with.
      MINIO_ROOT_USER: ${STORAGE_ACCESS_KEY:?set STORAGE_ACCESS_KEY in .env}
      MINIO_ROOT_PASSWORD: ${STORAGE_SECRET_KEY:?set STORAGE_SECRET_KEY in .env}
      # Uploads go straight from the browser to here, which is a cross-origin
      # PUT — so the page's origin has to be admitted by name. Never "*": that
      # would let any site on the internet upload with a URL it managed to
      # obtain.
      MINIO_API_CORS_ALLOW_ORIGIN: https://${APP_HOST}
    volumes:
      - blob-data:/data
    # Also no published port. The browser reaches this through Caddy on the
    # storage name; the built-in console on 9001 is not exposed at all, and
    # wanting it is a sign you want `docker compose exec` instead.

  # The four-container topology, for when background work outgrows the threads
  # inside Puma — large private channels or heavy notification volume are the
  # signs. Turning it on takes two lines in .env that must agree, and they are
  # documented together there: COMPOSE_PROFILES=jobs, and an empty
  # MOB_JOBS_IN_PUMA so that two supervisors do not drain one queue.
  jobs:
    profiles: [ jobs ]
    image: ghcr.io/wibbly/mob:${MOB_VERSION:-latest}
    restart: unless-stopped
    logging: *logging
    command: bin/jobs
    depends_on:
      postgres:
        condition: service_healthy
    env_file: .env
    environment:
      RAILS_ENV: production
      MOB_DATABASE_HOST: postgres
      MOB_DATABASE_USERNAME: postgres
      STORAGE_ENDPOINT: http://minio:9000
      APP_ORIGIN: https://${APP_HOST}
      STORAGE_PUBLIC_ENDPOINT: https://${MOB_STORAGE_PUBLIC_HOST}
      STORAGE_BUCKET: ${STORAGE_BUCKET:-mob-uploads}

volumes:
  postgres-data:
  blob-data:
  caddy-data:
  caddy-config:
