Containers

Containers

C4 level 2: the nine separately running pieces of Synapse, what each one owns, and how reads, runs and writes each take their own path through them.

Suggest an edit

Containers

You'll be able to: name every container in Synapse and the technology it runs on; trace a read, a run and a write through them; and explain why the origin is one process with one replica rather than a set of services.

A container in C4 is anything that runs as its own process and holds code or data you deploy: an application, a database, a sidecar. It is not a Docker container, although here most of them happen to be one.

The picture

Browser[Astro pages · Preact islands · viz-wasm]Cloudflare[CDN]synapse podCode sandbox[go-judge]Application store[PostgreSQL 17]Content checkouts[git-sync PVC + satellite cache]Content sync[git-sync]KeycloakGitHubOrigin server[Rust · axum]Web tier[Astro 7 SSR · Node 22]d2-render[Go] HTTPScache misspage (fallback)APId2 fenceboardsreadrunwriteJWKStarballs · PRs

The containers

Container Technology Owns Runs as
Origin server Rust · axum 0.8 · tokio the front door: every API route, files, SEO, security headers, rate limits; 12 bounded contexts process 1 in the synapse pod
Web tier Astro 7 SSR on Node 22 server-rendered HTML for every page process 2 in the same pod
d2-render Go draws D2 fences and walkthrough boards to SVG, cached by content sidecar in the pod
Content sync git-sync v4.4 clones the primary content repository and checks out new commits every 60 s its own deployment, writing to a volume
Content checkouts files on disk the primary checkout, plus a tarball cache of every registered satellite repository a volume the origin mounts read-only
Code sandbox go-judge compiles and runs untrusted code under CPU, wall-clock, memory and process caps its own deployment, egress denied
Application store PostgreSQL 17 the only state that cannot be rebuilt from git: 10 tables the cluster's shared Postgres
Browser Astro pages, Preact islands, Monaco, viz-wasm the editor, the visualiser, the diagram labs, the tracers the reader's tab
Coach LLM Ollama (llama3.1) answers the coach chat local development only, off in production

Keycloak, GitHub and Cloudflare are not containers of Synapse; they are the outside systems from System context.

Three paths through them

Every request belongs to one of three traffic classes, and each class has its own path.

A read — a lesson, a post, a search, a diagram. The CDN answers most of these. A miss reaches the origin, which forwards page requests to the web tier; the web tier renders against the origin's own content API and asks d2-render for any D2 fence, which it inlines as SVG. Nothing on this path touches Postgres except one append-only readership row.

A run — execute this code. The origin checks the rate limit and the admission gate (at most 6 runs in flight, 2 per caller), then sends the program to go-judge. Nothing is stored. An anonymous run gets half of each language's CPU and wall-clock budget.

A write — a judged submission, a ✓ tick, a note, a Think canvas, an edit proposal. These need a signed-in caller, go to Postgres, and are never cached. A submission is accepted with 202 and judged in the background; the submission lifecycle follows it end to end.

Two processes behind one front door

