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# Build context is this directory alone: the site is its own Go module with its
2# own copy of web/. Base images are pinned by digest because :1-alpine floats.
3
4FROM oven/bun:1-alpine@sha256:07235578f79ef8c6f97d94aee7938e76f5cdba5f21ae5dbfdd3d3d38058437eb AS frontend
5WORKDIR /src
6COPY frontend/package.json frontend/bun.lock ./
7RUN bun install --frozen-lockfile
8COPY frontend/ ./
9RUN bun run build
10
11FROM golang:1.27-alpine@sha256:4c9fe60190a2a3350ddc51de80d0224b8a6698d12bdfc999fee45ea9d6c46dbc AS build
12WORKDIR /src
13
14# The bracket glob makes go.sum optional, since a plain COPY go.sum fails the
15# build outright for a module with no third party dependency.
16COPY go.mod go.su[m] ./
17COPY web/ ./web/
18COPY *.go ./
19COPY templates/ ./templates/
20# -tags embed reads build/ at compile time, so the bundle has to land here
21# before the compile below.
22COPY --from=frontend /build/dist ./build/dist
23RUN CGO_ENABLED=0 go build -tags embed -trimpath -ldflags="-s -w" -o /app .
24
25# A scratch image cannot RUN mkdir, so the mountpoint has to arrive as a
26# directory copied from a stage that can. Docker seeds a fresh volume from the
27# image path it mounts over and carries its ownership, so without this the
28# mountpoint is root-owned and the guard's first write fails.
29RUN mkdir -p /seed-data
30
31# scratch, since nothing here shells out. Every asset is embedded and the only
32# thing on disk is the guard's state file.
33FROM scratch
34
35# Four upstreams over TLS, and without this every one of them fails x509 with no
36# other symptom.
37COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
38COPY --from=build /app /app
39
40ENV SITE_DATA=/data
41
42# UID 65532 like the other scratch images here, so one number owns every /data
43# volume. There is no /etc/passwd to name a user in, so it is the bare number.
44COPY --from=build --chown=65532:65532 /seed-data /data
45USER 65532:65532
46VOLUME ["/data"]
47
48# The binary probes itself, since a scratch image has no shell for a check to
49# call. Compose sets the same check.
50HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
51 CMD ["/app", "-healthcheck"]
52
53EXPOSE 8000
54ENTRYPOINT ["/app"]