SECTION 01
Keep the topology deliberately small
A browser reaches a Cloudflare Worker, the Worker reaches the origin through Cloudflare Access and an outbound-only tunnel, and only Next.js talks to PostgreSQL over the Compose network.
The production image compiles a standalone Next.js application and runs it as an unprivileged Node process on a single small EC2 instance. Docker Compose runs four services there: the web server, PostgreSQL 16, cloudflared, and Grafana Alloy. Compose waits for PostgreSQL health before starting the web service, injects the database connection only into the server container, and persists the database in a named volume. Web and database ports are bound to the loopback interface for health checks and operator sessions, and the security group has no ingress rules at all. The browser never connects to PostgreSQL. Server components and route handlers query through the postgres client, while client components call same-origin JSON endpoints for mutations and interactive state.
Public requests take one fixed path. The site hostname is served by a Cloudflare Worker that proxies to an origin hostname protected by Cloudflare Access, whose only policy admits the Worker's service token. cloudflared on the instance holds an outbound connection to Cloudflare and requires a valid Access token again before forwarding to the web container. That separation matters more than the number of containers: curriculum pages render from the published release, assessment handlers create and grade sessions transactionally, browser-only affordances such as narration stay on the client, and one database remains the state authority, so a learner who signs in on a second device sees the same progress without reconciling browser caches.
- Browser
Receives HTML over HTTPS and runs explicit client components.
- Edge Worker
Rate limits auth and telemetry posts, hides admin paths, sets trusted client headers.
- Access + tunnel
Admits only the Worker's service token; cloudflared connects outbound from the host.
- Next.js web
Standalone Node server; proxy.ts verifies the Access token on every route.
- PostgreSQL
Published curriculum, accounts, and per-learner state.
- Named volume
Durable database files across container replacement.
SECTION 02
Place each capability on the correct side of the boundary
Database credentials, grading keys, and release operations stay in server-only modules; pointer interactions, local preferences, and speech synthesis stay in client components.
The database and session-store modules import server-only, making an accidental client import fail during the build rather than leaking a connection path into a browser bundle. Route handlers validate JSON payloads, check same-origin mutation requests, and translate expected failures into public errors. Interactive components receive already-reduced data and never need the database connection string. This gives the codebase a reviewable boundary even though server and client code share one TypeScript project.
The browser still owns transient presentation state: which lesson section is active, whether narration is paused, which locally installed voice is selected, and which pathway card is expanded. Durable learning state—progress, answers, bookmarks, release IDs, and audit records—returns to PostgreSQL. Keeping those categories distinct prevents a cleared browser profile from erasing learning history and prevents convenience preferences from becoming account policy.
| State | Owner | Reason |
|---|---|---|
| Published release and lesson progress | PostgreSQL | Durable, scoped to each learner, and transactionally updated. |
| Session item snapshots and grading keys | Server + PostgreSQL | Must not be trusted to the browser before feedback is due. |
| Narration voice and speed | Browser local storage | Device capability and personal preference vary by browser. |
| Pathway preview selection | Browser local storage | A reversible planning preference, not completion evidence. |
SECTION 03
Scope every learner query to an account
Each account owns one learner row, so the database coordinates progress across that person's devices while keeping one learner's evidence apart from another's.
Accounts sign in with an email address or, for operator-provisioned accounts, a username. Passwords are stored as scrypt hashes. A sign-in creates a random opaque session token; the browser keeps it in an HTTP-only cookie, and the database stores only its SHA-256 digest. Every progress and assessment query takes its learner ID from that session, and session reads match both the session ID and the owning learner, so another attempt's identifier returns nothing. Monotonic lesson updates use a database greatest operation so an older 25% request cannot overwrite a concurrently saved 95% result.
The model grew out of a single shared learner. Before accounts existed, every query used one fixed learner row; the first account registered afterwards claimed that row and its history, and every later account receives a fresh one. Registration, sign-in, and password-reset forms carry a Turnstile challenge and are rate limited both at the edge and in the app. What an account may open is a separate question answered by entitlements: a free account gets a short diagnostic practice set and a lesson preview, while full access is an entitlement row checked on the server, never a browser flag.
SECTION 04
Verify every request's door, not its network
Noindex and private hostnames are context, not controls; the app verifies a signed Cloudflare Access token before it believes forwarded client headers or serves an admin route.
The Worker strips client-supplied forwarding and X-Portal headers and writes trusted ones: client IP, country, region, city, and Cloudflare ray ID. The app does not take that on faith. proxy.ts runs before every non-static route, verifies the Cf-Access-Jwt-Assertion header as an RS256 token against the Access team's published signing keys, and reads its audience to decide which door the request used. Only the public origin application's audience makes the Worker's headers trustworthy, and only the admin application's audience makes admin routes reachable. Requests without a valid token are refused, apart from the container health check and a bearer-protected metrics scrape.
Admin pages live on a separate hostname behind their own Access application, which sends an email one-time PIN to allowlisted addresses; past that, the app still requires sign-in by an account on its admin allowlist. The Worker returns 404 for admin and internal paths, and proxy.ts does the same for any public-door request that reaches them. Robots policy is a separate concern: the root layout applies noindex and nofollow by default, only the blog, legal pages, and pricing opt in to indexing, and robots.txt disallows learner and admin prefixes. That keeps graded results out of search, but it is not authorization. Response headers add a restrictive content security policy, deny framing, limit device permissions, and set HSTS.
| Layer | Responsibility | Evidence |
|---|---|---|
| Edge Worker | Rate limits, 404 for admin and internal paths, trusted client headers | Worker unit tests with mocked fetch |
| Access + tunnel | Admit only the Worker's service token or an allowlisted admin | Access applications and policies in OpenTofu |
| proxy.ts | Verify the Access JWT and its audience before trusting headers | Edge-trust unit tests |
| Host | No ingress rules, no SSH key, loopback-only published ports | Security group and Compose port bindings |
| Account session | Learner routes need a session; admin routes need an allowlisted account | Server-side guards on each handler and page |
SECTION 05
Deploy, observe, and back up without opening a port
A Forgejo pipeline provisions and deploys through APIs and AWS Systems Manager, telemetry leaves through its own tunnel, and nightly dumps go to S3, so nothing needs an inbound connection.
A Forgejo pipeline deploys the stack. OpenTofu provisions the instance, container registry, backup and operations buckets, parameter-store secrets, tunnels, Access applications, and Turnstile widget. The image is built once with the public site URL baked in and pushed to ECR, and wrangler deploys the Worker. An Ansible bundle then runs on the host through AWS Systems Manager, because there is no SSH key or inbound port to use. Ansible renders the environment file from the parameter store, pulls the image, migrates, starts the stack, and waits for the health endpoint to report the new version. If it does not, the previous version is restarted and the deployment fails; migrations are not reversed.
Grafana Alloy on the instance tails container logs, scrapes host, database, tunnel, and application metrics, and forwards application traces. It ships them through a second tunnel, guarded by its own Access service token, to a self-hosted Prometheus, Loki, Tempo, and Grafana stack. A systemd timer takes a nightly pg_dump, uploads it to an encrypted, versioned S3 bucket that expires backups after 30 days, and writes a freshness metric that an alert watches. An uploaded archive is not a restore drill; restore remains a deliberate, separately confirmed operation.
REFERENCES
Sources and further reading
- Server and Client ComponentsNext.js
Framework reference for keeping data access on the server while isolating interactive browser code behind explicit client boundaries.
- Deploying a Next.js applicationNext.js
Framework deployment reference for the standalone Node.js output used by the container image.
- TransactionsPostgreSQL Global Development Group
Database reference for all-or-nothing release changes, session creation, grading, and auditable state transitions.
- Validate JWTsCloudflare Zero Trust documentation
Reference for verifying the Cf-Access-Jwt-Assertion header against the team's published signing keys, which proxy.ts does before trusting forwarded client headers.
KEEP EXPLORING
See the system in context
- Dashboard — View the signed-in learner's database-backed summary, the same on every device.
- Settings — Compare device-local accessibility preferences with account-scoped learning state.
- Sign in — Sign in with an email address or provisioned username; the resulting session scopes every learner query.