diff --git a/.build/prod/README.md b/.build/prod/README.md new file mode 100644 index 0000000..0d72c30 --- /dev/null +++ b/.build/prod/README.md @@ -0,0 +1,79 @@ +# Production deployment + +Unified build for the SAR Link portal monorepo. One `compose.yml` builds all +services from the repo root, wiring together the two submodules: + +- **`backend/`** — Django API (gunicorn on `:5000`, static via WhiteNoise) +- **`frontend/`** — Next.js portal (standalone server on `:3000`) + +A single **nginx** container is the only published entrypoint. The browser only +ever talks to Next.js; Next.js reaches Django **server-side** over the compose +network (`SARLINK_API_BASE_URL=http://backend:5000`). nginx only exposes +Django's browser-facing surface — the admin, its static assets, and media. + +``` +host.com/ -> frontend (Next.js) # incl. its own /api/* route handlers +host.com/admin/ -> backend (Django admin) +host.com/static/ -> backend (WhiteNoise) +host.com/media/ -> nginx (shared `media` volume) +``` + +> `/api/` is **not** proxied to Django — it belongs to Next.js (NextAuth etc.). +> Django's own `/api/...` is reached only internally via `SARLINK_API_BASE_URL`. + +## Files + +| File | Purpose | +| -------------------- | --------------------------------------------------- | +| `compose.yml` | postgres + backend + frontend + nginx | +| `api.Dockerfile` | Django image (collectstatic at build) | +| `frontend.Dockerfile`| Next.js standalone image | +| `nginx.Dockerfile` | nginx + `nginx.conf` | +| `entrypoint.sh` | backend: wait for postgres, `migrate`, then gunicorn| +| `nginx.conf` | front reverse proxy | + +## Configure + +Fill each submodule's `.env` (copy from its `.env.example`); compose reads +`backend/.env` and `frontend/.env`. For the compose network set: + +**`backend/.env`** +``` +POSTGRES_HOST=database +POSTGRES_PORT=5432 +POSTGRES_DATABASE=sarlink +POSTGRES_USER=sarlink +POSTGRES_PASSWORD=changeme +ALLOWED_HOSTS=localhost,127.0.0.1,backend # + your public host +CSRF_TRUSTED_ORIGINS=https://portal.example.com +``` + +**`frontend/.env`** +``` +SARLINK_API_BASE_URL=http://backend:5000 +NEXTAUTH_URL=https://portal.example.com +NEXTAUTH_SECRET=... +``` + +The `POSTGRES_*` values also feed the `database` service (via compose defaults), +so keep them in sync — or export them in the shell before `up`. + +## Build & run + +```sh +docker compose -f .build/prod/compose.yml up -d --build +``` + +The published site is on `http://localhost:8080` (remap the `nginx` port in +`compose.yml` behind your TLS terminator). The backend runs migrations on +startup; create an admin user once with: + +```sh +docker compose -f .build/prod/compose.yml exec backend python manage.py createsuperuser +``` + +## Running from published images + +The build pushes to `git.shihaam.dev/sarlink/sarlinkportal/{backend,frontend,nginx}`. +To deploy without building, replace each service's `build:` block with its +`image:` and keep the `database`, volumes, `env_file`, and `nginx` port mapping. diff --git a/.build/prod/api.Dockerfile b/.build/prod/api.Dockerfile new file mode 100644 index 0000000..9c86f19 --- /dev/null +++ b/.build/prod/api.Dockerfile @@ -0,0 +1,25 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +# netcat is used by entrypoint.sh to wait for postgres +RUN apt-get update \ + && apt-get install -y --no-install-recommends netcat-openbsd \ + && rm -rf /var/lib/apt/lists/* + +COPY backend/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY backend/ . + +# Build the WhiteNoise manifest (staticfiles/staticfiles.json) so /static/ works. +RUN python manage.py collectstatic --noinput + +COPY .build/prod/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh"] +CMD ["gunicorn", "apibase.wsgi:application", "--bind", "0.0.0.0:5000", "--workers", "4"] diff --git a/.build/prod/compose.yml b/.build/prod/compose.yml new file mode 100644 index 0000000..215ca35 --- /dev/null +++ b/.build/prod/compose.yml @@ -0,0 +1,52 @@ +services: + database: + image: postgres:16 + hostname: database + environment: + POSTGRES_DB: ${POSTGRES_DATABASE:-sarlink} + POSTGRES_USER: ${POSTGRES_USER:-sarlink} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme} + volumes: + - pgdata:/var/lib/postgresql/data + + backend: + build: + context: ../../ + dockerfile: .build/prod/api.Dockerfile + hostname: backend + image: git.shihaam.dev/sarlink/sarlinkportal/backend + env_file: + - ../../backend/.env + volumes: + - media:/app/media + depends_on: + - database + + frontend: + build: + context: ../../ + dockerfile: .build/prod/frontend.Dockerfile + hostname: frontend + image: git.shihaam.dev/sarlink/sarlinkportal/frontend + env_file: + - ../../frontend/.env + depends_on: + - backend + + nginx: + build: + context: ../../ + dockerfile: .build/prod/nginx.Dockerfile + hostname: nginx + image: git.shihaam.dev/sarlink/sarlinkportal/nginx + ports: + - "8080:80" + volumes: + - media:/app/media:ro + depends_on: + - frontend + - backend + +volumes: + pgdata: + media: diff --git a/.build/prod/entrypoint.sh b/.build/prod/entrypoint.sh new file mode 100644 index 0000000..0e35adf --- /dev/null +++ b/.build/prod/entrypoint.sh @@ -0,0 +1,15 @@ +#!/bin/sh +set -e + +if [ -n "$POSTGRES_HOST" ]; then + echo "Waiting for postgres at $POSTGRES_HOST:${POSTGRES_PORT:-5432}..." + while ! nc -z "$POSTGRES_HOST" "${POSTGRES_PORT:-5432}"; do + sleep 0.2 + done + echo "PostgreSQL is up" +fi + +echo "Applying database migrations..." +python manage.py migrate --noinput + +exec "$@" diff --git a/.build/prod/frontend.Dockerfile b/.build/prod/frontend.Dockerfile new file mode 100644 index 0000000..a96e875 --- /dev/null +++ b/.build/prod/frontend.Dockerfile @@ -0,0 +1,27 @@ +FROM node:22-slim AS builder + +WORKDIR /var/www/html +ENV NEXT_TELEMETRY_DISABLED=1 + +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci --legacy-peer-deps + +COPY frontend/ . +RUN npm run build + +# ---- runtime ---- +# next.config.ts sets `output: "standalone"`, so we ship only the traced server. +FROM node:22-slim AS runner + +WORKDIR /var/www/html +ENV NODE_ENV=production \ + NEXT_TELEMETRY_DISABLED=1 \ + HOSTNAME=0.0.0.0 \ + PORT=3000 + +COPY --from=builder /var/www/html/public ./public +COPY --from=builder /var/www/html/.next/standalone ./ +COPY --from=builder /var/www/html/.next/static ./.next/static + +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/.build/prod/nginx.Dockerfile b/.build/prod/nginx.Dockerfile new file mode 100644 index 0000000..8ee13cb --- /dev/null +++ b/.build/prod/nginx.Dockerfile @@ -0,0 +1,3 @@ +FROM nginx:alpine + +COPY .build/prod/nginx.conf /etc/nginx/conf.d/default.conf diff --git a/.build/prod/nginx.conf b/.build/prod/nginx.conf new file mode 100644 index 0000000..d081aac --- /dev/null +++ b/.build/prod/nginx.conf @@ -0,0 +1,49 @@ +upstream frontend { server frontend:3000; } +upstream backend { server backend:5000; } + +server { + listen 80; + server_name _; + + access_log /dev/stdout; + error_log /dev/stderr; + + # Matches the frontend's serverActions bodySizeLimit (20mb). + client_max_body_size 20M; + + # --- Django admin + its static assets (served by WhiteNoise from gunicorn) --- + location /admin/ { + proxy_pass http://backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + location /static/ { + proxy_pass http://backend; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # --- Django-uploaded media (written to the shared `media` volume) --- + location /media/ { + alias /app/media/; + access_log off; + } + + # --- Next.js app (everything else, including its own /api/* route handlers) --- + # The browser only ever talks to Next.js; Next.js reaches Django server-side + # over the compose network via SARLINK_API_BASE_URL=http://backend:5000. + location / { + proxy_pass http://frontend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + # Honour the X-Accel-Buffering: no header the app sets for streamed responses. + proxy_buffering off; + } +} diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..706298f --- /dev/null +++ b/compose.yml @@ -0,0 +1,3 @@ +include: + - frontend/compose.yml + - backend/compose.yml