Serve a static site behind Traefik

End-to-end walkthrough: take a host that already has docker, Traefik, and the stratum-edge network (i.e. one you just bootstrapped with bootstrap-droplet), and add a second application — an nginx container serving a static directory — routed by Traefik.

This is the canonical "second app behind Traefik" pattern. Use it as a template for any static-asset deploy. The two configs (bootstrap + this one) apply together against one state file — see Multi-file configs for why state is per-host and not per-config. If you'd rather apply them as independent slices, the same shape works as two namespaces — see the closing section of this tutorial.

What you get

After apply:

  • /srv/site/ on the host, containing whatever you point source_dir at (the output of any static-site build — mdbook build, hugo, a folder of pre-rendered HTML).
  • A site nginx:alpine container, mounting that directory read-only at the nginx web root.
  • Traefik labels routing Host(site.example.com) to the container.
  • Two new resources in state: system_dir.site and docker_container.site. The 11 resources from the bootstrap are untouched.

Prerequisites

  1. Bootstrap done. The host needs the docker daemon, Traefik running on :80/:443, and the stratum-edge network. See Bootstrap a fresh droplet.
  2. The shared host state file. Use the same -s .stratum/host.json you applied the bootstrap with. State is one-per-host, not one-per-config — both .strat files apply together against the shared state via repeated -c flags. See Multi-file configs.
  3. A built static tree. Any directory of HTML/CSS/JS/assets works. The config below uploads ./site/ from next to the .strat file; substitute any local directory.

Step 1: the config

host "primary" {
  addr = "root@192.0.2.10"
}

resource "system_dir" "site" {
  host         = host.primary.addr
  source_dir   = "site"
  path         = "/srv/site"
  mode         = "0644"
  dir_mode     = "0755"
  owner        = "root"
  group        = "root"
  delete_extra = true
}

resource "docker_container" "site" {
  host     = host.primary.addr
  name     = "site"
  image    = "nginx:alpine"
  restart  = "unless-stopped"
  networks = ["stratum-edge"]
  volumes  = [
    "/srv/site:/usr/share/nginx/html:ro",
  ]
  labels = {
    "traefik.enable"                                       = "true"
    "traefik.http.routers.site.rule"                       = "Host(`site.example.com`)"
    "traefik.http.routers.site.entrypoints"                = "web"
    "traefik.http.services.site.loadbalancer.server.port"  = "80"
  }
}

Two resources. Notable details:

  • delete_extra = true keeps the remote tree in sync: files removed from site/ locally get rm -f'd on the host on the next apply. Without it, deleted local files stay on the host indefinitely.
  • Substitute site.example.com with a hostname that resolves to your host. For a quick demo without DNS, a service like nip.io resolves any hostname of the form <anything>.<ip>.nip.io to <ip> — useful for development, not for production.
  • The container attaches to stratum-edge — the same network Traefik discovers via the docker socket. No host port mapping needed; Traefik proxies via the network.
  • The Traefik labels are exactly the Traefik 2.x router/service form. Stratum doesn't parse them — they're opaque strings handed to docker.

Step 2: build the static tree

Build whatever your site is and drop the output in site/ next to the .strat file. The exact command depends on the generator — mdbook build, hugo, npm run build, or cp -R public site/. The system_dir provider doesn't care about the source; it just walks the directory tree and ships every regular file.

Step 3: plan

Apply both configs together against the shared host state. The bootstrap resources are already in state, so the only Create steps are the two new ones.

./target/release/stratum plan \
  -c bootstrap.strat \
  -c site.strat \
  -s .stratum/host.json

Expected output:

stratum plan
============
   docker_container.traefik
   docker_network.edge
   ...
 + docker_container.site
 + system_dir.site
   ...

2 create, 0 update, 0 delete, 11 no-op

(Order shown abbreviated.) Eleven resources are in state and unchanged; two new ones are queued for create.

Step 4: apply

./target/release/stratum apply -y \
  -c bootstrap.strat \
  -c site.strat \
  -s .stratum/host.json

The system_dir step tars + gzips site/ in memory, streams it over SSH, extracts on the host, and applies chown -R + chmod recursively. You'll see one summary line on stderr:

[system] DIR `site` -> root@192.0.2.10:/srv/site (N files, mode=0644 dir_mode=0755 root:root)

Then the container starts. Post-apply self-check:

post-apply drift: clean

(No unreadable count — neither resource uses an Unknown read.)

Step 5: verify

curl -H "Host: site.example.com" http://<host-ip>/
# or open http://site.example.com in a browser (with DNS pointed at the host).

You should get the site's landing page.

Re-deploying after content changes

Rebuild the static tree locally, then apply again. The system_dir provider hashes every file and compares against manifest_sha256 in prior state:

  • No content changes[system] DIR \site` -> ... unchanged (N files, manifest match)` and the upload is skipped.
  • Any file added, removed, or modified → manifest digest changes, the whole tree re-tars and re-uploads. With delete_extra = true, files removed locally are rm -f'd on the host as part of the apply.

The docker_container is unchanged in either case — nginx is just serving from a bind mount, so new files on disk show up immediately without a container restart.

Why both -c flags together, not separately

Apply site.strat alone against .stratum/host.json and the plan diff is "11 deletes, 2 creates" — the site config doesn't mention any of the bootstrap resources, so build_plan flags them for deletion. The destruction guard catches the resulting apply with a list naming the loaded configs:

refusing to apply: plan would delete 11 resources not in config:
  - ...

loaded configs: site.strat
state file: .stratum/host.json

The missing config (bootstrap.strat) is visually obvious in loaded configs. The structural fix is to pass every .strat file that touches the host to every plan / apply. State is per-host. See Multi-file configs.

Or: split into two namespaces

If bootstrap.strat and site.strat are logically independent — bootstrap rarely changes, the site re-deploys often — wrapping them as two namespaces lets each one plan and apply on its own without juggling a long -c list. The shape is:

# stratum.strat
host "primary" {
  addr = "root@192.0.2.10"
}

namespace "infra" { configs = ["bootstrap.strat"] }
namespace "site"  { configs = ["site.strat"] }

Then stratum -n infra apply -y for the host-tier setup and stratum -n site apply -y for the site, each against its own state file. See the Multi-namespace deployments tutorial for the full walkthrough.

What's next

  • Layer additional apps the same way: one system_dir (or system_file for a single config) plus one docker_container with Traefik labels, each in its own .strat file, and add the file to your -c list (or to a new namespace).
  • For dynamic apps (build images, manage envs, blue/green rollouts), reach for a per-app deploy tool — stratum is for the host-tier setup, not per-app lifecycle.