Skip to content

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 / SSMLocalStack (already in the local profile)
Supabase cloudSelf-hosted Supabase stack (db, GoTrue, PostgREST, Kong, Studio β€” already in the local profile)
Cloudflare R2MinIO (already in the local profile)
Cloudflare KVGap β€” 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.ts vs index-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)

  1. 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 a redis service to the compose.
  2. 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.js injection. Recommended: build-args for MVP (simplest, matches the existing staging Dockerfile).
  3. Cloud-only frontend calls. WorkshopPromoBar, checklist worker, and anything else hitting worker.theaccessible.org must degrade gracefully offline (mostly already auto-hide on fetch failure β€” verify). Add a NEXT_PUBLIC_SELF_HOSTED=1 flag to suppress SaaS-only surfaces (billing/Stripe checkout, workshops promo, admin spend panels).
  4. 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 dev local profile verbatim):
    • traefik β€” single entrypoint on 80/443, auto-TLS (Let’s Encrypt or customer cert), routes / β†’ web, /api β†’ api, /storage β†’ MinIO (presigned URLs), /auth β†’ Kong
    • web β€” 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 SQS
    • redis β€” KV/sessions/rate-limit (Phase 2)
    • localstack β€” SQS + DynamoDB + SSM emulation (existing infra/localstack/init-aws.sh)
    • Supabase stack β€” supabase-db, supabase-auth, supabase-rest, supabase-kong, supabase-migrate (drop Studio + inbucket for customers, or gate behind a debug profile)
    • minio + minio-init β€” the six buckets already scripted
    • Conversion sidecars β€” weasyprint, verapdf, flowchart-extract, markitdown, audio-transcribe; exclude audiveris (niche) and opus-harness (gated to @theaccessible.org) from the default profile, offer as opt-in profiles
    • loki/promtail/grafana β€” monitoring profile, 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, first docker compose up, bootstrap admin user.
  • Migration runner β€” supabase-migrate one-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 in on-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_dump of supabase-db + mc mirror of MinIO volumes (documented, cron-able).
  • Upgrade path: docker compose pull && docker compose up -d with 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-server management 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.