···497497 command: |
498498 go build -o spindle.out ./cmd/spindle
499499```
500500+501501+---
502502+503503+## Docker Compose Setup
504504+505505+### Existing Compose Files
506506+507507+The project has four compose files at the repository root:
508508+509509+| File | Purpose |
510510+|---|---|
511511+| `docker-compose.yaml` | Infrastructure only (db, redis, minio) — used as a base for all modes |
512512+| `docker-compose.pre-built.yaml` | Runs pre-built GHCR image (`ghcr.io/ohcnetwork/care:latest`) — backend, celery-worker, celery-beat |
513513+| `docker-compose.local.yaml` | Local dev with source-mounted volumes and `dev.Dockerfile` |
514514+| `docker-compose.coolify.yaml` | Coolify PaaS deployment (similar to local but with coolify networking) |
515515+516516+**Usage pattern:** `docker compose -f docker-compose.yaml -f docker-compose.pre-built.yaml up`
517517+518518+The base `docker-compose.yaml` defines:
519519+- `db` — `postgres:alpine` on port 5433 (host) → 5432 (container), healthcheck via `pg_isready`
520520+- `redis` — `redis:8-alpine` on port 6380 (host) → 6379 (container), healthcheck via `redis-cli ping`
521521+- `minio` — `minio/minio:latest` on ports 9100 (S3 API) and 9001 (console), with init scripts at `docker/minio/`
522522+- Volumes: `postgres-data`, `redis-data`
523523+- Network: `care` (named default network)
524524+525525+The pre-built overlay (`docker-compose.pre-built.yaml`) defines:
526526+- `backend` — image `ghcr.io/ohcnetwork/care:latest`, entrypoint `bash start.sh`, port 9000
527527+- `celery-worker` — same image, entrypoint `bash celery_worker.sh`
528528+- `celery-beat` — same image, entrypoint `bash celery_beat.sh`, healthcheck determines readiness for backend/worker
529529+530530+All services use `./docker/.prebuilt.env` for environment configuration.
531531+532532+### Env Files
533533+534534+| File | Purpose |
535535+|---|---|
536536+| `docker/.prebuilt.env` | Production-like env: `DJANGO_SETTINGS_MODULE=config.settings.deployment`, `DJANGO_DEBUG=False`, full DB/Redis/MinIO/S3 config |
537537+| `docker/.local.env` | Dev env: `DJANGO_DEBUG=true`, `ATTACH_DEBUGGER=false`, subset of config vars |
538538+539539+### Nix Image vs Dockerfile Image — Script Path Compatibility
540540+541541+**Critical difference:** The prod Dockerfile and the Nix image place entrypoint scripts at different paths.
542542+543543+**Prod Dockerfile (`docker/prod.Dockerfile`):**
544544+1. `COPY --chmod=0755 --chown=django:django ./scripts/*.sh $APP_HOME` — copies all `.sh` files **flat** into `/app/` (e.g., `/app/start.sh`, `/app/wait_for_db.sh`)
545545+2. `COPY --chown=django:django . $APP_HOME` — copies the full source tree to `/app/` (so scripts also exist at `/app/scripts/start.sh`)
546546+547547+Result: Scripts exist at **both** `/app/start.sh` and `/app/scripts/start.sh`. The entrypoint `bash start.sh` works because WORKDIR is `/app`, and `start.sh` internally calls `./wait_for_db.sh` which resolves to `/app/wait_for_db.sh`.
548548+549549+**Nix image (`nix/docker-image.nix`) — BEFORE fix:**
550550+1. `rsync` copies the entire source tree to `/app/` — scripts land at `/app/scripts/start.sh`
551551+2. There was **no flat copy** of scripts to `/app/` root
552552+3. The healthcheck is configured as `/app/scripts/healthcheck.sh` (absolute path, correct)
553553+554554+Without the fix, scripts existed **only** at `/app/scripts/start.sh`. The entrypoint `bash start.sh` would **fail** because `/app/start.sh` did not exist.
555555+556556+**Additionally**, the scripts themselves use relative paths internally:
557557+- `start.sh` calls `./wait_for_db.sh` and `./wait_for_redis.sh`
558558+- `celery_worker.sh` calls `./wait_for_db.sh` and `./wait_for_redis.sh`
559559+- `celery_beat.sh` calls `./wait_for_db.sh` and `./wait_for_redis.sh`
560560+561561+These resolve relative to WORKDIR (`/app`), so they need the flat copies to exist.
562562+563563+**Fix applied to `nix/docker-image.nix`:** Added a step in the `appDir` derivation that copies `scripts/*.sh` flat into `/app/` root, mirroring the prod Dockerfile's `COPY --chmod=0755 ./scripts/*.sh $APP_HOME`. The existing `chmod +x` loop for top-level `*.sh` files then makes them executable. This maintains full backward compatibility — the same `bash start.sh` entrypoint used by the GHCR pre-built compose works identically with the Nix image.
564564+565565+```nix
566566+# Copy scripts/*.sh flat into /app/ root
567567+if [ -d "$out/app/scripts" ]; then
568568+ for f in $out/app/scripts/*.sh; do
569569+ [ -f "$f" ] && cp "$f" "$out/app/$(basename "$f")"
570570+ done
571571+fi
572572+```
573573+574574+### Nix Image — Additional Env Var Differences
575575+576576+The Nix image sets several env vars that point to Nix store paths (TLS certs, fontconfig). These are baked into the image config and do not need to be provided via the env file. Key ones:
577577+- `SSL_CERT_FILE`, `NIX_SSL_CERT_FILE`, `CURL_CA_BUNDLE`, `REQUESTS_CA_BUNDLE` — point to `${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt`
578578+- `FONTCONFIG_PATH` — points to `${pkgs.fontconfig.out}/etc/fonts`
579579+- `PATH` — `/app/.venv/bin:/bin:/usr/bin:/sbin:/usr/sbin` (Nix store bins are symlinked into `/bin` by `buildLayeredImage`)
580580+581581+The existing `.prebuilt.env` is compatible — it sets app-level config (DB, Redis, S3, Django settings) that the Nix image needs identically.
582582+583583+### Nix Image User
584584+585585+The Nix image runs as `django` (UID 1000, GID 1000), same as the prod Dockerfile. The `fakeRootCommands` in the Nix expression create `/tmp/container-role` with mode 666, so the healthcheck script's `printf "api" > /tmp/container-role` works. However, `/tmp` persistence between container restarts depends on Docker's default tmpfs behavior — this should be fine since container-role is written at startup.
586586+587587+### Docker Compose for Nix-Built Image (`docker/docker-compose.nix-prebuilt.yaml`) — CREATED
588588+589589+A self-contained compose file for running CARE with the Nix-built image. Unlike the existing split (`docker-compose.yaml` + `docker-compose.pre-built.yaml`), this single file includes all services (backend, celery, db, redis, minio) so it can be run standalone.
590590+591591+**Usage:**
592592+```bash
593593+# Default (uses docker.io/ohcnetwork/care:latest)
594594+docker compose -f docker/docker-compose.nix-prebuilt.yaml up
595595+596596+# Specific tag
597597+CARE_IMAGE=docker.io/ohcnetwork/care:v25.28.0 docker compose -f docker/docker-compose.nix-prebuilt.yaml up
598598+```
599599+600600+**Key design decisions:**
601601+- **Image reference:** `${CARE_IMAGE:-docker.io/ohcnetwork/care:latest}` — defaults to Docker Hub (where the Tangled build pushes), overridable via env var
602602+- **Entrypoints:** `bash start.sh`, `bash celery_worker.sh`, `bash celery_beat.sh` — identical to the GHCR pre-built compose (works because of the flat script copy fix in `nix/docker-image.nix`)
603603+- **Env file:** Uses same `docker/.prebuilt.env` — fully compatible, no changes needed
604604+- **Dependency graph:** celery-beat depends on db+redis (`service_started`); backend and celery-worker depend on db+redis (`service_started`) AND celery-beat (`service_healthy`). celery-beat runs migrations and creates `/tmp/healthy` marker for its healthcheck
605605+- **Volume paths:** Relative to `docker/` directory (where the compose file lives), so minio scripts reference `./minio/init-script.sh` and backups default to `../care-backups`
606606+- **Network:** Named `care` network shared by all services
607607+- **Ports:** Same as existing setup — 9000 (backend), 5433→5432 (postgres), 6380→6379 (redis), 9100→9000 (minio S3), 9001 (minio console)
+124
docker/docker-compose.nix-prebuilt.yaml
···11+# Docker Compose for Nix-built CARE image
22+#
33+# This is a self-contained compose file for running the CARE application
44+# using the Nix-built OCI image produced by nix/docker-image.nix and
55+# pushed via .tangled/workflows/build.yml.
66+#
77+# Usage:
88+# docker compose -f docker/docker-compose.nix-prebuilt.yaml up
99+#
1010+# To use a specific image tag:
1111+# CARE_IMAGE=docker.io/tellmey/care:v25.28.0 docker compose -f docker/docker-compose.nix-prebuilt.yaml up
1212+#
1313+# The Nix image is functionally equivalent to the prod Dockerfile image
1414+# (docker/prod.Dockerfile) but built with Nix dockerTools.buildLayeredImage.
1515+# Key differences:
1616+# - TLS certs, fontconfig, and runtime libs come from the Nix store
1717+# (baked into the image ENV — no host-level config needed)
1818+# - The image runs as user "django" (UID 1000) by default
1919+# - Scripts exist at both /app/scripts/*.sh and /app/*.sh (flat copy)
2020+# so entrypoints like "bash start.sh" work identically to the GHCR image
2121+2222+services:
2323+ backend:
2424+ image: "${CARE_IMAGE:-docker.io/tellmey/care:latest}"
2525+ env_file:
2626+ - ./.prebuilt.env
2727+ entrypoint: ["bash", "start.sh"]
2828+ restart: unless-stopped
2929+ depends_on:
3030+ db:
3131+ condition: service_started
3232+ redis:
3333+ condition: service_started
3434+ celery-beat:
3535+ condition: service_healthy
3636+ ports:
3737+ - "9000:9000"
3838+3939+ celery-worker:
4040+ image: "${CARE_IMAGE:-docker.io/tellmey/care:latest}"
4141+ env_file:
4242+ - ./.prebuilt.env
4343+ entrypoint: ["bash", "celery_worker.sh"]
4444+ restart: unless-stopped
4545+ depends_on:
4646+ db:
4747+ condition: service_started
4848+ redis:
4949+ condition: service_started
5050+ celery-beat:
5151+ condition: service_healthy
5252+5353+ celery-beat:
5454+ image: "${CARE_IMAGE:-docker.io/tellmey/care:latest}"
5555+ env_file:
5656+ - ./.prebuilt.env
5757+ entrypoint: ["bash", "celery_beat.sh"]
5858+ restart: unless-stopped
5959+ depends_on:
6060+ db:
6161+ condition: service_started
6262+ redis:
6363+ condition: service_started
6464+6565+ db:
6666+ image: postgres:alpine
6767+ restart: unless-stopped
6868+ env_file:
6969+ - ./.prebuilt.env
7070+ volumes:
7171+ - postgres-data:/var/lib/postgresql/data
7272+ - ${BACKUP_DIR:-../care-backups}:/backups
7373+ ports:
7474+ - "5433:5432"
7575+ healthcheck:
7676+ test: ["CMD", "pg_isready", "-U", "${POSTGRES_USER:-postgres}"]
7777+ interval: 10s
7878+ retries: 5
7979+ start_period: 10s
8080+ timeout: 10s
8181+8282+ redis:
8383+ image: redis:8-alpine
8484+ restart: unless-stopped
8585+ volumes:
8686+ - redis-data:/data
8787+ ports:
8888+ - "6380:6379"
8989+ healthcheck:
9090+ test: ["CMD", "redis-cli", "ping"]
9191+ interval: 10s
9292+ retries: 5
9393+ start_period: 10s
9494+ timeout: 10s
9595+9696+ minio:
9797+ image: minio/minio:latest
9898+ restart: unless-stopped
9999+ environment:
100100+ MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-minioadmin}
101101+ MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:-minioadmin}
102102+ AWS_DEFAULT_REGION: ap-south-1
103103+ volumes:
104104+ - "../care/media/minio:/data"
105105+ - "./minio/init-script.sh:/init-script.sh:ro"
106106+ - "./minio/entrypoint.sh:/entrypoint.sh:ro"
107107+ ports:
108108+ - "9100:9000"
109109+ - "9001:9001"
110110+ entrypoint: ["/entrypoint.sh"]
111111+ healthcheck:
112112+ test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/ready"]
113113+ interval: 10s
114114+ retries: 5
115115+ start_period: 10s
116116+ timeout: 10s
117117+118118+volumes:
119119+ postgres-data:
120120+ redis-data:
121121+122122+networks:
123123+ default:
124124+ name: care
+11
nix/docker-image.nix
···109109 chmod -R +x $out/app/scripts/
110110 fi
111111112112+ # Copy scripts/*.sh flat into /app/ root — mirrors the prod Dockerfile's
113113+ # COPY --chmod=0755 --chown=django:django ./scripts/*.sh $APP_HOME
114114+ # which places them at /app/start.sh, /app/wait_for_db.sh, etc.
115115+ # The entrypoint scripts use relative calls (./wait_for_db.sh) that
116116+ # resolve against WORKDIR (/app), so the flat copies must exist.
117117+ if [ -d "$out/app/scripts" ]; then
118118+ for f in $out/app/scripts/*.sh; do
119119+ [ -f "$f" ] && cp "$f" "$out/app/$(basename "$f")"
120120+ done
121121+ fi
122122+112123 # Make top-level shell scripts executable (healthcheck.sh etc. are
113124 # copied to /app in the Dockerfile)
114125 for f in $out/app/*.sh; do