#34 is infra-blocked and unverifiable on the Windows dev host, so spec it rather than build it blind. A capability sweep found the load-balancing, rate-limit, proxy-cache, per-vhost-log, wildcard-TLS, and S3-upload primitives already exist (nginx_advanced_service / nginx_service / advanced_ssl_service / storage_provider_service) — so #34 is wiring + a public base-domain + stateless WP, not greenfield nginx. Adds docs/HORIZONTAL_SCALING_SPEC.md: a shared keystone (K0-K4) that also unblocks #21/#15-TLS/#8/#30-jail/#25, then #34 proper (H0-H4), each phase a separately verifiable PR with a Windows-vs-real-host split. Links it from the roadmap #34 entry. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
18 KiB
ServerKit — Per-Site Edge & Horizontal Scaling Spec
Splitting the #34 epic — and specifying the keystone it shares with #21, #15-TLS, #30-jail, and #8/#25.
Status: design only. No code shipped. This doc exists so #34 (and its prerequisites) can be executed in focused passes on a real Linux/Docker host, where the infra can actually be verified — which is exactly why it wasn't built blind from the Windows dev environment.
1. Why this doc
docs/WORDPRESS_ROADMAP.md defers #34 (horizontal scaling) as "XL + blocked on
the same per-site reverse-proxy/upstream layer as #21." That deferral is correct,
but it undersells how much already exists. A capability sweep of the nginx/SSL/storage
services shows the load-balancing, rate-limiting, caching, per-vhost-log, wildcard-TLS,
and S3-upload primitives are already built — they're just bound to the generic
Application/domain model, never to a WordPressSite running as a localhost:PORT
container.
So the real work is three layers, in order:
- The keystone — a per-site edge (public domain → reverse-proxy vhost → the WP container) with auto-TLS. This is the missing infrastructure that blocks #21, #15-TLS, #30-jail, #8-backend, and the original #25-nginx design — and it is #34's prerequisite.
- Stateless WP — shared media + shared object cache so replicas are interchangeable.
- Replicas + upstream LB — multiple WP containers behind the keystone vhost.
This doc specs all three as numbered phases (K0–K4 keystone, H0–H4 scaling), each with
Goal / Do / Reuse / Acceptance / Verifiable-here? / Risk, plus a dependency graph and a
real-host verification checklist.
2. Current reality (verified against the code)
| Fact | Evidence |
|---|---|
A managed WP site is a single wordpress:${VERSION}-apache container publishing ${HTTP_PORT}:80 straight to the host; per-site MySQL + per-site Redis (#23). No domain, no per-site vhost at create. |
backend/templates/wordpress.yaml (ports: "${HTTP_PORT}:80") |
site.url falls back to the host port unless a domain is attached. |
wordpress_service.py _enrich_site_data (#3) |
| A container-proxying vhost primitive already exists, incl. HTTPS via cert + HTTP→HTTPS redirect. | nginx_service.py:391 create_site(..., ssl_cert, ssl_key), :450 _with_ssl, :535 add_ssl_to_site |
Upstream load-balancing, limit_req, proxy_cache, and per-vhost log reads already exist — bound to a generic domain/data dict, not to a WP site. |
nginx_advanced_service.py:31 create_reverse_proxy (upstreams→upstream{} at :47, proxy_pass http://{upstream} at :95/:113, limit_req_zone at :61, proxy_cache_path at :68), :173 get_vhost_logs, :196 get_load_balancing_methods |
| Wildcard cert issuance + auto-renew already exist. | advanced_ssl_service.py:39 issue_wildcard_cert(domain, dns_provider, credentials, email), ssl_service.py:256 setup_auto_renewal |
| Subdomain generation for environments already exists. | environment_domain_service.py:61 generate_domain(production_domain, env_type, …) |
| S3/B2/MinIO directory upload already exists (for media offload). | storage_provider_service.py:260 upload_directory(local_dir, remote_prefix) |
Conclusion: the gap is wiring + a public base-domain + statelessness, not greenfield nginx work. That makes both the keystone and #34 far smaller than "XL from zero" implies — but every phase touches nginx apply/reload, cert issuance, or container orchestration, none of which is runtime-verifiable on Windows.
3. Dependency graph
┌─────────────────────────── Part 1: KEYSTONE (per-site edge) ───────────────────────────┐
│ K0 public base-domain + wildcard DNS/TLS │
│ │ │
│ ▼ │
│ K1 per-site reverse-proxy vhost ──► K2 auto-TLS per site ──► K3 preview URL (#21) │
│ │ │ │
│ ▼ ▼ │
│ K4 per-site access log + limit_req ──► unblocks #30-jail, restores #25-nginx │
└────────┬────────────────────────────────────────────────────────────────────────────┘
│ (also closes: #15-TLS, #8-backend SSL wiring, #3 already done)
▼
┌─────────────────────────── Part 2: #34 HORIZONTAL SCALING ─────────────────────────────┐
│ H0 stateless WP (shared media + shared object cache) │
│ ▼ │
│ H1 replica orchestration (internal network; edge publishes, not the container) │
│ ▼ │
│ H2 upstream LB wiring ──► H3 health/rolling ──► H4 autoscale signal (optional) │
└────────────────────────────────────────────────────────────────────────────────────────┘
Do K0→K2 first — it alone closes #15-TLS, #8-backend, and #21 (with K3), independent of #34. #34 cannot start before H0, and H0/H1 require the keystone's edge to exist (replicas can't each publish a host port).
4. Part 1 — The keystone: per-site edge
K0 — Panel-wide public base domain + wildcard DNS/TLS [M]
- Goal: a configurable base domain (e.g.
apps.example.com) with*.apps.example.comDNS and a wildcard cert, so any managed site can get a real public URL. This is the single setting whose absence makes every public-URL feature impossible today. - Do: add a
base_domain+ DNS-provider binding to settings; on save, issue/refresh the wildcard cert and schedule renewal. Validate the wildcard record resolves before declaring it ready. - Reuse:
advanced_ssl_service.issue_wildcard_cert(:39),ssl_service.setup_auto_renewal(:256), the existing DNS-provider credentials (Connections /provider_secretencryption). - Acceptance: with a base domain configured,
dig *.baseresolves and a valid*.basecert exists. - Verifiable here? Config/model/validation logic yes; DNS + cert issuance real host only.
- Risk: registrar/DNS-provider variance; wildcard issuance needs DNS-01 (already supported). If no public base domain is set, the whole edge degrades gracefully to today's
localhost:PORTbehavior — keep that fallback.
K1 — Per-site reverse-proxy vhost at create/attach [L]
- Goal: put an nginx vhost in front of each managed WP container so it answers on a real hostname instead of
localhost:PORT. - Do: on create (and on domain-attach), call
NginxService.create_site(name, app_type='docker', domains=[hostname], port=<container published port>)+enable_site+reload. Persist the hostname onWordPressSite. Make WP proxy-aware: setWP_HOME/WP_SITEURLto the hostname, trustX-Forwarded-Proto/-Forinwp-config, and run the serialized-safesearch-replace(#5/#9 primitive) fromlocalhost:PORT→ the hostname. - Reuse:
nginx_service.create_site/enable_site/reload(:391/:483/:288),db_sync_servicesearch-replace, thedomains.pyattach flow (#8 already wires domain-attach for generic docker apps). - Acceptance: a freshly created site is reachable at
http://<hostname>andwp-adminloads with correct URLs (no redirect loop, no mixedlocalhostlinks). - Verifiable here? Template rendering + the
wp-config/search-replace logic yes; nginx apply + live HTTP real host only. - Risk: the classic WP-behind-proxy redirect loop (needs
X-Forwarded-Protohandling); a port is still published in K1 (internal-only comes in H1).
K2 — Auto-TLS per site [M]
- Goal: every site is HTTPS by default.
- Do: for a
*.basesubdomain, serve the K0 wildcard cert viacreate_site(ssl_cert=…, ssl_key=…)(the_with_sslpath already does the 80→443 redirect). For an attached custom domain, issue a per-host LE cert. Addssl_statustoWordPressSiteand surface grade/expiry (the #8SiteSSLPanelalready renders this once a domain is routed). - Reuse:
nginx_service.create_site(ssl_cert, ssl_key)(:391) /add_ssl_to_site(:535),ssl_service/advanced_ssl_service, the #8 frontend. - Acceptance: the site loads over HTTPS with a valid cert; HTTP redirects to HTTPS.
- Verifiable here?
ssl_statusmodel + wiring yes; cert + TLS handshake real host only. - Risk: custom-domain certs need DNS pointed first — keep a "pending DNS" state.
K3 — Preview URL before DNS (#21) [S–M, on top of K0–K2]
- Goal: a working HTTPS preview link the instant a site exists, pre-DNS.
- Do: mint
<site|hash>.preview.<base>via the K0 wildcard (covered by the wildcard cert, so no per-host issuance). Optionally a basic-auth gate (the env-pipeline already has basic-auth, P4). This is #21 once K0–K2 exist. - Reuse:
environment_domain_service.generate_domain(:61), K0 wildcard,create_site. - Acceptance: every new site shows an immediately-working
https://….preview.baselink. - Verifiable here? Hostname generation yes; live preview real host only.
K4 — Per-site access log + limit_req jail (restores #25-nginx, unblocks #30-jail) [M]
- Goal: a per-site nginx access log on the host + brute-force protection — the exact signals #25 and #30 had to defer because the container model exposes none.
- Do: emit a per-site
access_logpath in the K1 vhost. Add alimit_reqzone forwp-login.php/xmlrpc.phpviacreate_reverse_proxy(it already emitslimit_req_zone/limit_req). Point #25's analytics atget_vhost_logs(a real per-vhost log) instead of/alongside thedocker logssource it fell back to; this also restores%Dresponse-time once we own theLogFormat. - Reuse:
nginx_advanced_service.create_reverse_proxy(:31,limit_reqat:61/:85),get_vhost_logs(:173),security_serviceFail2ban (optional, watching the new log). - Acceptance: repeated
wp-loginhits get throttled; per-site visits/5xx/slow-pages come from the vhost log. - Verifiable here? Log-parsing + zone-rendering yes; live throttling + log emission real host only.
After Part 1: #15-TLS ✅, #21 ✅, #8-backend ✅, #30-jail ✅, #25 (response-time/slow-pages) ✅ — independent of #34.
5. Part 2 — #34 horizontal scaling
Built entirely on the keystone. The mental shift: in H1 the WP container stops publishing a host port; the edge vhost (K1) becomes the only public entry and load-balances across internal replicas.
H0 — Make WP stateless [L]
- Goal: replicas must be interchangeable — no replica-local state.
- What's already fine: the object cache is already shareable — the #23 Redis is one service in the site's compose stack, so N WP replicas pointing at the same
redisservice share it automatically. No change needed there. - The real statefulness is two things:
- Uploads /
wp-content/uploads(local disk per replica). Decision (open):- Option A — S3/object offload (a media-offload plugin writing to the site's configured
storage_provider). Pro: truly stateless, scales infinitely. Con: a plugin dependency (mild tension with the WP-CLI-over-plugin thesis), egress cost, and rewriting existing media URLs. Reusestorage_provider_service.upload_directory(:260) + creds. - Option B — shared volume (NFS/CIFS or a Docker named volume shared across replicas on one host). Pro: no plugin, transparent. Con: needs shared-FS infra; single-host only unless NFS; locking edge-cases.
- Recommendation: Option A for multi-host scale; Option B as a single-host MVP. Spec both; pick at execution.
- Option A — S3/object offload (a media-offload plugin writing to the site's configured
- On-disk page cache — if #22 landed a disk-writing page-cache plugin, that's replica-local and must be moved to Redis or disabled under LB. Verify #22's cache backend before H1.
- Uploads /
- Acceptance: uploading media on replica A is immediately served by replica B.
- Verifiable here? Offload-config + URL-rewrite logic yes; cross-replica consistency real host only.
- Risk: media migration for existing sites; plugin licensing for offload.
H1 — Replica orchestration [L]
- Goal: run N WP containers for one site.
- Do: change the stack so the WP service is replica-able (
deploy.replicasunder swarm, or N enumerated services /docker compose up --scaleon a compose host) on an internal network — drop the${HTTP_PORT}:80host publish; only the edge nginx reaches them. This is a breaking change to the single-container model, so it needs a per-site opt-in + a migration path (existing sites stay single-container until "Enable scaling"). - Reuse: the
wordpress.yamltemplate + the_ensure_*_in_stackadditive-compose pattern (#23 redis injection is the proven precedent). - Acceptance:
docker psshows N WP replicas for the site, none publishing a host port. - Verifiable here? Template rendering yes; actual replicas real host only.
- Risk: the publish→internal flip must be coordinated with K1 (edge must exist first or the site goes dark); MySQL stays single (not replicated here — that's a separate epic).
H2 — Upstream load-balancing wiring [M]
- Goal: the edge vhost balances across the H1 replicas.
- Do: upgrade the K1 vhost to an upstream-backed one: feed the replica endpoints to
create_reverse_proxy(upstreams=[…], method=…). Anonymous WP is stateless → round-robin; logged-in/cart needs affinity →ip_hashor cookie-sticky (expose viaget_load_balancing_methods). - Reuse:
nginx_advanced_service.create_reverse_proxy(upstream{}at:47,proxy_passat:95),get_load_balancing_methods(:196). - Acceptance: requests distribute across replicas; killing one replica doesn't drop traffic.
- Verifiable here? Upstream-config rendering yes; live balancing/failover real host only.
- Risk: session affinity for WooCommerce carts; cache coherence across replicas (mitigated by H0 shared Redis).
H3 — Health checks + rolling deploys [M]
- Goal: safe scale up/down and zero-downtime deploys.
- Do: per-replica health probe; drain a replica out of the upstream before stop; a scale API on the
WordPressSite. Tie deploys (#13/#29) into a rolling pattern: update one replica, health-check, proceed. - Reuse:
environment_health_service(#26 poller),nginx_advanced_service(rewrite upstream on scale), #29 safe-update health gate. - Acceptance: scaling and deploys cause no failed requests.
- Verifiable here? Orchestration logic yes; zero-downtime claim real host only.
H4 — Autoscale signal (optional) [M]
- Goal: scale on load.
- Do: drive H3's scale API from the #25 per-site traffic metrics (now real, via K4). Thresholds + cooldown.
- Reuse: #25 analytics, H3 scale API.
- Acceptance: sustained load adds a replica; quiet removes one.
6. Verifiable on Windows vs. needs a real Docker host
| Verifiable here (pytest / unit) | Real Linux+Docker host only |
|---|---|
WordPressSite schema additions (hostname, ssl_status, scaling flags, replica count) |
nginx config apply + reload |
| nginx template/string rendering (vhost, upstream, limit_req, ssl block) | Let's Encrypt / wildcard cert issuance |
wp-config mutation + serialized search-replace logic |
live HTTP / HTTPS, redirect-loop checks |
| service unit logic with mocked shell/Docker | container replicas, --scale, internal networking |
| API routes + RBAC + workspace scoping | load-balancing / failover behavior |
| media-offload config + URL-rewrite logic | cross-replica media + cache consistency |
limit_req zone generation |
actual brute-force throttling |
→ Each real-host item gets a manual verification checklist in the executing PR; nothing infra ships claimed-working until exercised on a host (per the project's verify-before-claiming bar).
7. Sequencing recommendation
- K0 → K1 → K2 — the highest-value slice. Closes #15-TLS, #8-backend, and (with K3) #21. Ship and verify on a host before touching #34.
- K4 — closes #30-jail and restores #25 response-time/slow-pages.
- H0 → H1 → H2 — the #34 MVP (stateless + replicas + LB). Gate H1 behind a per-site "Enable scaling" opt-in so the single-container model stays the default.
- H3 → H4 — production hardening + autoscale.
Each arrow is a separately shippable, separately verifiable PR. #34's "Done when" (N replicas behind a balancer with consistent media + cache) is reached at the end of H2.
8. Open decisions (resolve at execution)
- Media: S3 offload (Option A) vs shared volume (Option B) — pick per deployment topology (multi-host ⇒ A).
- Port model: the K1-publishes-a-port → H1-internal-only flip is a breaking change; needs a migration for existing sites and a clear opt-in.
- MySQL: stays single-instance in this spec. DB replication/clustering is a distinct epic, explicitly out of scope for #34.
- Panel vs sites: the panel's single-gevent-worker constraint (agent gateway, see
CLAUDE.md/ARCHITECTURE.md) is unrelated — this LB is for the managed sites, not the panel. Don't conflate. - Plugin thesis: media-offload (Option A) and any disk→Redis page-cache move may need plugins; weigh against the WP-CLI-over-plugin preference.
Cross-references: docs/WORDPRESS_ROADMAP.md #34, #21, #15, #8, #25, #30, #22, #23. This spec is the
"split before starting" the roadmap asks for on every XL.