repos
/ orchard main

orchard

mirror

Every 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

9.2 KB · 214 lines · markdown Raw History
  1# orchard
  2
  3Every site I host, in one repo, along with the Cloudflare Tunnel and Caddy that
  4front them. All of it runs on a desktop at home and nothing listens on an
  5inbound port.
  6
  7| Directory | Site |
  8|---|---|
  9| `sites/isaacbythewood.com/` | Portfolio. Go and Vite, no third party Go dependencies |
 10| `sites/blog.bythewood.me/` | Markdown blog. A PDF and a social card are built per post |
 11| `sites/analytics.bythewood.me/` | Analytics. SQLite, GeoIP, Typst PDF reports |
 12| `sites/status.bythewood.me/` | Uptime monitoring. SQLite, Lighthouse audits, crawler |
 13| `sites/logging.bythewood.me/` | Log aggregation. Every site ships its records here |
 14| `sites/repos.bythewood.me/` | Git remote you push to over HTTPS. Also mirrors GitHub |
 15| `sites/dash.bythewood.me/` | Dashboard. Markets, news, weather and the health of the rest, live over SSE |
 16| `sites/auth.bythewood.me/` | The front door. One account, a code pushed to a phone, one session for every site |
 17| `edge/` | The shared cloudflared tunnel, the Caddy behind it, and ntfy for alerts |
 18
 19## Requirements
 20
 21Docker, Go and bun. A Cloudflare account with a zone you control, since the
 22whole thing is served through a tunnel. Everything else comes out of the
 23Dockerfiles.
 24
 25## Getting it running
 26
 27### From nothing
 28
 29A fresh clone on a machine where none of this exists, or a machine where it all
 30went down at once:
 31
 32```sh
 33make install
 34```
 35
 36That opens a browser to authorise a Cloudflare zone, creates the tunnel and
 37routes every hostname to it, writes a `.env` for each site that needs one,
 38brings the edge and all eight sites up, creates the three ntfy accounts, mints
 39the publishers' tokens into the `.env` files that use them, hands them to the
 40running sites, creates the login account and prints its recovery codes, and ends
 41on `doctor`.
 42
 43Nothing asks you to invent a password. Every one it needs is generated and
 44printed as it goes, and so are the ten recovery codes the first sign in uses.
 45Those are the only copies, so put them in 1Password while they are on screen.
 46`make password` prints another whenever you want one.
 47
 48Signing in is on `auth.bythewood.me`: a username, then a six digit code pushed
 49to a phone over ntfy. The other sites hold no password of their own and ask it
 50whether the cookie on a request is a live session, so a session revoked there is
 51gone everywhere on the next request.
 52
 53`cert.pem` covers a single Cloudflare zone, and the hostnames here span two, so
 54the DNS routes for the second zone fail the first time through. Run
 55`make tunnel-login` and `make tunnel` again, pick the other zone in the browser,
 56and the rest are created.
 57
 58Then point the ntfy Android client at `https://ntfy.bythewood.me` with the
 59reading account it printed, and subscribe to `status` and `logging`.
 60
 61Each step is a target of its own, for a run that stopped halfway:
 62
 63```sh
 64make tunnel-login   # browser auth, one Cloudflare zone at a time
 65make tunnel         # create the tunnel, route DNS, write config.yml
 66make env            # a .env per site, passwords generated and printed
 67make up             # the edge, then every site, ending in doctor
 68make ntfy           # the two alert accounts
 69make ntfy-token     # the publishers' token, into the two .env files
 70make up             # hand the token to the sites that publish with it
 71```
 72
 73`up` has to come before `ntfy`, since the accounts are created inside a running
 74container. Everything else is safe to re-run at any time, and `make env` never
 75touches a `.env` that already exists.
 76
 77A different domain means editing three things before the tunnel step: the
 78`HOSTNAMES` line in `edge/setup-tunnel.sh`, the ingress rules in
 79`edge/cloudflared/config.yml`, and the site blocks in `edge/caddy/Caddyfile`.
 80Each site's own hostname is a constant in its `site.go`.
 81
 82### After you change something
 83
 84```sh
 85make deploy SITE=blog.bythewood.me
 86```
 87
 88That is the only command that rebuilds. Nothing needs exporting first, since
 89the four sites with secrets read them from a `.env` beside their compose file.
 90
 91Editing anything in `edge/` needs `make edge` instead, because those configs are
 92baked into images and `make up` does not rebuild.
 93
 94### When something is broken
 95
 96```sh
 97make doctor
 98```
 99
