diff --git a/app_config/apache/analyzer-vhost.snippet.conf b/app_config/apache/analyzer-vhost.snippet.conf
new file mode 100644
index 0000000..3d44faf
--- /dev/null
+++ b/app_config/apache/analyzer-vhost.snippet.conf
@@ -0,0 +1,44 @@
+# Beacon behind a host Apache (the analyzer.meshmapper.net shape). Drop these lines into the
+# *:443 VirtualHost next to the existing ProxyPass rules. Needs mod_headers, mod_proxy,
+# mod_proxy_http, mod_proxy_wstunnel, mod_authz_core (all already loaded on that box).
+
+ # dedicated log so fail2ban and the runbook can parse it (other_vhosts uses vhost_combined)
+ ErrorLog ${APACHE_LOG_DIR}/analyzer-ssl-error.log
+ CustomLog ${APACHE_LOG_DIR}/analyzer-ssl-access.log combined
+
+ # client-IP hygiene: the app trusts these headers, so only Apache may set them.
+ # mod_proxy re-adds a clean X-Forwarded-For after the unset.
+ RequestHeader unset X-Forwarded-For early
+ RequestHeader unset X-Real-IP early
+ RequestHeader unset True-Client-IP early
+ RequestHeader set X-Real-IP "%{REMOTE_ADDR}s"
+
+ # scraper User-Agent signatures live in /etc/apache2/beacon-blocklist-ua.conf; a match sets
+ # beacon_block, which the /api and /ws blocks below reject
+ Include /etc/apache2/beacon-blocklist-ua.conf
+
+ # manual block list for /api and /ws: edit /etc/apache2/beacon-blocklist.conf, then
+ # `apachectl configtest && systemctl reload apache2`
+
+
+ Require all granted
+ Require not env beacon_block
+ Include /etc/apache2/beacon-blocklist.conf
+
+
+
+
+ Require all granted
+ Require not env beacon_block
+ Include /etc/apache2/beacon-blocklist.conf
+
+
+
+# /etc/apache2/beacon-blocklist.conf — one `Require not ip ` per line.
+# Keep the TEST-NET placeholder so the file is never empty:
+#
+# Require not ip 192.0.2.0/24
+#
+# /etc/apache2/beacon-blocklist-ua.conf — one SetEnvIfNoCase per scraper User-Agent signature:
+#
+# SetEnvIfNoCase User-Agent "mesh\.hansimgamr\.net" beacon_block
diff --git a/app_config/caddy/Caddyfile.type1 b/app_config/caddy/Caddyfile.type1
index a7c9eef..45efd60 100644
--- a/app_config/caddy/Caddyfile.type1
+++ b/app_config/caddy/Caddyfile.type1
@@ -1,14 +1,55 @@
# Type 1 (All-in-One): API + Web on same server
{$DOMAIN} {
- handle /api/* {
- reverse_proxy tower-app:8080
- }
+ # defines @blocked_ip and @blocked_ua; edit conf.d/blocklist.caddy then `caddy reload`
+ import conf.d/blocklist.caddy
- handle /ws {
- reverse_proxy tower-app:8080
- }
+ # JSON access log for the whole site (API, SPA and assets together, so per-IP joins are one file)
+ log {
+ output file /var/log/caddy/access.json {
+ mode 0644
+ roll_size 100MiB
+ roll_keep 20
+ roll_keep_for 720h
+ }
+ format json
+ }
- handle {
- reverse_proxy tower-web:80
- }
+ # opt-in: 403 API calls whose Referer names another site. Spoofable; enable only if the
+ # logs show a specific foreign host with sustained volume.
+ # @foreign_referer {
+ # header Referer *
+ # not header Referer https://{$DOMAIN}/*
+ # }
+
+ handle /api/* {
+ respond @blocked_ip "Forbidden" 403 {
+ close
+ }
+ respond @blocked_ua "Forbidden" 403 {
+ close
+ }
+ # respond @foreign_referer "Forbidden" 403
+ reverse_proxy app:8080 {
+ # the app trusts these headers for the client IP, so only Caddy may set them
+ header_up -True-Client-IP
+ header_up X-Real-IP {remote_host}
+ }
+ }
+
+ handle /ws {
+ respond @blocked_ip "Forbidden" 403 {
+ close
+ }
+ respond @blocked_ua "Forbidden" 403 {
+ close
+ }
+ reverse_proxy app:8080 {
+ header_up -True-Client-IP
+ header_up X-Real-IP {remote_host}
+ }
+ }
+
+ handle {
+ reverse_proxy web:80
+ }
}
diff --git a/app_config/fail2ban/README.md b/app_config/fail2ban/README.md
new file mode 100644
index 0000000..9d7b07b
--- /dev/null
+++ b/app_config/fail2ban/README.md
@@ -0,0 +1,123 @@
+# Beacon API abuse controls: edge log, block list, fail2ban
+
+Staged posture: **log → throttle (beacon-server 429s) → firewall-ban repeat offenders.** Real
+users include mobile users behind carrier CGNAT, so thresholds are generous and bans are short.
+
+Two deployment shapes, two file sets:
+
+| Shape | Edge | Files |
+|---|---|---|
+| Caddy in Docker (type 1, e.g. dev.meshcore.ca) | `docker-deployment-type1/data/Caddy/…` | `fail2ban/caddy/` |
+| Host Apache in front of the containers (analyzer.meshmapper.net) | `app_config/apache/analyzer-vhost.snippet.conf` | `fail2ban/apache/` |
+
+## 1. Edge: log + client-IP hygiene + manual block list
+
+**Caddy.** `Caddyfile.proxy` now writes a JSON access log to `data/Caddy/logs/access.json`, strips
+`True-Client-IP` and overwrites `X-Real-IP` on the way to the app (beacon-server trusts those headers
+for the client IP), and 403s anything listed in `data/Caddy/conf.d/blocklist.caddy`. The compose
+file mounts `conf.d` and `logs`; new mounts need one `docker compose up -d caddy`, after that:
+
+```bash
+docker compose exec caddy caddy validate --config /etc/caddy/Caddyfile
+docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile
+```
+
+**Apache.** Apply the snippet to the `*:443` vhost and create `/etc/apache2/beacon-blocklist.conf`
+and `/etc/apache2/beacon-blocklist-ua.conf`, then `apachectl configtest && systemctl reload apache2`.
+
+**Two ways to block.** By source IP (`remote_ip` / `Require not ip`) or by User-Agent signature
+(`@blocked_ua` / `SetEnvIfNoCase`). A scraper that identifies itself, like the
+`mesh.hansimgamr.net am-i-connected` client found on day 0, is better blocked by UA: it survives an
+IP change and leaves the operator's own browser alone. Both apply to `/api/*` and `/ws` only.
+
+Smoke test on either box (chi's log line must show the real client IP, not 1.2.3.4):
+
+```bash
+curl -s -o /dev/null -w '%{http_code}\n' -H 'True-Client-IP: 1.2.3.4' -H 'X-Real-IP: 1.2.3.4' https://$DOMAIN/api/v1/scopes
+docker compose logs --tail 5 app
+```
+
+## 2. fail2ban
+
+Two jails per box, same thresholds:
+
+| Jail | Counts | Trips at | Why |
+|---|---|---|---|
+| `beacon-api-flood` | every `/api/` request | 900 in 60 s | a browser session peaks ~150 in its first 30 s (auto-chained pages), then ~2/min; 15 req/s for a full minute is no browser |
+| `beacon-api-429` | `/api/` responses with status 429 | 300 in 10 min | only fires on clients that keep hammering through the server's throttle |
+
+Both use `backend = polling` (the Debian default `systemd` cannot tail files) and a flat 10 min ban.
+Install:
+
+```bash
+sudo cp filter.d/*.conf /etc/fail2ban/filter.d/
+sudo cp jail.d/beacon-http.conf /etc/fail2ban/jail.d/
+# Caddy box only: the ban chain must come up after dockerd
+sudo install -D fail2ban.service.d/after-docker.conf /etc/systemd/system/fail2ban.service.d/after-docker.conf
+sudo systemctl daemon-reload && sudo systemctl restart fail2ban
+sudo fail2ban-regex /etc/fail2ban/filter.d/beacon-api-flood.conf # date hits > 0, matches == /api/ lines
+sudo fail2ban-client status beacon-api-flood
+```
+
+**Observe first.** The Caddy jail file ships with `banaction = dummy`; on the Apache box add the same
+line per jail. Would-be bans land in `/var/run/fail2ban/fail2ban.dummy`. Run 3–7 days; every IP
+there must be an obvious scraper, otherwise raise `maxretry`.
+
+**Enforce.** Caddy box: traffic to Docker-published ports is DNAT'ed through the FORWARD path, which
+the default nftables action (input hook) never sees, so switch to
+`banaction = nftables[type=allports, chain=f2b-forward, chain_hook=forward]` (a second chain in the
+existing `f2b-table`; `iptables-allports[chain=DOCKER-USER]` is equivalent). Apache box: remove the
+`dummy` line, the inherited nftables input-hook action works because Apache listens on the host.
+Then:
+
+```bash
+sudo fail2ban-client reload
+sudo nft list table inet f2b-table # addr-set-beacon-api-flood present
+sudo fail2ban-client set beacon-api-flood banip # site unreachable from it
+sudo fail2ban-client set beacon-api-flood unbanip
+```
+
+Once beacon-server's per-IP limit is live, re-derive the flood `maxretry` as ~3× the per-minute
+allowance. Consider `bantime.increment` only after a clean week.
+
+## 3. Finding scrapers by hand
+
+Caddy JSON (`LOG=/opt/docker/beacon/data/Caddy/logs/access.json`):
+
+```bash
+# top IPs, last hour
+jq -r 'select(.ts > (now-3600)) | .request.remote_ip' "$LOG" | sort | uniq -c | sort -rn | head -20
+# IPs with >200 /api/ hits and zero /ws upgrades, last 24h (the SPA always opens a socket)
+jq -r 'select(.ts > (now-86400)) | [.request.remote_ip, (.request.uri|startswith("/api/")|tostring), (.request.uri=="/ws"|tostring)] | @tsv' "$LOG" \
+ | awk -F'\t' '{a[$1]+=($2=="true"); w[$1]+=($3=="true")} END {for (ip in a) if (a[ip]>200 && w[ip]==0) print a[ip], ip}' | sort -rn | head
+# IPs asking limit= above 200 (the SPA never does)
+jq -r '(.request.uri|capture("[?&]limit=(?[0-9]+)")?) as $c | select($c != null and ($c.n|tonumber) > 200) | .request.remote_ip' "$LOG" | sort | uniq -c | sort -rn | head
+# Referer / Origin hosts hitting /api/ (a reverse-proxying site usually forwards its visitors' Referer)
+jq -r 'select(.request.uri|startswith("/api/")) | [(.request.headers.Referer[0] // "-"), (.request.headers.Origin[0] // "-")] | @tsv' "$LOG" \
+ | sed -E 's#(https?://[^/[:space:]]+)[^[:space:]]*#\1#g' | sort | uniq -c | sort -rn | head -30
+# IPs with >50 /api/ hits and no SPA/asset fetch, last 24h (a native app looks like this too)
+jq -r 'select(.ts > (now-86400)) | [.request.remote_ip, (.request.uri|startswith("/api/")|tostring), ((.request.uri|startswith("/api/")|not) and .request.uri!="/ws"|tostring)] | @tsv' "$LOG" \
+ | awk -F'\t' '{a[$1]+=($2=="true"); s[$1]+=($3=="true")} END {for (ip in a) if (a[ip]>50 && s[ip]==0) print a[ip], ip}' | sort -rn | head
+# peak /api/ requests per IP per minute: calibrate maxretry / the server limit at ~3x the largest legitimate bucket
+jq -r 'select(.request.uri|startswith("/api/")) | "\(.request.remote_ip) \((.ts/60)|floor)"' "$LOG" | sort | uniq -c | sort -rn | head -20
+# spoof attempts (client-sent IP headers are logged as received)
+jq -r 'select(.request.headers["True-Client-IP"] or .request.headers["X-Real-IP"]) | [.request.remote_ip, (.request.headers["True-Client-IP"][0]//"-")] | @tsv' "$LOG" | sort | uniq -c | sort -rn | head
+```
+
+Apache combined (`LOG=/var/log/apache2/analyzer-ssl-access.log`, run with sudo; `$7` is the request
+path, `$11` the quoted Referer):
+
+```bash
+awk '{print $1}' "$LOG" | sort | uniq -c | sort -rn | head -20
+awk '$7 ~ /^\/api\// {a[$1]++} $7=="/ws" {w[$1]++} END {for (ip in a) if (a[ip]>200 && !(ip in w)) print a[ip], ip}' "$LOG" | sort -rn | head
+awk '$7 ~ /^\/api\/.*[?&]limit=/ {match($7,/[?&]limit=[0-9]+/); n=substr($7,RSTART+7,RLENGTH-7); if (n+0>200) print $1}' "$LOG" | sort | uniq -c | sort -rn | head
+awk '$7 ~ /^\/api\// {print $11}' "$LOG" | sed -E 's#"(https?://[^/"]+).*#\1#' | sort | uniq -c | sort -rn | head -30
+awk '$7 ~ /^\/api\// {a[$1]++} $7 !~ /^\/api\// && $7!="/ws" {s[$1]++} END {for (ip in a) if (a[ip]>50 && !(ip in s)) print a[ip], ip}' "$LOG" | sort -rn | head
+awk '$7 ~ /^\/api\// {print $1, substr($4,2,17)}' "$LOG" | sort | uniq -c | sort -rn | head -20
+```
+
+A site that reverse-proxies the API shows up as one high-volume, API-only IP with no `/ws` and
+usually a foreign Referer. Put it in the block list and reload.
+
+Operations: `fail2ban-client status `, `fail2ban-client get banip --with-time`,
+`fail2ban-client set unbanip `, `fail2ban-client reload ` after editing a filter.
diff --git a/app_config/fail2ban/apache/filter.d/beacon-api-429.conf b/app_config/fail2ban/apache/filter.d/beacon-api-429.conf
new file mode 100644
index 0000000..5a0d409
--- /dev/null
+++ b/app_config/fail2ban/apache/filter.d/beacon-api-429.conf
@@ -0,0 +1,5 @@
+# fail2ban filter: beacon-api-429
+# Requests to /api/ that beacon-server already throttled (status 429). Only persistence is punished.
+[Definition]
+failregex = ^ \S+ \S+ \[?\]? ?"[A-Z]+ /api/[^"]*" 429
+ignoreregex =
diff --git a/app_config/fail2ban/apache/filter.d/beacon-api-flood.conf b/app_config/fail2ban/apache/filter.d/beacon-api-flood.conf
new file mode 100644
index 0000000..89f5b5f
--- /dev/null
+++ b/app_config/fail2ban/apache/filter.d/beacon-api-flood.conf
@@ -0,0 +1,6 @@
+# fail2ban filter: beacon-api-flood
+# Apache `combined` log (Ubuntu variant). Every request to /api/ from an IP counts as one hit;
+# the jail's maxretry/findtime decide what "too many" is. fail2ban has already cut the date out.
+[Definition]
+failregex = ^ \S+ \S+ \[?\]? ?"[A-Z]+ /api/
+ignoreregex =
diff --git a/app_config/fail2ban/apache/jail.d/beacon-http.conf b/app_config/fail2ban/apache/jail.d/beacon-http.conf
new file mode 100644
index 0000000..1752db2
--- /dev/null
+++ b/app_config/fail2ban/apache/jail.d/beacon-http.conf
@@ -0,0 +1,31 @@
+# Beacon API jails for a host-Apache deployment (analyzer.meshmapper.net shape).
+# Same conventions as meshmapper-http.conf on that box: no [DEFAULT], backend = polling per jail,
+# ignoreip = loopback, Tailscale, the Subscriber.
+# banaction = dummy is observe mode (would-be bans land in /var/run/fail2ban/fail2ban.dummy).
+# To enforce, delete those lines: the inherited default (nftables, input hook) works as-is because
+# Apache listens on the host. bantime is a flat 10m to start: a banned CGNAT address hurts every
+# user behind it.
+
+[beacon-api-flood]
+enabled = true
+backend = polling
+filter = beacon-api-flood
+port = http,https
+banaction = dummy
+logpath = /var/log/apache2/analyzer-ssl-access.log
+maxretry = 900
+findtime = 60
+bantime = 10m
+ignoreip = 127.0.0.1/8 ::1 100.64.0.0/10 192.99.151.15
+
+[beacon-api-429]
+enabled = true
+backend = polling
+filter = beacon-api-429
+port = http,https
+banaction = dummy
+logpath = /var/log/apache2/analyzer-ssl-access.log
+maxretry = 300
+findtime = 600
+bantime = 10m
+ignoreip = 127.0.0.1/8 ::1 100.64.0.0/10 192.99.151.15
diff --git a/app_config/fail2ban/caddy/fail2ban.service.d/after-docker.conf b/app_config/fail2ban/caddy/fail2ban.service.d/after-docker.conf
new file mode 100644
index 0000000..cd64841
--- /dev/null
+++ b/app_config/fail2ban/caddy/fail2ban.service.d/after-docker.conf
@@ -0,0 +1,4 @@
+# /etc/systemd/system/fail2ban.service.d/after-docker.conf
+# The forward-hook / DOCKER-USER ban chain only makes sense once dockerd has set up its rules.
+[Unit]
+After=docker.service
diff --git a/app_config/fail2ban/caddy/filter.d/beacon-api-429.conf b/app_config/fail2ban/caddy/filter.d/beacon-api-429.conf
new file mode 100644
index 0000000..5601052
--- /dev/null
+++ b/app_config/fail2ban/caddy/filter.d/beacon-api-429.conf
@@ -0,0 +1,7 @@
+# fail2ban filter: beacon-api-429
+# Requests to /api/ that beacon-server already throttled (status 429). A client that keeps hammering
+# through 429s is ignoring the app's own limit; this jail only enforces persistence.
+[Definition]
+datepattern = "ts":{EPOCH}
+failregex = ^\{"level":"\w+",.*?"request":\{"remote_ip":"","remote_port":"\d+",.*?"uri":"/api/.*?"status":429,
+ignoreregex =
diff --git a/app_config/fail2ban/caddy/filter.d/beacon-api-flood.conf b/app_config/fail2ban/caddy/filter.d/beacon-api-flood.conf
new file mode 100644
index 0000000..d022424
--- /dev/null
+++ b/app_config/fail2ban/caddy/filter.d/beacon-api-flood.conf
@@ -0,0 +1,7 @@
+# fail2ban filter: beacon-api-flood
+# Caddy JSON access log, one object per line. Every request to /api/ from an IP counts as one hit;
+# the jail's maxretry/findtime decide what "too many" is.
+[Definition]
+datepattern = "ts":{EPOCH}
+failregex = ^\{"level":"\w+",.*?"request":\{"remote_ip":"","remote_port":"\d+",.*?"uri":"/api/
+ignoreregex =
diff --git a/app_config/fail2ban/caddy/jail.d/beacon-http.conf b/app_config/fail2ban/caddy/jail.d/beacon-http.conf
new file mode 100644
index 0000000..2f45414
--- /dev/null
+++ b/app_config/fail2ban/caddy/jail.d/beacon-http.conf
@@ -0,0 +1,36 @@
+# Beacon API jails for a Caddy-in-Docker deployment (type 1). See app_config/fail2ban/README.md.
+#
+# DELIBERATE choices:
+# - No [DEFAULT] section: it would merge into the global default and change the sshd jail.
+# - backend = polling per jail: the Debian default is systemd, which cannot tail log FILES.
+# - banaction: traffic to Docker-published 80/443 is DNAT'ed through the FORWARD path, so the
+# default nftables action (input hook) never sees it. Start in observe mode (dummy), then switch
+# to a forward-hook chain in the existing f2b-table:
+# nftables[type=allports, chain=f2b-forward, chain_hook=forward]
+# (equivalent: iptables-allports[chain=DOCKER-USER])
+# - ignoreip: loopback, RFC1918 (Docker bridges), Tailscale. Add the operator's home IP if wanted.
+# - bantime is a flat 10m: a banned CGNAT address hurts every user behind it.
+
+[beacon-api-flood]
+enabled = true
+backend = polling
+filter = beacon-api-flood
+logpath = /opt/docker/beacon/data/Caddy/logs/access.json
+banaction = dummy
+ignoreip = 127.0.0.0/8 ::1 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 100.64.0.0/10
+findtime = 60
+maxretry = 900
+bantime = 10m
+maxmatches = 5
+
+[beacon-api-429]
+enabled = true
+backend = polling
+filter = beacon-api-429
+logpath = /opt/docker/beacon/data/Caddy/logs/access.json
+banaction = dummy
+ignoreip = 127.0.0.0/8 ::1 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 100.64.0.0/10
+findtime = 10m
+maxretry = 300
+bantime = 10m
+maxmatches = 5
diff --git a/docker-deployment-type1/data/Caddy/CaddyFile/Caddyfile.proxy b/docker-deployment-type1/data/Caddy/CaddyFile/Caddyfile.proxy
index 10142c6..88835dd 100644
--- a/docker-deployment-type1/data/Caddy/CaddyFile/Caddyfile.proxy
+++ b/docker-deployment-type1/data/Caddy/CaddyFile/Caddyfile.proxy
@@ -1,13 +1,54 @@
{$DOMAIN} {
- handle /api/* {
- reverse_proxy tower-app:8080
- }
+ # defines @blocked_ip and @blocked_ua; edit conf.d/blocklist.caddy then `caddy reload`
+ import conf.d/blocklist.caddy
- handle /ws {
- reverse_proxy tower-app:8080
- }
+ # JSON access log for the whole site (API, SPA and assets together, so per-IP joins are one file)
+ log {
+ output file /var/log/caddy/access.json {
+ mode 0644
+ roll_size 100MiB
+ roll_keep 20
+ roll_keep_for 720h
+ }
+ format json
+ }
- handle {
- reverse_proxy tower-web:80
- }
+ # opt-in: 403 API calls whose Referer names another site. Spoofable; enable only if the
+ # logs show a specific foreign host with sustained volume.
+ # @foreign_referer {
+ # header Referer *
+ # not header Referer https://{$DOMAIN}/*
+ # }
+
+ handle /api/* {
+ respond @blocked_ip "Forbidden" 403 {
+ close
+ }
+ respond @blocked_ua "Forbidden" 403 {
+ close
+ }
+ # respond @foreign_referer "Forbidden" 403
+ reverse_proxy app:8080 {
+ # the app trusts these headers for the client IP, so only Caddy may set them
+ header_up -True-Client-IP
+ header_up X-Real-IP {remote_host}
+ }
+ }
+
+ handle /ws {
+ respond @blocked_ip "Forbidden" 403 {
+ close
+ }
+ respond @blocked_ua "Forbidden" 403 {
+ close
+ }
+ reverse_proxy app:8080 {
+ header_up -True-Client-IP
+ header_up X-Real-IP {remote_host}
+ }
+ }
+
+ handle {
+ reverse_proxy web:80
+ }
}
diff --git a/docker-deployment-type1/data/Caddy/conf.d/blocklist.caddy b/docker-deployment-type1/data/Caddy/conf.d/blocklist.caddy
new file mode 100644
index 0000000..6de87d4
--- /dev/null
+++ b/docker-deployment-type1/data/Caddy/conf.d/blocklist.caddy
@@ -0,0 +1,13 @@
+# Manual block list for /api/* and /ws.
+# @blocked_ip: one IP or CIDR per remote_ip line. Keep at least one line: 192.0.2.0/24 is TEST-NET
+# (never routable) and is a safe placeholder.
+# @blocked_ua: one header_regexp line per scraper User-Agent signature (Go regexp; (?i) = case-insensitive).
+# Apply changes with:
+# docker compose exec caddy caddy validate --config /etc/caddy/Caddyfile
+# docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile
+@blocked_ip {
+ remote_ip 192.0.2.0/24
+}
+@blocked_ua {
+ header_regexp User-Agent "(?i)mesh\.hansimgamr\.net"
+}
diff --git a/docker-deployment-type1/docker-compose.yml b/docker-deployment-type1/docker-compose.yml
index a7da144..5e728e8 100644
--- a/docker-deployment-type1/docker-compose.yml
+++ b/docker-deployment-type1/docker-compose.yml
@@ -53,6 +53,8 @@ services:
env_file: .env
volumes:
- ./data/Caddy/CaddyFile/Caddyfile.proxy:/etc/caddy/Caddyfile
+ - ./data/Caddy/conf.d:/etc/caddy/conf.d:ro
+ - ./data/Caddy/logs:/var/log/caddy
- ./data/Caddy/data:/data/caddy/
- ./data/Caddy/config:/config/caddy/
depends_on: