orchard
mirrorEvery site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.
blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics
1#!/bin/sh
2# Create, inspect and tear down the Cloudflare Tunnel that fronts this repo. Run
3# these from the repo root, where the first three are make targets:
4#
5# make tunnel-login once per zone: browser auth, writes cert.pem
6# make tunnel create the tunnel, route DNS, write config.yml
7# make tunnel-status what exists right now
8# sh edge/setup-tunnel.sh down delete the tunnel and its volume
9#
10# `down` has no target on purpose, since deleting the tunnel is not something to
11# have one keystroke away from `make doctor`.
12#
13# All cloudflared state lives in a named volume, never a bind mount. The Docker
14# CLI here talks to Docker Desktop on the Windows host, whose daemon cannot see
15# this filesystem, so a bind mount silently resolves to an empty directory.
16#
17# Every docker command goes through sudo, because the socket in the webdev
18# container is root:root mode 660 and being in the docker group does not help.
19# On a host where docker needs no sudo: make tunnel SUDO=
20set -e
21
22# Resolved before the cd below, since $0 is relative to the caller's
23# directory and stops resolving the moment we leave it.
24SELF="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")"
25
26# Run from this script's own directory, whatever the caller's was, since every
27# path below is relative to edge/.
28cd "$(dirname "$0")"
29
30SUDO=${SUDO-sudo}
31DOCKER_BIN=$(command -v docker) || { echo "no docker on PATH" >&2; exit 1; }
32# An absolute path, because `command` is a shell builtin and sudo cannot exec
33# one, and because sudo's secure_path is not this shell's PATH.
34docker() { ${SUDO} "$DOCKER_BIN" "$@"; }
35
36VOLUME=orchard-cloudflared
37TUNNEL=orchard
38IMAGE=cloudflare/cloudflared:latest
39# Every hostname the tunnel serves, matching the ingress rules in
40# cloudflared/config.yml. These span two zones and cert.pem covers one at a time,
41# so routing all of them means logging in once per zone and running `up` again.
42# The per-host failures in between are expected.
43HOSTNAMES="isaacbythewood.com www.isaacbythewood.com bythewood.me www.bythewood.me blog.bythewood.me analytics.bythewood.me auth.bythewood.me status.bythewood.me logging.bythewood.me ntfy.bythewood.me repos.bythewood.me dash.bythewood.me search.bythewood.me chat.bythewood.me llm.bythewood.me"
44
45# cloudflared's image is distroless with no shell, so anything that needs to
46# poke at the volume borrows a plain alpine.
47volume_sh() {
48 docker run --rm -v "$VOLUME:/etc/cloudflared" alpine:3 sh -c "$1"
49}
50
51# Every command except login has to be told where the origin cert is, or it
52# looks in $HOME/.cloudflared, reports "cert.pem not found" and tells you to log
53# in again on a machine that already has.
54cfd() {
55 docker run --rm -v "$VOLUME:/etc/cloudflared" "$IMAGE" \
56 --origincert /etc/cloudflared/cert.pem "$@"
57}
58
59ensure_volume() {
60 docker volume inspect "$VOLUME" >/dev/null 2>&1 || docker volume create "$VOLUME" >/dev/null
61 # The image runs as uid 65532 and a fresh volume is root-owned. Without this
62 # `tunnel login` authenticates and then dies with EACCES writing cert.pem,
63 # burning a one-time callback token.
64 volume_sh "chown -R 65532:65532 /etc/cloudflared"
65}
66
67case "${1:-}" in
68login)
69 ensure_volume
70 # Mounted at the image's HOME rather than /etc/cloudflared, because login
71 # ignores --origincert and writes to $HOME/.cloudflared unconditionally.
72 # Mount it elsewhere and it reports success while writing the cert into the
73 # container layer, taking the one-time callback token with it.
74 #
75 # No TTY either, since login only prints a URL and polls the callback.
76 docker run --rm -v "$VOLUME:/home/nonroot/.cloudflared" "$IMAGE" tunnel login
77 volume_sh "chown -R 65532:65532 /etc/cloudflared"
78 ;;
79
80up)
81 ensure_volume
82 cfd tunnel create "$TUNNEL" || true
83
84 # The credentials file is named after the tunnel id, so the id reads straight
85 # off the volume instead of out of `tunnel list`.
86 ID=$(volume_sh "ls /etc/cloudflared" | grep -E '^[0-9a-f-]{36}\.json$' | head -1 | sed 's/\.json$//')
87 if [ -z "$ID" ]; then
88 echo "could not determine tunnel id, run: make tunnel-status" >&2
89 exit 1
90 fi
91 echo "tunnel id: $ID"
92
93 # ONE ZONE ONLY. cert.pem carries a single zoneID picked in the browser
94 # during `login`. Handed a hostname from another zone this does not fail, it
95 # treats the whole string as a subdomain and creates
96 # "blog.bythewood.me.isaacbythewood.com". Routing a second zone means logging
97 # in again to replace cert.pem, which is safe for a running tunnel since that
98 # authenticates with the credentials JSON instead.
99 #
100 # ONE LABEL ONLY. Cloudflare's free Universal SSL signs the apex and a single
101 # wildcard level, so a two-label host like next.blog.bythewood.me has no
102 # certificate and fails the TLS handshake. Use a hyphen instead.
103 for host in $HOSTNAMES; do
104 cfd tunnel route dns "$TUNNEL" "$host" || true
105 done
106
107 # Seeded over stdin rather than mounted, for the same reason as everything
108 # else here. The id appears twice in the template so the substitution is
109 # global, and it renders to a temp file first because a sed failure in the
110 # middle of a pipeline does not fail the pipeline.
111 rendered=$(mktemp)
112 trap 'rm -f "$rendered"' EXIT
113 sed "s/CHANGEME_TUNNEL_ID/$ID/g" cloudflared/config.yml > "$rendered"
114 grep -q CHANGEME_TUNNEL_ID "$rendered" && {
115 echo "tunnel id was not substituted into config.yml" >&2
116 exit 1
117 }
118 docker run --rm -i -v "$VOLUME:/etc/cloudflared" alpine:3 \
119 sh -c 'cat > /etc/cloudflared/config.yml && chown 65532:65532 /etc/cloudflared/config.yml' \
120 < "$rendered"
121
122 echo "config.yml written. now, from the repo root: make edge"
123 ;;
124
125status)
126 echo "--- volume ---"
127 volume_sh "ls -la /etc/cloudflared" || echo "no volume"
128 echo "--- tunnels ---"
129 cfd tunnel list || true
130 echo "--- containers ---"
131 docker ps --filter name=orchard --format '{{.Names}}\t{{.Status}}'
132 ;;
133
134down)
135 docker compose down --remove-orphans || true
136 cfd tunnel delete -f "$TUNNEL" || true
137 docker volume rm "$VOLUME" || true
138 # `tunnel route dns` creates records and cloudflared has no delete
139 # counterpart, so the CNAME outlives the tunnel and has to go from the
140 # dashboard. Until it does the hostname answers 530, error 1033.
141 echo
142 echo "NOTE: the CNAME for $HOSTNAMES still exists and must be deleted"
143 echo "in the Cloudflare dashboard. Until then it returns 530."
144 ;;
145
146*)
147 awk 'NR>1 && /^#/ { sub(/^#[ ]?/, ""); print; next } NR>1 { exit }' "$SELF"
148 exit 1
149 ;;
150esac