Self-Hosted Single Docker Compose β PDF Product Plan
Plan for bundling the PDF productβs three functions β the apps/web frontend, the Lambda API, and the Node backend (10.1.1.4) β into a single self-hostable Docker Compose stack.
Decisions made (2026-07-20):
- Audience: customer-first product package, also usable as internal dev/DR stack
- Queue: LocalStack SQS for MVP (zero code changes); Redis/BullMQ swap post-MVP
- Frontend: Next standalone server (existing
apps/web/Dockerfile)
Related docs: docs/admin/docker-self-hosted-implementation-plan.md, docs/admin/on-prem-deployment.md.
Framing
The three βfunctionsβ collapse into two containers plus infrastructure, because the Lambda API and the Node backend are one codebase (workers/api, Hono, dual entry points):
| Today (cloud) | Self-hosted equivalent |
|---|---|
apps/web on Cloudflare Pages (static export) | web container β Next standalone server (apps/web/Dockerfile, port 3000, already exists) |
Lambda API (workers/api/src/index-aws.ts via API Gateway + CDK) | Folded into the Node API container β no Lambda emulation needed |
Node server on 10.1.1.4 (workers/api/src/server.ts, Puppeteer image) | api container β same workers/api/Dockerfile used today |
| SQS / DynamoDB / SSM | LocalStack (already in the local profile) |
| Supabase cloud | Self-hosted Supabase stack (db, GoTrue, PostgREST, Kong, Studio β already in the local profile) |
| Cloudflare R2 | MinIO (already in the local profile) |
| Cloudflare KV | Gap β Nodeβs local KV provider is in-memory only (see Phase 2) |
Roughly 80% of the compose already exists in the repoβs docker-compose.yml (local + monitoring profiles). The real work is closing four code gaps and packaging it as a customer-facing bundle.
Phase 1 β Route parity audit (prerequisite, ~1 day)
The Node entry (server.ts/index.ts) and Lambda entry (index-aws.ts) have drifted before (the /api/advising/access 404 incident). Self-hosted must serve the union of both route sets from the Node entry.
- Diff the routes registered in
index.tsvsindex-aws.ts; register any Lambda-only routes in the Node entry. - Add a CI check (script comparing registered route lists) so parity canβt silently drift again β this also fixes the recurring βregister in BOTHβ footgun for the hosted product.
- Audit Lambda-only code paths: SSM secret loading at cold start, DynamoDB KV, SES email β confirm each has a Node-mode equivalent (env file, KV provider, Resend/SMTP).
Phase 2 β Close the four code gaps (~3β5 days)
- KV store. Node mode currently talks to Cloudflare KV over REST (
CF_ACCOUNT_ID/CF_API_TOKEN); the local fallback is in-memory (loses sessions/rate-limits on restart, breaks with 2 API replicas). Add a Redis-backed KV provider behind the existing KV interface and aredisservice to the compose. - Frontend runtime config.
NEXT_PUBLIC_*(API URL, Supabase URL, anon key) are baked at build with production defaults. For a self-hosted image the customerβs hostname isnβt known at build time. Options: build the image inside the customerβs compose (build:with--build-arg), or a runtime-config endpoint/env.jsinjection. Recommended: build-args for MVP (simplest, matches the existing staging Dockerfile). - Cloud-only frontend calls.
WorkshopPromoBar, checklist worker, and anything else hittingworker.theaccessible.orgmust degrade gracefully offline (mostly already auto-hide on fetch failure β verify). Add aNEXT_PUBLIC_SELF_HOSTED=1flag to suppress SaaS-only surfaces (billing/Stripe checkout, workshops promo, admin spend panels). - Billing/licensing. Stripe is disabled in self-hosted mode; entitlements come from a license token env var validated at API startup. MVP can be a signed JWT checked offline.
Phase 3 β The compose package itself (~2β3 days)
Create deploy/self-hosted/ containing:
docker-compose.yml(customer edition β derived from the repo one, not the devlocalprofile verbatim):traefikβ single entrypoint on 80/443, auto-TLS (Letβs Encrypt or customer cert), routes/β web,/apiβ api,/storageβ MinIO (presigned URLs),/authβ Kongwebβ apps/web standalone (port 3000)apiβ workers/api Node server Γ1 (port 8790; scale to 2 like prod is optional)batch-workerβ same image,npx tsx workers/batch/src/index.ts, consuming LocalStack SQSredisβ KV/sessions/rate-limit (Phase 2)localstackβ SQS + DynamoDB + SSM emulation (existinginfra/localstack/init-aws.sh)- Supabase stack β
supabase-db,supabase-auth,supabase-rest,supabase-kong,supabase-migrate(drop Studio + inbucket for customers, or gate behind adebugprofile) minio+minio-initβ the six buckets already scripted- Conversion sidecars β
weasyprint,verapdf,flowchart-extract,markitdown,audio-transcribe; excludeaudiveris(niche) andopus-harness(gated to @theaccessible.org) from the default profile, offer as opt-in profiles loki/promtail/grafanaβmonitoringprofile, on by default
.env.customer.exampleβ trimmed from.env.node-server.example: customer supplies AI provider keys (Anthropic/Gemini required, Mathpix/Marker optional), SMTP or Resend key, license token, hostname. Everything infra-internal (MinIO creds, JWT secret, DB password) generated by the installer.install.shβ preflight (Docker version, ports, disk, RAM β₯ 16 GB given Puppeteer + Python sidecars), secret generation, firstdocker compose up, bootstrap admin user.- Migration runner β
supabase-migrateone-shot applies the repoβs numbered migrations in order on every upgrade. Self-hosted is migration-file-driven from day one (sidesteps the prod-drift problem).
Phase 4 β Images & release (~2β3 days)
- Publish versioned images to GHCR (
ghcr.io/anglinai/accessible-{web,api,weasyprint,...}:x.y.z) via a GitHub Actions workflow, per the naming/versioning scheme inon-prem-deployment.md. - Pin base images, keep the existing non-root users (
pptruser,nextjs); health checks already exist (/ready,/health). - Internal-consolidation side benefit: 10.1.1.4 can eventually pull these same images instead of building on-box via
rebuild-server.sh.
Phase 5 β Operations & docs (~3β4 days)
- Backup/restore commands:
pg_dumpof supabase-db +mc mirrorof MinIO volumes (documented, cron-able). - Upgrade path:
docker compose pull && docker compose up -dwith the migrate one-shot ordered before API start; documented rollback (previous tag + DB restore). - Redacted diagnostic bundle script (versions, container health, recent logs, env shape minus secrets).
- Docs in
docs/admin/self-hosted/: requirements, install, first login, AI-provider setup, backups, upgrades, troubleshooting. - Defer the full
accessible-servermanagement CLI from the on-prem doc β install script + documented compose commands are enough for MVP.
Risks / open items
- LocalStack in a customer bundle is unusual (dev tool) β works for MVP, but plan the Redis/BullMQ queue swap as the first post-MVP item so LocalStack and its DynamoDB emulation can be retired, leaving Redis as the single infra dependency.
- Resource footprint: Puppeteer API image + 5 Python sidecars + Supabase + LocalStack + monitoring is a beefy single node (~16 GB RAM realistic minimum). State in requirements up front.
- AI egress: even self-hosted, conversions call Anthropic/Gemini/Mathpix outbound. True air-gap is the separate βhybrid on-prem converter nodeβ track from the v2 roadmap β explicitly out of scope here.
- Email intake (convert-by-email) depends on Cloudflare Email Routing β excluded from self-hosted MVP.
Estimate
~12β17 days total, consistent with the 14β22 day estimate in docker-self-hosted-implementation-plan.md, minus what the local profile already delivers.
First step: Phase 1 (route-parity audit) β low-risk and also hardens the hosted product.