diff --git a/TODO.md b/TODO.md index 4ba514d..f0538fd 100644 --- a/TODO.md +++ b/TODO.md @@ -2,6 +2,6 @@ ## Infrastructure -- [ ] **Rootless containers** — run backend and frontend containers as non-root users (add `USER` directive to Dockerfiles, map UID/GID appropriately) +- [x] **Rootless containers** — run backend and frontend containers as non-root users (add `USER` directive to Dockerfiles, map UID/GID appropriately) - [ ] **Persistent storage** — ensure database data, config files, and any uploaded assets survive container restarts and rebuilds (named volumes, bind mounts for config) - [ ] **Docker development workflow** — document and streamline the full dev loop: hot reload, one-command startup, migration handling, seed data, and how to attach a debugger diff --git a/backend/Dockerfile b/backend/Dockerfile index e08b72c..5e37208 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -11,15 +11,21 @@ RUN pip install --prefix=/install . # ── Stage 2: runtime ────────────────────────────────────────────────────────── FROM python:3.12-slim +# Create non-root user (UID/GID 1001) +RUN groupadd --gid 1001 appuser && \ + useradd --uid 1001 --gid 1001 --no-create-home --shell /bin/sh appuser + WORKDIR /app # Copy installed packages from builder COPY --from=builder /install /usr/local -# Copy application source -COPY app ./app -COPY alembic ./alembic -COPY alembic.ini . +# Copy application source with correct ownership +COPY --chown=appuser:appuser app ./app +COPY --chown=appuser:appuser alembic ./alembic +COPY --chown=appuser:appuser alembic.ini . + +USER appuser EXPOSE 8000 diff --git a/changelog/2026-04-13_rootless-containers.md b/changelog/2026-04-13_rootless-containers.md new file mode 100644 index 0000000..27c7e67 --- /dev/null +++ b/changelog/2026-04-13_rootless-containers.md @@ -0,0 +1,25 @@ +# 2026-04-13 — Rootless containers + +**Timestamp:** 2026-04-13T00:00:00 + +## Summary + +All containers now run as non-root users with explicit UID:GID assignments enforced in both Dockerfiles and docker-compose files. + +## User mapping + +| Service | User | UID:GID | Notes | +|---|---|---|---| +| `db` | `postgres` | `70:70` | Fixed by `postgres:16-alpine`; image owns PGDATA as 70:70 so named-volume seeding works | +| `backend` | `appuser` | `1001:1001` | Created via `useradd` in runtime stage | +| `frontend` (prod) | `nginx` | `101:101` | Switched to `nginxinc/nginx-unprivileged:alpine`; listens on 8080 | +| `frontend` (dev) | `appuser` | `1001:1001` | Created via `adduser` in builder stage | + +## Files Modified + +- `backend/Dockerfile` — added `groupadd`/`useradd` for appuser (1001:1001), `--chown` on all `COPY` directives, `USER appuser` +- `frontend/Dockerfile` — builder stage: added `addgroup`/`adduser` for appuser (1001:1001), `USER appuser`; prod stage: switched to `nginxinc/nginx-unprivileged:alpine`, `EXPOSE 8080` +- `frontend/nginx.conf` — changed `listen 80` → `listen 8080` to match unprivileged image default +- `docker-compose.yml` — added `user: "70:70"` to `db`, `user: "1001:1001"` to `backend`, `user: "101:101"` to `frontend`; updated frontend port mapping to `"80:8080"` +- `docker-compose.dev.yml` — added `user: "1001:1001"` to `backend` and `frontend` overrides +- `TODO.md` — marked rootless containers item as completed diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 66a3052..694d741 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -4,6 +4,7 @@ services: backend: + user: "1001:1001" command: sh scripts/start_dev.sh volumes: - ./backend:/app @@ -12,6 +13,7 @@ services: build: context: ./frontend target: builder # stop at the Node stage, skip nginx + user: "1001:1001" command: npm run dev -- --host 0.0.0.0 ports: - "5173:5173" diff --git a/docker-compose.yml b/docker-compose.yml index 23f08c5..8238a56 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,6 +3,7 @@ services: # ── Database ──────────────────────────────────────────────────────────────── db: image: postgres:16-alpine + user: "70:70" # postgres user UID:GID in alpine image (fixed by image) restart: unless-stopped environment: POSTGRES_USER: ${POSTGRES_USER:-postgres} @@ -24,6 +25,7 @@ services: context: ./backend dockerfile: Dockerfile network: host + user: "1001:1001" restart: unless-stopped env_file: ./backend/.env environment: @@ -40,9 +42,10 @@ services: context: ./frontend dockerfile: Dockerfile network: host + user: "101:101" # nginx user UID:GID in nginx-unprivileged:alpine restart: unless-stopped ports: - - "80:80" + - "80:8080" depends_on: - backend diff --git a/frontend/Dockerfile b/frontend/Dockerfile index ef90a6a..298b265 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,18 +1,24 @@ # ── Stage 1: build ──────────────────────────────────────────────────────────── FROM node:20-alpine AS builder -WORKDIR /app +# Create non-root user (UID/GID 1001) +RUN addgroup -g 1001 appuser && adduser -u 1001 -G appuser -s /bin/sh -D appuser -COPY package.json package-lock.json* ./ +WORKDIR /app +RUN chown appuser:appuser /app + +USER appuser + +COPY --chown=appuser:appuser package.json package-lock.json* ./ RUN npm ci -COPY . . +COPY --chown=appuser:appuser . . RUN npm run build -# ── Stage 2: serve with nginx ───────────────────────────────────────────────── -FROM nginx:alpine +# ── Stage 2: serve with nginx (unprivileged, UID 101) ───────────────────────── +FROM nginxinc/nginx-unprivileged:alpine COPY --from=builder /app/dist /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf -EXPOSE 80 +EXPOSE 8080 diff --git a/frontend/nginx.conf b/frontend/nginx.conf index efa582f..4cbb775 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -1,5 +1,5 @@ server { - listen 80; + listen 8080; root /usr/share/nginx/html; index index.html;