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 pointsource_dirat (the output of any static-site build —mdbook build,hugo, a folder of pre-rendered HTML).- A
sitenginx:alpinecontainer, 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.siteanddocker_container.site. The 11 resources from the bootstrap are untouched.
Prerequisites
- Bootstrap done. The host needs the docker daemon, Traefik running on
:80/:443, and thestratum-edgenetwork. See Bootstrap a fresh droplet. - The shared host state file. Use the same
-s .stratum/host.jsonyou applied the bootstrap with. State is one-per-host, not one-per-config — both.stratfiles apply together against the shared state via repeated-cflags. See Multi-file configs. - A built static tree. Any directory of HTML/CSS/JS/assets works. The config below uploads
./site/from next to the.stratfile; 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 = truekeeps the remote tree in sync: files removed fromsite/locally getrm -f'd on the host on the next apply. Without it, deleted local files stay on the host indefinitely.- Substitute
site.example.comwith a hostname that resolves to your host. For a quick demo without DNS, a service likenip.ioresolves any hostname of the form<anything>.<ip>.nip.ioto<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 arerm -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(orsystem_filefor a single config) plus onedocker_containerwith Traefik labels, each in its own.stratfile, and add the file to your-clist (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.