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" },