The origin and the web tier are one container image running two processes (dev-tools/start.sh). The origin is the only one the outside world talks to. It handles every route it owns — /api/*, /media, /simulators, /content-assets, /robots.txt, /sitemap.xml — and forwards everything else to the web tier as the router's fallback.

CDNaxum front door/api · /media · /simulators · /content-assetsrobots · sitemapsecurity headers · compressionAstro SSR(Node, same pod) fallback: everything elserenders against the public API

Two things follow from that ordering. A registered route can never be shadowed by a page path, so a lesson called api cannot break the API. And the web tier renders by calling the same public content API a browser would — a real loopback hop, paid for the property that there is exactly one content contract.

Either process dying takes the container down (wait -n in the start script). Half-alive is the worst state: one half gone would mean every page is a 502 while the orchestrator, which restarts only a container that exits, sees nothing wrong.

Why d2-render is its own container

D2 used to be compiled in the reader's browser, which cost 5.9 MB gzipped on a diagram-heavy lesson. Then it moved into each content repository's CI, which committed the SVGs under _media/d2 (ADR RS007). Both are gone (ADR RS009). The web tier now asks a sidecar while it renders the page, and inlines the SVG it gets back. The reason is memory: the WebAssembly build of d2 peaked at about 5.2 GB on one 23-diagram lesson, against a 256 MiB web container, while the native engine in the sidecar peaks at about 160–170 MB inside its own limit. A render spike can no longer take the pages down with it.

A miss is not an error. If the sidecar is down or refuses a malformed diagram, the page ships the source and the reader's browser draws it — the same floor every D2 miss lands on.

One process, one replica, on purpose

All 12 bounded contexts ship as one binary. Splitting a codebase into bounded contexts buys clean seams and the option to extract; splitting the deployment buys independent scaling at the cost of a network between every call. Synapse takes the first and not yet the second. The contexts themselves are the next level down.

The deployment runs replicas: 1, and that is a correctness requirement, not a resource compromise. The rate limiter and the admission gate keep their counters in process memory, so two replicas would mean twice the intended limits. Scaling out means moving that state first — a written-down trigger, covered in Scaling and maintainability.

The one context designed to leave first is execution. It is CPU-bound, runs hostile input, and its resource needs differ from the rest by an order of magnitude. Its port is already a trait and its adapter already speaks HTTP to a separate process, which is why the sandbox is already a separate container.

What each container refuses to do

Container Fails how Why
Origin fails fast without Postgres — no boot the system of record is not optional
Origin degrades without Keycloak — 503 on signed-in routes, never 401 "I cannot check" is not "you are not allowed"
Origin mounts a disabled feature not at all — the coach, the editor, the source registry and the d2 proxy are absent routes when switched off a structural 404 cannot be defeated by a misread flag
Web tier on a d2-render miss, ships the source the browser can still draw it
Code sandbox denies all egress; stops at the first failing test case it runs code written by strangers
Content sync on a failed fetch, keeps the last checkout readers keep the last good content

🧱 Container reference

Each container in detail — what it owns, the property it protects, and its honest limits

Origin server

Rust · axum 0.8 · tokio · prod image ghcr.io/ani2fun/synapse-rs, port 8080

Stateless by construction apart from two in-memory counters (rate limit and admission). Every response it sends — API, proxied page, file or error — passes through the same layer stack: telemetry outermost, then a 2-minute timeout and a 1 MiB body cap, then compression (gzip or deflate above 1 KiB), then security headers.

Public content GETs are stamped public, max-age=60, stale-while-revalidate=600: sixty seconds because that is how often new content can arrive. Anything per-user — /api/me, /api/run, submissions, the coach — is never stamped. A shared cache in front of a per-user response is one of the most damaging mistakes available in web architecture, and the narrowness of the rule is the defence.

Web tier

Astro 7 SSR · Node 22 · Preact islands

Every page is server-rendered: the prose arrives as HTML in the first response. Hashed assets are served immutable for a year. Interactivity hydrates per feature, and everything expensive — the editor, the sign-in client, the diagram engines, the tracers, the visualisation bundle — is a dynamic import a reader downloads only when they use it. The web tier has the detail.

d2-render

Go · prod sidecar on port 8390, disk cache at /var/cache/d2-render

Content-addressed: a diagram is keyed by the hash of its source, so the same diagram in two lessons is drawn once. Walkthrough boards are served through /api/synapse/d2/{hash}/{slug} as immutable — the path is the content.

Content sync and the checkouts

git-sync v4.4.0, period 60 s

git-sync clones the primary repository, and when the remote moves it checks the new commit out and flips a symlink atomically. The origin reads through SYNAPSE_ROOT=/content/current and re-reads the commit on each request, so new prose appears with no restart and no purge. Satellite repositories (ADR RS005) are not git-synced; the origin fetches them as tarballs from the GitHub API into its own cache on the same 60-second cycle, because adding a repository must not need a redeploy. The content pipeline traces both routes.

Code sandbox

go-judge · its own deployment, pinned to one node

Requests 250m CPU and 512 MiB, capped at 2 CPUs and 2 GiB. The network policy denies all egress. Each run is capped at 15 s CPU, 30 s wall clock, 512 MiB memory and 256 processes by default, with anonymous callers at half the time. go-judge needs a privileged container for its own sandboxing syscalls; that, and the fact that this is process-level isolation rather than a separate kernel per run, is the honest limit. It is right for a personal deployment with a handful of signed-in users and would not be right at public scale, where the next step is a hardened runtime.

Application store

PostgreSQL 17

Ten tables: submissions and the submit allowlist; lesson views; problem progress; content edit requests and the editor allowlist; content sources and their readers; Think canvases; problem notes. Books are not rows — they are Markdown on disk. The schema is in Data design.

Browser

Astro pages · Preact · Monaco 0.52 · Mermaid 11 · viz-wasm

The code that runs on the reader's machine: the code editor, the step-through visualiser (Rust compiled to WebAssembly), the language tracers, the /viz, /d2 and /mermaid labs, and the D2 fallback renderer.

Coach LLM

Ollama · llama3.1 · not deployed

The coach chat (/api/tutor/*) is mounted only when TUTOR_ENABLED is set, which production leaves unset. It is a development feature today.

The diagram would look the same whether these were nine services or one binary. So what is it telling you?

It tells you about coupling, not about topology. A box on a C4 container diagram has its own runtime and talks to its neighbours through a declared contract. Whether the origin's contexts are one process or twelve is a deployment decision, driven by scaling and failure requirements, not by how the code is organised. Draw the boundaries first and choose the topology from evidence.

Mark as readBook versionda22e04