No internet connection
  1. Home
  2. General

Deployment topology: containers, networks & traffic

By @IvanTheGeek
    2026-07-05 15:30:57.891Z

    This is a reference for how a stock Talkyard deployment is laid out at the container level, as of v1.2026.003: what each container runs, the private networks that segment them, and which services can talk to which over which network. Everything below is drawn from the shipped docker-compose.yml, the default .env, and the running server source — it describes the upstream default topology, not any one person's install.

    The three diagrams answer three questions: what runs (inventory), how it's segmented (networks), and who talks to whom at runtime (traffic).

    1. Container inventory — what each container runs

    A default Talkyard install is a single Docker Compose project of seven long-running containers plus one on-demand backup container. Each uses a debiki/talkyard-* image pinned to the same version tag.

    flowchart TB
      subgraph project["Docker Compose project — images debiki/talkyard-*:v1.2026.003"]
        web["web<br/>OpenResty (nginx + Lua)<br/>reverse proxy · rate + conn limits (native nginx)<br/>bandwidth limits (Lua) · serves /-/u/* uploads directly<br/>publishes host ports 80 + 443"]
        app["app<br/>JVM Java 11 · Play Framework (Scala)<br/>talkyard-server :9000 (HTTP)<br/>React SSR in-JVM (Nashorn)<br/>WebSocket pub-sub as an in-JVM actor"]
        cache["cache<br/>Redis 8.6.1 :6379<br/>presence · watchbar · one-time login secrets<br/>link-preview cache · auto-SSL cert storage<br/>(not a page cache · no Redis pub-sub)"]
        rdb[("rdb<br/>PostgreSQL 18.3 :5432<br/>database 'talkyard' — all forum data")]
        search["search<br/>Elasticsearch 9.3.1<br/>:9200 REST · :9300 transport (unused, single node)<br/>full-text index"]
        rendr["rendr<br/>debian:13.3-slim<br/>placeholder (sleep infinity) — no traffic today<br/>future Deno React SSR"]
        egressp["egressp<br/>Smokescreen (Stripe, Go) :4750<br/>SSRF-filtering egress proxy<br/>read-only rootfs · cap_drop ALL"]
        backup["backup<br/>debian-slim + pg client + gpg<br/>profiles:[backup] — never auto-starts<br/>one-shot: pg_dumpall + uploads, optional GPG at rest"]
      end
    
      classDef front fill:#2d5016,stroke:#7cb342,color:#fff
      classDef back fill:#4a2c5e,stroke:#ab7fd1,color:#fff
      classDef egress fill:#5e2c2c,stroke:#d17f7f,color:#fff
      classDef sleepy fill:#3a3a3a,stroke:#888,color:#bbb,stroke-dasharray:5 5
      class web,cache front
      class app,rdb,search back
      class egressp egress
      class rendr,backup sleepy
    
    • web (OpenResty) is the ingress. It reverse-proxies dynamic requests to app, enforces rate/connection limits with native nginx (limit_req / limit_conn) and bandwidth limits with Lua, and serves uploaded files under /-/u/* directly off the uploads volume. In a stock install it publishes ports 80 and 443 to the host.
    • app is the Scala/Play server (talkyard-server) on port 9000. React server-side rendering currently runs inside the JVM (Nashorn), and live WebSocket fan-out is an in-JVM actor — Redis is not used as a pub-sub bus.
    • cache is Redis. It holds user presence, the watchbar, one-time login/SSO secrets, the link-preview HTML cache, and web's auto-SSL cert storage. It is deliberately not a rendered-page cache (that lives in the JVM mem-cache plus a Postgres page_html table).
    • rdb is PostgreSQL — the single source of truth for all forum data.
    • search is Elasticsearch, a single node holding the full-text index (its :9300 transport port is unused with one node).
    • rendr ships as an idle placeholder for a future out-of-JVM (Deno) renderer; it receives no traffic today.
    • egressp is Smokescreen, an SSRF-filtering egress proxy with a read-only rootfs and all capabilities dropped. All of app's outbound traffic goes through it.
    • backup carries the [backup] profile, so it never auto-starts; it is run on demand to pg_dumpall the database and archive uploads (with optional GPG encryption at rest).

    2. The five networks — segmentation

    Compose defines five internal networks, each a /24 in the 172.26.x range (these subnets and their internal/public flags are upstream .env defaults). Two are internet-capable; three are internal: true with no route off the host. Membership is what enforces the segmentation. Edge labels show each container's pinned static IP on that network.

    flowchart LR
      inet(("Internet"))
    
      web["web"]
      app["app"]
      cache["cache"]
      rdb["rdb"]
      search["search"]
      rendr["rendr"]
      egressp["egressp"]
      backup["backup<br/>(only while running)"]
    
      fepub{{"fe_pub_net<br/>172.26.1.0/24<br/>internet-capable"}}
      feint{{"fe_int_net<br/>172.26.2.0/24 · internal"}}
      beint{{"be_int_net<br/>172.26.3.0/24 · internal"}}
      egint{{"eg_int_net<br/>172.26.4.0/24 · internal"}}
      egpub{{"eg_pub_net<br/>172.26.5.0/24<br/>internet-capable"}}
    
      inet ---|"host :80 :443"| web
      web ---|".1.21"| fepub
      fepub -.-|"outbound only"| inet
    
      web ---|".2.21"| feint
      cache ---|".2.31"| feint
      app ---|".2.41"| feint
    
      app ---|".3.41"| beint
      rendr ---|".3.51"| beint
      rdb ---|".3.61"| beint
      search ---|".3.71"| beint
      backup ---|"dynamic IP"| beint
    
      app ---|".4.41"| egint
      egressp ---|".4.99"| egint
    
      egressp ---|".5.99"| egpub
      egpub ---|"outbound"| inet
    
      classDef net fill:#4a2c5e,stroke:#ab7fd1,color:#fff
      classDef pubnet fill:#7a4a1e,stroke:#e0a060,color:#fff
      classDef ctr fill:#1e3a5f,stroke:#4a90d9,color:#fff
      classDef world fill:#2d5016,stroke:#7cb342,color:#fff
      class feint,beint,egint net
      class fepub,egpub pubnet
      class web,app,cache,rdb,search,rendr,egressp,backup ctr
      class inet world
    
    • fe_pub_net (172.26.1.0/24, internet-capable) — web only. This is web's outbound path; inbound reaches web via its published host ports.
    • fe_int_net (172.26.2.0/24, internal) — web, cache, app. The front tier: web proxies to app, and both app and web reach Redis here.
    • be_int_net (172.26.3.0/24, internal) — app, rendr, rdb, search (and backup while it runs). The back tier holds the datastores; nothing here can reach the internet.
    • eg_int_net (172.26.4.0/24, internal) — app, egressp. The only egress path app has to the outside world.
    • eg_pub_net (172.26.5.0/24, internet-capable) — egressp only. Smokescreen's own outbound path.

    The 172.26.x addresses are pinned (via .env) so container IPs survive restarts. The segmentation story in one line: only web and egressp can touch the internet (and web only outbound on fe_pub_net); rdb, search, and rendr sit on internal-only networks and can see nothing but app, each other, and the backup job during its run; and app's only route off the host is through the Smokescreen egress proxy.

    You can optionally place your own reverse proxy (Traefik, nginx, HAProxy, a cloud load balancer, etc.) in front of web's published ports for TLS termination or vhost routing — the topology above is unchanged either way.

    3. Runtime traffic — who talks to whom

    This is the same set of containers viewed as traffic flows, annotated with the network each hop rides on and the protocol.

    flowchart LR
      user(("Browser /<br/>API client"))
      smtp["SMTP relay<br/>(outgoing email)"]
      ext["Link-preview sites ·<br/>webhook targets"]
    
      web["web<br/>OpenResty"]
      app["app<br/>talkyard-server"]
      cache["cache<br/>Redis"]
      rdb[("rdb<br/>PostgreSQL")]
      search["search<br/>Elasticsearch"]
      rendr["rendr<br/>(idle placeholder)"]
      egressp["egressp<br/>Smokescreen"]
      pubf[("pub-files<br/>volume")]
    
      user -->|"HTTPS/HTTP → host :443 / :80"| web
      web -->|"HTTP + WebSocket → app:9000<br/>net: fe_int"| app
      web -->|"/-/u/* upload bytes from volume (ro)<br/>after auth_request → app"| pubf
      web -.->|"auto-SSL cert storage → cache:6379<br/>net: fe_int (dormant by default)"| cache
      app -->|"JDBC → rdb:5432<br/>net: be_int"| rdb
      app -->|"REST → search:9200<br/>net: be_int"| search
      app -->|"presence · watchbar · login secrets<br/>· link-preview cache → cache:6379 · net: fe_int"| cache
      app -->|"ALL outbound (http_proxy + JVM flags)<br/>→ egressp:4750 · net: eg_int"| egressp
      app x-.-x|"no traffic today<br/>(SSR runs in-JVM)"| rendr
      egressp -->|"SMTP STARTTLS :587<br/>net: eg_pub"| smtp
      egressp -->|"HTTPS: link previews<br/>net: eg_pub"| ext
      egressp -.->|"webhooks (if configured)"| ext
    
      classDef world fill:#2d5016,stroke:#7cb342,color:#fff
      classDef edge fill:#1e3a5f,stroke:#4a90d9,color:#fff
      classDef svc fill:#4a2c5e,stroke:#ab7fd1,color:#fff
      classDef idle fill:#3a3a3a,stroke:#888,color:#bbb,stroke-dasharray:5 5
      classDef vol fill:#7a4a1e,stroke:#e0a060,color:#fff
      class user,smtp,ext world
      class web,egressp edge
      class app,cache,rdb,search svc
      class rendr idle
      class pubf vol
    
    • Browser/API client → web over the published host ports (443 for HTTPS, 80 redirecting to HTTPS in a typical setup).
    • web → app (fe_int) carries all dynamic requests, including the WebSocket upgrade (proxy_pass http://app:9000/-/websocket).
    • web → pub-files (ro) — uploaded files under /-/u/<pubSiteId>/<hashPath> are streamed straight from the volume by nginx (app never streams the bytes), but each request first passes an nginx auth_request subrequest to app (may-download-file) for authorization. Talkyard's own JS/CSS under /-/assets/* is served from the web image's disk, with app not involved at all.
    • web → cache (fe_int) is web's auto-SSL cert storage — dormant unless web is terminating TLS itself.
    • app → rdb (be_int) over JDBC for all forum data.
    • app → search (be_int) over the Elasticsearch REST API for the full-text index.
    • app → cache (fe_int) for presence, watchbar, one-time login secrets, and the link-preview cache. (Presence is written on logged-in HTTP activity, not on WebSocket connect.)
    • app → egressp (eg_int) — all of app's outbound traffic is forced through Smokescreen via http_proxy/https_proxy env vars plus JVM proxy flags. From there, on eg_pub, Smokescreen reaches the outgoing SMTP relay (STARTTLS :587), fetches link previews over HTTPS, and delivers webhooks if any are configured.
    • app → rendr carries no traffic today, since SSR runs inside the JVM.

    Taken together: the front tier terminates and rate-limits requests, the app tier holds all logic and is the only thing that talks to the datastores, the datastores are quarantined on internal-only networks, and every byte the app sends to the internet is mediated by a single SSRF-filtering egress proxy.

    • 0 replies