100Read-only. It prints the tunnel credentials, the network, both edge containers,
101every site, and the SQLite volumes with their sizes, and puts the command that
102fixes it next to anything wrong. A container that is stopped or unhealthy
103usually wants `make up`; one serving the wrong thing wants `make deploy`.
104
105## Commands
106
107```sh
108make install                         a machine that has never run this
109make up                              everything, from nothing or from broken
110make deploy SITE=blog.bythewood.me   rebuild one site and replace it
111make edge                            rebuild the edge after editing edge/
112make doctor                          tunnel, network, containers, data volumes
113make down                            stop everything
114
115make tunnel-login                    browser auth for one Cloudflare zone
116make tunnel                          create the tunnel, route DNS, write config
117make tunnel-status                   what the tunnel has right now
118make env                             a .env per site, passwords generated
119make password                        print a suggested password, writing nothing
120make ntfy                            create the two alert accounts
121make ntfy-token                      mint the publishers' token into the .env files
122make ntfy-status                     accounts, access and tokens
123make ntfy-passwd                     change the reading account's password
124
125make run SITE=blog.bythewood.me      vite watch + go run, on :8000
126make build SITE=blog.bythewood.me    frontend, then a release binary in bin/
127make check                           gofmt, then vet and build every site
128make test                            every site's tests
129```
130
131The development targets touch no Docker. Everything else goes through `sudo`,
132because the socket in the webdev container is `root:root` mode 660. On a host
133where docker needs no sudo: `make up SUDO=`.
134
135Every site has `run`, `build` and `clean` in its own `Makefile`, so you can work
136inside one without going through the root. There is no default `SITE`.
137
138## Layout
139
140Each site is its own Go module and builds on its own. There is no module at the
141repo root, and `go.work` only exists so repo-wide `make` targets and an editor
142can see all eight at once:
143
144```sh
145cd sites/blog.bythewood.me && GOWORK=off go build ./...
146```
147
148Each site also carries its own copy of `web/`, the small HTTP layer they all
149need. That means a site is a directory you can lift into its own repository, and
150its Docker build context is that directory instead of the whole monorepo. The
151cost is that a fix in `web/` has to be made eight times.
152
153Go serves every request and `html/template` renders the pages. Vite is a build
154step and never a server: it writes content-hashed JS and CSS into `build/dist/`,
155and the Go binary reads `build/dist/.vite/manifest.json` to turn those hashes
156into script and link tags. Typst does the typesetting at build time for post
157PDFs, the resume, and the social card each site serves as its `og:image`.
158
159Everything generated lands in `sites/<name>/build/`, which is gitignored.
160`make build` passes `-tags embed` so `//go:embed` compiles that directory into
161the binary, which is how blog and the portfolio come out as a single file.
162
163## The edge
164
165One cloudflared and one Caddy serve the whole repo. Traffic is outbound only,
166so there is no port forwarding, no dynamic DNS, and the origin IP is never
167published. Every container listens on 8000 internally and nothing is published
168to the host.
169
170Adding a hostname takes three changes: a site block in `edge/caddy/Caddyfile`,
171an ingress rule in `edge/cloudflared/config.yml`, and a proxied CNAME to
172`<tunnel-id>.cfargotunnel.com`.
173
174Caddy also writes its access log to `logging.bythewood.me`, over a plain socket
175on port 9001 rather than through the shipper every site uses, since Caddy can't
176carry a Go handler. It keeps writing the same lines to stderr as well, so
177`docker logs orchard-caddy` is unchanged. cloudflared and ntfy don't ship
178anywhere, because neither can write its log to a network address and pointing
179either at a file takes its stdout away.
180
181ntfy runs in the edge and is how anything here reaches a phone. status publishes
182outage transitions and logging publishes silence transitions, both over the
183internal bridge. Reading is over the tunnel with a read-only account.
184
185## Things that will bite you
186
187Editing an `edge/` config and running `make up` does nothing. Those configs are
188baked into images. Use `make edge`.
189
190A `/data` volume created root-owned stays root-owned and needs a one-time
191`chown -R 65532:65532`.
192
193`.env` is gitignored by bare name and this repository is public, so it is worth
194checking rather than trusting:
195
196```sh
197git check-ignore -v sites/status.bythewood.me/.env
198```
199
200Bind mounts of paths under `/home/dev` silently mount an empty directory,
201because the daemon is Docker Desktop on the Windows host and resolves the source
202against its own filesystem. Use named volumes.
203
204An alerter running on this machine cannot tell you this machine lost power. A
205tunnel that drops is covered from the other side, since Cloudflare sends its own
206tunnel health notification.
207
208`CLAUDE.md` has the rest: the conventions, and the mistakes that have already
209been made once.
210
211## License
212
213BSD 2-Clause. See `LICENSE.md`.