services: jobd: # Pulled from GHCR, NOT built here. There used to be `build: .` + `image: # jobd:latest` with no registry behind that tag, which meant `docker compose pull` # was a silent no-op and a bare `docker compose up -d` after a `git pull` happily # reused the stale local image — you restarted the OLD code and nothing said so. # The workaround was to remember `--build` forever. A registry-backed, version- # pinned tag deletes that whole class of mistake instead of guarding against it. # # JOBD_TAG pins the EXACT version (e.g. 0.5.18). Production should never follow # `latest`: scripts/deploy-broker.sh resolves the newest release, writes JOBD_TAG # into .env, and can roll back to the previous pin if the new image fails its # health gate — none of which is possible against a moving tag. The `latest` # default here exists only so a fresh clone can start without ceremony. # # Building from source (a fork, or a local change): # docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build image: ghcr.io/musharna/jobd:${JOBD_TAG:-latest} container_name: jobd restart: unless-stopped # PERMISSIONS: the image runs as a non-root user (uid 10001, see Dockerfile). # The bind-mounted ./data + ./logs below must be WRITABLE by that user, or the # broker crash-loops on "sqlite3.OperationalError: attempt to write a readonly # database". Two fixes — pick one: # (a) chown the dirs to the image user: chown -R 10001:10001 ./data ./logs # (b) run the container as your host user so the files stay host-owned (nicer # for backups/log-tailing): uncomment the line below, replacing 1000:1000 # with your own `id -u`:`id -g`. # user: "1000:1000" # SECURITY: host networking + JOBD_HOST binding is the access-control model. # Docker's default userland-proxy NATs all cross-host inbound source IPs to # the bridge gateway, which defeats the source-IP ACL. With network_mode: # host, uvicorn binds DIRECTLY to JOBD_HOST (a tailscale IP) on the host's # network namespace — only requests arriving on the tailscale interface # reach the broker, and source IPs are preserved for the ACL to inspect. # JOBD_HOST MUST be a loopback (127.0.0.1) or tailscale CGNAT (100.64.0.0/10) # address — never 0.0.0.0 or a public-facing interface. tests/test_deploy_lint.py # enforces this at CI. network_mode: host volumes: - ./config:/app/config:ro - ./data:/app/data - ./logs:/app/logs environment: JOBD_CONFIG_DIR: /app/config JOBD_DB_URL: sqlite:////app/data/jobd.db JOBD_LOGS_DIR: /app/logs # Set to your host's tailscale CGNAT IP (100.64.0.0/10) for cross-host # access, or leave at 127.0.0.1 for a single-host (loopback-only) broker. # MUST NOT be 0.0.0.0 or a public interface — see the SECURITY note above. JOBD_HOST: ${JOBD_HOST:-127.0.0.1} JOBD_PORT: 8765 JOBD_API_TOKEN: ${JOBD_API_TOKEN:?JOBD_API_TOKEN must be set in .env (see docs/security.md)}