feat(docker): zero-touch Cloudflare Tunnel provisioning from API token

Make the tunnel ingress fully automatic from env vars — no dashboard clicks,
no cloudflared login, no cert.pem. A one-shot tunnel-init step (reusing the
archivebox image's python, so no extra image) uses CLOUDFLARE_DNS_API_TOKEN to:
create/reuse a tunnel named archivebox-<domain>, set its ingress
(*.<domain> + <domain> -> archivebox:8000, Host-routed), upsert the proxied
apex+wildcard CNAMEs to the tunnel, and write the connector token; cloudflared
then runs it (depends_on service_completed_successfully). Idempotent.

Add .env.example documenting BASE_URL / SERVER_SECURITY_MODE / COMPOSE_PROFILES
/ CLOUDFLARE_DNS_API_TOKEN as the only required config.
This commit is contained in:
archivebox 2026-06-04 23:06:30 +00:00
parent f50aa0f274
commit f5e47d8197
No known key found for this signature in database
2 changed files with 79 additions and 7 deletions

16
.env.example Normal file
View File

@ -0,0 +1,16 @@
# Copy to `.env` next to docker-compose.yml; `docker compose up -d` reads it automatically.
# Local default needs none of this — it's only for a public/HTTPS deployment.
BASE_URL=https://archive.example.com
SERVER_SECURITY_MODE=safe-subdomains-fullreplay
REVERSE_PROXY_TRUST_FORWARDED_PROTO=True
# Pick ONE ingress (see docker-compose.yml). Activates its profile with no extra flags:
# tunnel = Cloudflare Tunnel (no public IP needed) https = Caddy + Let's Encrypt (public IP)
COMPOSE_PROFILES=tunnel
# Cloudflare API token. For `tunnel`: Account:Cloudflare Tunnel:Edit + Zone:DNS:Edit + Zone:Read.
# For `https` (lego DNS-01): Zone:DNS:Edit + Zone:Read. Everything else is provisioned for you.
CLOUDFLARE_DNS_API_TOKEN=
# CLOUDFLARE_ACCOUNT_ID= # optional (tunnel only); first account is used if unset
# ARCHIVEBOX_ACME_EMAIL=admin@example.com # https profile: ACME contact email

View File

@ -92,17 +92,73 @@ services:
# Cloudflare's edge terminates TLS and resolves *.your.domain to a SINGLE tunnel;
# every snapshot/control subdomain rides one connection to archivebox:8000, which
# routes by Host header — so the tunnel itself needs no wildcard cert or per-host
# config. In Cloudflare Zero Trust create a tunnel with a public hostname
# `*.your.domain` (and `your.domain`) -> http://archivebox:8000, then paste its
# token below. (A DNS API token alone can't create tunnels; this token is separate.)
# config. ZERO manual setup: the one-shot tunnel-init below uses your
# CLOUDFLARE_DNS_API_TOKEN (give it Account:Cloudflare Tunnel:Edit + Zone:DNS:Edit
# + Zone:Read) to create/reuse the tunnel, point *.your.domain and your.domain at
# it, and write its connector token — then cloudflared just runs it.
tunnel-init:
image: ${ARCHIVEBOX_IMAGE:-archivebox/archivebox:dev} # reuse the image (its python); no extra image
profiles: ["tunnel"]
restart: "no"
environment:
- BASE_URL=${BASE_URL:-https://archive.example.com}
- CLOUDFLARE_DNS_API_TOKEN=${CLOUDFLARE_DNS_API_TOKEN:-}
- CLOUDFLARE_ACCOUNT_ID=${CLOUDFLARE_ACCOUNT_ID:-} # optional; first account used if unset
- TUNNEL_SERVICE=http://archivebox:8000
- TUNNEL_TOKEN_OUT=/shared/token
volumes:
- ./data/proxy/tunnel:/shared
entrypoint:
- python3
- -c
- |
import os, json, base64, secrets, urllib.request, urllib.error
API = "https://api.cloudflare.com/client/v4"
TOKEN = os.environ["CLOUDFLARE_DNS_API_TOKEN"]
DOMAIN = os.environ.get("BASE_URL", "").split("://")[-1].split("/")[0].split(":")[0]
SERVICE = os.environ.get("TUNNEL_SERVICE", "http://archivebox:8000")
OUT = os.environ.get("TUNNEL_TOKEN_OUT", "/shared/token")
H = {"Authorization": "Bearer " + TOKEN, "Content-Type": "application/json"}
def call(method, path, data=None):
body = json.dumps(data).encode() if data is not None else None
req = urllib.request.Request(API + path, data=body, headers=H, method=method)
try:
with urllib.request.urlopen(req, timeout=30) as r: return json.load(r)
except urllib.error.HTTPError as e: return json.load(e)
assert DOMAIN and TOKEN, "set BASE_URL (https://archive.example.com) + CLOUDFLARE_DNS_API_TOKEN"
acct = os.environ.get("CLOUDFLARE_ACCOUNT_ID", "").strip() or call("GET", "/accounts")["result"][0]["id"]
zone = call("GET", f"/zones?name={DOMAIN}")["result"][0]["id"]
NAME = "archivebox-" + DOMAIN.replace(".", "-")
ts = call("GET", f"/accounts/{acct}/cfd_tunnel?name={NAME}&is_deleted=false")["result"]
tid = ts[0]["id"] if ts else call("POST", f"/accounts/{acct}/cfd_tunnel", {"name": NAME,
"tunnel_secret": base64.b64encode(secrets.token_bytes(32)).decode(), "config_src": "cloudflare"})["result"]["id"]
target = f"{tid}.cfargotunnel.com"
call("PUT", f"/accounts/{acct}/cfd_tunnel/{tid}/configurations", {"config": {"ingress": [
{"hostname": f"*.{DOMAIN}", "service": SERVICE}, {"hostname": DOMAIN, "service": SERVICE},
{"service": "http_status:404"}]}})
for name in (DOMAIN, f"*.{DOMAIN}"):
recs = call("GET", f"/zones/{zone}/dns_records?name={name}")["result"]
cname = [r for r in recs if r["type"] == "CNAME"]
for r in [r for r in recs if r["type"] in ("A", "AAAA")] + cname[1:]:
call("DELETE", f"/zones/{zone}/dns_records/{r['id']}")
desired = {"type": "CNAME", "name": name, "content": target, "proxied": True, "ttl": 1}
call("PUT", f"/zones/{zone}/dns_records/{cname[0]['id']}", desired) if cname else call("POST", f"/zones/{zone}/dns_records", desired)
tok = call("GET", f"/accounts/{acct}/cfd_tunnel/{tid}/token")["result"]
os.makedirs(os.path.dirname(OUT) or ".", exist_ok=True); open(OUT, "w").write(tok)
print(f"[tunnel-init] {NAME} ({tid}): *.{DOMAIN} + {DOMAIN} -> {SERVICE}; connector token -> {OUT}")
cloudflared:
image: cloudflare/cloudflared
profiles: ["tunnel"]
restart: unless-stopped
depends_on: [archivebox]
command: tunnel --no-autoupdate --protocol http2 run
environment:
- TUNNEL_TOKEN=${TUNNEL_TOKEN:-}
depends_on:
archivebox:
condition: service_started
tunnel-init:
condition: service_completed_successfully
command: tunnel --no-autoupdate --protocol http2 run --token-file /shared/token
volumes:
- ./data/proxy/tunnel:/shared:ro
### Option B — Caddy + automatic Let's Encrypt wildcard cert (you have a public IP).
# The lego sidecar fetches ONE *.your.domain wildcard cert via DNS-01 (≈150