Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .env.production.example
Original file line number Diff line number Diff line change
@@ -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
13 changes: 11 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ test-results/
# Local secrets / env
.env
*.env
.env.*
!.env.example
!.env.production.example

# Local agent runtime state
.claude/
2 changes: 1 addition & 1 deletion BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/
6 changes: 5 additions & 1 deletion Dockerfile.poller
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
31 changes: 30 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
```

Expand Down
2 changes: 1 addition & 1 deletion api/ApplyTrack.Api/ApplyTrack.Api.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>ApplyTrack.Api</RootNamespace>
<Version>1.11.5</Version>
<Version>1.12.0</Version>
<Authors>Aaron K. Clark</Authors>
<Copyright>Copyright 2026 Aaron K. Clark</Copyright>
<PackageLicenseExpression>Apache-2.0</PackageLicenseExpression>
Expand Down
1 change: 1 addition & 0 deletions api/ApplyTrack.Api/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
94 changes: 94 additions & 0 deletions docker-compose.production.yml
Original file line number Diff line number Diff line change
@@ -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:
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
39 changes: 39 additions & 0 deletions tests/test_containers.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading