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

main

Branches

..
README.md 543 B
middleware.go 6.4 KB
middleware_test.go 1.7 KB
server.go 2.5 KB
session.go 5.0 KB
shipper.go 9.1 KB
shipper_test.go 9.8 KB
README.md

orchard

Every site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. All of it runs on a desktop at home and nothing listens on an inbound port.

Directory Site
sites/isaacbythewood.com/ Portfolio. Go and Vite, no third party Go dependencies
sites/blog.bythewood.me/ Markdown blog. A PDF and a social card are built per post
sites/analytics.bythewood.me/ Analytics. SQLite, GeoIP, Typst PDF reports
sites/status.bythewood.me/ Uptime monitoring. SQLite, Lighthouse audits, crawler
sites/logging.bythewood.me/ Log aggregation. Every site ships its records here
sites/repos.bythewood.me/ Git remote you push to over HTTPS. Also mirrors GitHub
sites/dash.bythewood.me/ Dashboard. Markets, news, weather and the health of the rest, live over SSE
sites/auth.bythewood.me/ The front door. One account, a code pushed to a phone, one session for every site
edge/ The shared cloudflared tunnel, the Caddy behind it, and ntfy for alerts

Requirements

Docker, Go and bun. A Cloudflare account with a zone you control, since the whole thing is served through a tunnel. Everything else comes out of the Dockerfiles.

Getting it running

From nothing

A fresh clone on a machine where none of this exists, or a machine where it all went down at once:

make install

That opens a browser to authorise a Cloudflare zone, creates the tunnel and routes every hostname to it, writes a .env for each site that needs one, brings the edge and all eight sites up, creates the three ntfy accounts, mints the publishers’ tokens into the .env files that use them, hands them to the running sites, creates the login account and prints its recovery codes, and ends on doctor.

Nothing asks you to invent a password. Every one it needs is generated and printed as it goes, and so are the ten recovery codes the first sign in uses. Those are the only copies, so put them in 1Password while they are on screen. make password prints another whenever you want one.

Signing in is on auth.bythewood.me: a username, then a six digit code pushed to a phone over ntfy. The other sites hold no password of their own and ask it whether the cookie on a request is a live session, so a session revoked there is gone everywhere on the next request.

cert.pem covers a single Cloudflare zone, and the hostnames here span two, so the DNS routes for the second zone fail the first time through. Run make tunnel-login and make tunnel again, pick the other zone in the browser, and the rest are created.

Then point the ntfy Android client at https://ntfy.bythewood.me with the reading account it printed, and subscribe to status and logging.

Each step is a target of its own, for a run that stopped halfway:

make tunnel-login   # browser auth, one Cloudflare zone at a time
make tunnel         # create the tunnel, route DNS, write config.yml
make env            # a .env per site, passwords generated and printed
make up             # the edge, then every site, ending in doctor
make ntfy           # the two alert accounts
make ntfy-token     # the publishers' token, into the two .env files
make up             # hand the token to the sites that publish with it

up has to come before ntfy, since the accounts are created inside a running container. Everything else is safe to re-run at any time, and make env never touches a .env that already exists.

A different domain means editing three things before the tunnel step: the HOSTNAMES line in edge/setup-tunnel.sh, the ingress rules in edge/cloudflared/config.yml, and the site blocks in edge/caddy/Caddyfile. Each site’s own hostname is a constant in its site.go.

After you change something

make deploy SITE=blog.bythewood.me

That is the only command that rebuilds. Nothing needs exporting first, since the four sites with secrets read them from a .env beside their compose file.

Editing anything in edge/ needs make edge instead, because those configs are baked into images and make up does not rebuild.

When something is broken

make doctor

Read-only. It prints the tunnel credentials, the network, both edge containers, every site, and the SQLite volumes with their sizes, and puts the command that fixes it next to anything wrong. A container that is stopped or unhealthy usually wants make up; one serving the wrong thing wants make deploy.

