diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..f6d590a --- /dev/null +++ b/.env.production.example @@ -0,0 +1,31 @@ +# Production deployments intentionally have no working secret defaults. +# Copy this file to .env.production, replace every CHANGE-ME value, keep the +# result out of version control, and pin a released OSApplyTrack version. +OSAPPLYTRACK_VERSION=1.12.0 +POSTGRES_USER=applytrack +POSTGRES_PASSWORD=CHANGE-ME-use-a-long-random-password +POSTGRES_DB=applytrack + +# Public HTTPS origin and hostname served by the TLS reverse proxy. +APP_PUBLIC_BASE_URL=https://apply.example.com +ALLOWED_HOSTS=apply.example.com +API_PORT=8080 + +# Required only if tenants may store their own LLM API keys. Generate an +# independent random value; do not reuse POSTGRES_PASSWORD. +# APPLYTRACK_SECRETS_KEY=CHANGE-ME-use-an-independent-long-random-key + +# Configure the trusted proxy only when it reaches the container from a +# non-loopback address. Never trust 0.0.0.0/0 or ::/0. +# FORWARDED_HEADERS_KNOWN_PROXY=172.17.0.1 +# FORWARDED_HEADERS_KNOWN_NETWORK=172.18.0.0/16 + +# Optional SMTP and LLM settings use the same variables as .env.example. +# Email__Host=smtp.example.com +# Email__Port=587 +# Email__Username=apply +# Email__Password=CHANGE-ME +# Email__From=apply@example.com +# Llm__BaseUrl=https://llm.example.com/v1 +# Llm__Model=your-model +# Llm__ApiKey=CHANGE-ME diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c4a4b75..ae5e312 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -104,8 +104,17 @@ jobs: ## Run it - `docker compose up` brings up Postgres + API + poller β€” see the - [README](https://github.com/CryptoJones/OSApplyTrack#quickstart-docker). + Development: `docker compose up`. + + Production: copy `.env.production.example`, set its required values, + then run: + + ```bash + docker compose --env-file .env.production \ + -f docker-compose.production.yml up -d + ``` + + See the [README](https://github.com/CryptoJones/OSApplyTrack#quickstart-docker). Proudly Made in Nebraska. Go Big Red! 🌽 https://xkcd.com/2347/ run: gh release create "$TAG" --title "$TAG" --verify-tag --notes "$NOTES" diff --git a/.gitignore b/.gitignore index f6c261b..a28779c 100644 --- a/.gitignore +++ b/.gitignore @@ -27,7 +27,9 @@ test-results/ # Local secrets / env .env *.env +.env.* !.env.example +!.env.production.example # Local agent runtime state .claude/ diff --git a/BACKLOG.md b/BACKLOG.md index 46f64a0..7b439c1 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -14,7 +14,7 @@ or `SPRINTS.md` are not committed backlog until they have a corresponding issue. ## Operations and scalability -- [ ] [#53 β€” Add hardened production container defaults](https://github.com/CryptoJones/OSApplyTrack/issues/53) +- [x] [#53 β€” Add hardened production container defaults](https://github.com/CryptoJones/OSApplyTrack/issues/53) - [ ] [#54 β€” Paginate or delta-refresh the applications list](https://github.com/CryptoJones/OSApplyTrack/issues/54) Proudly Made in Nebraska. Go Big Red! 🌽 https://xkcd.com/2347/ diff --git a/Dockerfile.poller b/Dockerfile.poller index 07c886f..ab3ee6d 100644 --- a/Dockerfile.poller +++ b/Dockerfile.poller @@ -12,6 +12,10 @@ COPY src ./src RUN pip install --no-cache-dir . COPY docker/poller-entrypoint.sh /usr/local/bin/poller-entrypoint.sh -RUN chmod +x /usr/local/bin/poller-entrypoint.sh +RUN chmod +x /usr/local/bin/poller-entrypoint.sh \ + && groupadd --gid 10001 applytrack \ + && useradd --uid 10001 --gid applytrack --no-create-home \ + --home-dir /nonexistent --shell /usr/sbin/nologin applytrack +USER applytrack ENTRYPOINT ["/usr/local/bin/poller-entrypoint.sh"] diff --git a/README.md b/README.md index a8bfaf8..b9ca2d0 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,33 @@ the link instead, set the `Email__*` variables (see [Configuration](#configurati > all three; if you only bring up `db` + `api`, no leads will ever be discovered > because nothing drains the queue or runs the scheduled poll. +### Production containers + +Use the separate hardened stack for self-hosting. It consumes versioned release +images, keeps Postgres on an internal Docker network with no host port, and binds +Kestrel to host loopback for a same-host TLS reverse proxy: + +```sh +cp .env.production.example .env.production +# Replace every CHANGE-ME value and set your real HTTPS origin/hostname. +docker compose --env-file .env.production \ + -f docker-compose.production.yml up -d +``` + +Production startup deliberately fails if `OSAPPLYTRACK_VERSION`, +`POSTGRES_PASSWORD`, `APP_PUBLIC_BASE_URL`, or `ALLOWED_HOSTS` is missing. Pin +`OSAPPLYTRACK_VERSION` to a released version rather than `latest`; generate a +unique database password, and generate an independent `APPLYTRACK_SECRETS_KEY` +if tenants may store LLM API keys. Keep `.env.production` out of source control. +`openssl rand -hex 32` produces a connection-string-safe value for either secret. + +The API and poller images run as unprivileged users. In the production stack they +also have read-only root filesystems, all Linux capabilities dropped, +`no-new-privileges`, and only a bounded in-memory `/tmp`; neither runtime receives +a host or named writable volume. Postgres alone owns the persistent `pgdata` +volume. Front `127.0.0.1:${API_PORT:-8080}` with Caddy, nginx, or another +TLS-terminating reverse proxyβ€”do not expose Kestrel or the database directly. + ## How it works **Sign-in (magic link).** `POST /api/auth/request` always returns `200 {ok:true}` @@ -397,7 +424,8 @@ OSApplyTrack is built to face the public internet behind a reverse proxy: - **Change the default password.** For any deployment reachable beyond `localhost`, change `POSTGRES_PASSWORD` (and the matching connection string) from the bundled development default before first boot β€” the documented value is not a - production secret. + production secret. The hardened `docker-compose.production.yml` has no password + default and refuses to start until one is supplied. - **Dependency CVE watch.** [`.forgejo/workflows/audit.yml`](./.forgejo/workflows/audit.yml) runs `dotnet list package --vulnerable --include-transitive` and `pip-audit` on every push/PR and weekly, failing the build on a known-vulnerable dependency. Run @@ -520,6 +548,7 @@ api/ the .NET solution src/applytrack/ the Python poller + CLI docker/ poller entrypoint (two-cadence loop) docker-compose.yml db + api + poller +docker-compose.production.yml hardened self-hosting stack Dockerfile.poller the poller image ``` diff --git a/api/ApplyTrack.Api/ApplyTrack.Api.csproj b/api/ApplyTrack.Api/ApplyTrack.Api.csproj index c4406ca..d9c498c 100644 --- a/api/ApplyTrack.Api/ApplyTrack.Api.csproj +++ b/api/ApplyTrack.Api/ApplyTrack.Api.csproj @@ -5,7 +5,7 @@ enable enable ApplyTrack.Api - 1.11.5 + 1.12.0 Aaron K. Clark Copyright 2026 Aaron K. Clark Apache-2.0 diff --git a/api/ApplyTrack.Api/Dockerfile b/api/ApplyTrack.Api/Dockerfile index 214f06f..2c6e9b8 100644 --- a/api/ApplyTrack.Api/Dockerfile +++ b/api/ApplyTrack.Api/Dockerfile @@ -12,4 +12,5 @@ FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime WORKDIR /app COPY --from=build /app . EXPOSE 8080 +USER app ENTRYPOINT ["dotnet", "ApplyTrack.Api.dll"] diff --git a/docker-compose.production.yml b/docker-compose.production.yml new file mode 100644 index 0000000..df3963b --- /dev/null +++ b/docker-compose.production.yml @@ -0,0 +1,94 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Aaron K. Clark +# +# Hardened self-hosting stack. Copy .env.production.example to .env.production, +# replace every required value, then run: +# docker compose --env-file .env.production -f docker-compose.production.yml up -d +services: + db: + image: postgres:17-alpine + environment: + POSTGRES_USER: ${POSTGRES_USER:-applytrack} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env.production} + POSTGRES_DB: ${POSTGRES_DB:-applytrack} + volumes: + - pgdata:/var/lib/postgresql/data + networks: + - database + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-applytrack}"] + interval: 5s + timeout: 5s + retries: 10 + restart: unless-stopped + + api: + image: ghcr.io/cryptojones/osapplytrack-api:${OSAPPLYTRACK_VERSION:?Set OSAPPLYTRACK_VERSION to a released version} + environment: + ConnectionStrings__Postgres: "Host=db;Port=5432;Database=${POSTGRES_DB:-applytrack};Username=${POSTGRES_USER:-applytrack};Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env.production}" + ASPNETCORE_URLS: "http://+:8080" + App__PublicBaseUrl: ${APP_PUBLIC_BASE_URL:?Set APP_PUBLIC_BASE_URL to the public HTTPS origin} + AllowedHosts: ${ALLOWED_HOSTS:?Set ALLOWED_HOSTS to the public hostname} + Llm__BaseUrl: ${Llm__BaseUrl:-} + Llm__Model: ${Llm__Model:-} + Llm__ApiKey: ${Llm__ApiKey:-} + APPLYTRACK_SECRETS_KEY: ${APPLYTRACK_SECRETS_KEY:-} + Email__Host: ${Email__Host:-} + Email__Port: ${Email__Port:-587} + Email__Username: ${Email__Username:-} + Email__Password: ${Email__Password:-} + Email__From: ${Email__From:-} + Email__FromName: ${Email__FromName:-OSApplyTrack} + ForwardedHeaders__KnownProxies__0: ${FORWARDED_HEADERS_KNOWN_PROXY:-} + ForwardedHeaders__KnownNetworks__0: ${FORWARDED_HEADERS_KNOWN_NETWORK:-} + # Keep Kestrel off the public interface. Terminate TLS with a same-host reverse + # proxy and forward it to this loopback port. + ports: + - "127.0.0.1:${API_PORT:-8080}:8080" + networks: + - database + - egress + depends_on: + db: + condition: service_healthy + user: "1654:1654" + read_only: true + tmpfs: + - /tmp:rw,noexec,nosuid,size=64m + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + init: true + restart: unless-stopped + + poller: + image: ghcr.io/cryptojones/osapplytrack-poller:${OSAPPLYTRACK_VERSION:?Set OSAPPLYTRACK_VERSION to a released version} + environment: + DATABASE_URL: "postgresql://${POSTGRES_USER:-applytrack}:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env.production}@db:5432/${POSTGRES_DB:-applytrack}" + DRAIN_INTERVAL: ${DRAIN_INTERVAL:-60} + POLL_INTERVAL: ${POLL_INTERVAL:-3600} + networks: + - database + - egress + depends_on: + db: + condition: service_healthy + user: "10001:10001" + read_only: true + tmpfs: + - /tmp:rw,noexec,nosuid,size=64m + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + init: true + restart: unless-stopped + +networks: + database: + internal: true + egress: + +volumes: + pgdata: diff --git a/pyproject.toml b/pyproject.toml index a4e9f2f..b82f43a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "applytrack-poller" -version = "1.11.5" +version = "1.12.0" description = "Discovery poller for OSApplyTrack β€” fetches and scores remote job leads into shared Postgres." requires-python = ">=3.10" license = { text = "Apache-2.0" } diff --git a/tests/test_containers.py b/tests/test_containers.py new file mode 100644 index 0000000..dc33223 --- /dev/null +++ b/tests/test_containers.py @@ -0,0 +1,39 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Aaron K. Clark +"""Regression checks for the hardened production container contract.""" + +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[1] + + +def test_production_database_is_not_published() -> None: + compose = yaml.safe_load((ROOT / "docker-compose.production.yml").read_text()) + + assert "ports" not in compose["services"]["db"] + assert compose["networks"]["database"]["internal"] is True + assert compose["services"]["db"]["networks"] == ["database"] + + +def test_production_runtimes_drop_privileges_and_write_only_to_tmpfs() -> None: + compose = yaml.safe_load((ROOT / "docker-compose.production.yml").read_text()) + + expected_users = {"api": "1654:1654", "poller": "10001:10001"} + for name, user in expected_users.items(): + service = compose["services"][name] + assert service["user"] == user + assert service["read_only"] is True + assert service["cap_drop"] == ["ALL"] + assert service["security_opt"] == ["no-new-privileges:true"] + assert service["tmpfs"] == ["/tmp:rw,noexec,nosuid,size=64m"] + assert "volumes" not in service + + +def test_runtime_images_select_non_root_users() -> None: + api_runtime = (ROOT / "api/ApplyTrack.Api/Dockerfile").read_text().rsplit("FROM ", 1)[1] + poller_runtime = (ROOT / "Dockerfile.poller").read_text().rsplit("FROM ", 1)[1] + + assert "\nUSER app\n" in api_runtime + assert "\nUSER applytrack\n" in poller_runtime diff --git a/uv.lock b/uv.lock index 3547888..e90d58f 100644 --- a/uv.lock +++ b/uv.lock @@ -23,7 +23,7 @@ wheels = [ [[package]] name = "applytrack-poller" -version = "1.11.5" +version = "1.12.0" source = { editable = "." } dependencies = [ { name = "defusedxml" },