Commands

make install                         a machine that has never run this
make up                              everything, from nothing or from broken
make deploy SITE=blog.bythewood.me   rebuild one site and replace it
make edge                            rebuild the edge after editing edge/
make doctor                          tunnel, network, containers, data volumes
make down                            stop everything

make tunnel-login                    browser auth for one Cloudflare zone
make tunnel                          create the tunnel, route DNS, write config
make tunnel-status                   what the tunnel has right now
make env                             a .env per site, passwords generated
make password                        print a suggested password, writing nothing
make ntfy                            create the two alert accounts
make ntfy-token                      mint the publishers' token into the .env files
make ntfy-status                     accounts, access and tokens
make ntfy-passwd                     change the reading account's password

make run SITE=blog.bythewood.me      vite watch + go run, on :8000
make build SITE=blog.bythewood.me    frontend, then a release binary in bin/
make check                           gofmt, then vet and build every site
make test                            every site's tests

The development targets touch no Docker. Everything else goes through sudo, because the socket in the webdev container is root:root mode 660. On a host where docker needs no sudo: make up SUDO=.

Every site has run, build and clean in its own Makefile, so you can work inside one without going through the root. There is no default SITE.

Layout

Each site is its own Go module and builds on its own. There is no module at the repo root, and go.work only exists so repo-wide make targets and an editor can see all eight at once:

cd sites/blog.bythewood.me && GOWORK=off go build ./...

Each site also carries its own copy of web/, the small HTTP layer they all need. That means a site is a directory you can lift into its own repository, and its Docker build context is that directory instead of the whole monorepo. The cost is that a fix in web/ has to be made eight times.

Go serves every request and html/template renders the pages. Vite is a build step and never a server: it writes content-hashed JS and CSS into build/dist/, and the Go binary reads build/dist/.vite/manifest.json to turn those hashes into script and link tags. Typst does the typesetting at build time for post PDFs, the resume, and the social card each site serves as its og:image.

Everything generated lands in sites/<name>/build/, which is gitignored. make build passes -tags embed so //go:embed compiles that directory into the binary, which is how blog and the portfolio come out as a single file.

The edge

One cloudflared and one Caddy serve the whole repo. Traffic is outbound only, so there is no port forwarding, no dynamic DNS, and the origin IP is never published. Every container listens on 8000 internally and nothing is published to the host.

Adding a hostname takes three changes: a site block in edge/caddy/Caddyfile, an ingress rule in edge/cloudflared/config.yml, and a proxied CNAME to <tunnel-id>.cfargotunnel.com.

Caddy also writes its access log to logging.bythewood.me, over a plain socket on port 9001 rather than through the shipper every site uses, since Caddy can’t carry a Go handler. It keeps writing the same lines to stderr as well, so docker logs orchard-caddy is unchanged. cloudflared and ntfy don’t ship anywhere, because neither can write its log to a network address and pointing either at a file takes its stdout away.

ntfy runs in the edge and is how anything here reaches a phone. status publishes outage transitions and logging publishes silence transitions, both over the internal bridge. Reading is over the tunnel with a read-only account.

Things that will bite you

Editing an edge/ config and running make up does nothing. Those configs are baked into images. Use make edge.

A /data volume created root-owned stays root-owned and needs a one-time chown -R 65532:65532.

.env is gitignored by bare name and this repository is public, so it is worth checking rather than trusting:

git check-ignore -v sites/status.bythewood.me/.env

Bind mounts of paths under /home/dev silently mount an empty directory, because the daemon is Docker Desktop on the Windows host and resolves the source against its own filesystem. Use named volumes.

An alerter running on this machine cannot tell you this machine lost power. A tunnel that drops is covered from the other side, since Cloudflare sends its own tunnel health notification.

CLAUDE.md has the rest: the conventions, and the mistakes that have already been made once.

License

BSD 2-Clause. See LICENSE.md.