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#
4# This is the heaviest image in the repo and Lighthouse is all of it: the audit
5# is Google's own JavaScript driving a real Chromium over DevTools, so the
6# runtime carries a browser, a JavaScript runtime and the CLI.
7
8# Typst is copied into the runtime image rather than discarded after the build,
9# because a report describes a live monitoring state that changes every three
10# minutes, so there is nothing finite to precompile. See typst.go.
11FROM alpine:3.23@sha256:fd791d74b68913cbb027c6546007b3f0d3bc45125f797758156952bc2d6daf40 AS typst
12ARG TYPST_VERSION=0.14.0
13ARG TYPST_SHA256=99816d2982de08d2b091bac56b59b2faa523a10e1378ad3cdd68e35b8eb74b3d
14RUN apk add --no-cache curl tar xz && \
15 curl -fsSL -o /tmp/typst.tar.xz \
16 "https://github.com/typst/typst/releases/download/v${TYPST_VERSION}/typst-x86_64-unknown-linux-musl.tar.xz" && \
17 echo "${TYPST_SHA256} /tmp/typst.tar.xz" | sha256sum -c - && \
18 tar -xJf /tmp/typst.tar.xz -C /tmp && \
19 mv /tmp/typst-x86_64-unknown-linux-musl/typst /usr/local/bin/typst
20
21FROM oven/bun:1-alpine@sha256:07235578f79ef8c6f97d94aee7938e76f5cdba5f21ae5dbfdd3d3d38058437eb AS frontend
22WORKDIR /src
23# bunfig.toml has to arrive before install runs, or install.peer = false
24# silently does nothing.
25COPY frontend/package.json frontend/bun.lock frontend/bunfig.toml ./
26RUN bun install --frozen-lockfile
27COPY frontend/ ./
28RUN bun run build
29
30# Its own stage so a CSS change does not reinstall the Lighthouse dependency
31# tree, which changes only when the Lighthouse version does.
32FROM oven/bun:1-alpine@sha256:07235578f79ef8c6f97d94aee7938e76f5cdba5f21ae5dbfdd3d3d38058437eb AS lighthouse
33WORKDIR /src
34COPY package.json bun.lock ./
35RUN bun install --frozen-lockfile
36
37# PNG, not SVG, since no social platform accepts image/svg+xml for og:image.
38# Geist comes out of the frontend stage's node_modules because @fontsource ships
39# woff2 only and Typst reads TrueType.
40FROM typst AS card
41COPY --from=frontend /src/node_modules/geist/dist/fonts/geist-sans /fonts/geist-sans
42COPY typst/card.typ /card.typ
43RUN typst compile --format png --ppi 72 --font-path /fonts/geist-sans /card.typ /card.png
44
45FROM golang:1.27-alpine@sha256:4c9fe60190a2a3350ddc51de80d0224b8a6698d12bdfc999fee45ea9d6c46dbc AS build
46WORKDIR /src
47
48# The bracket glob makes go.sum optional, since a plain COPY go.sum fails the
49# build outright for a module with no third party dependency.
50COPY go.mod go.su[m] ./
51COPY web/ ./web/
52COPY *.go ./
53COPY templates/ ./templates/
54COPY reports/ ./reports/
55# -tags embed reads build/dist at compile time, so the bundle has to land here
56# before the compile below, the card included.
57COPY --from=frontend /build/dist ./build/dist
58COPY --from=card /card.png ./build/dist/og/card.png
59# CGO off, since modernc.org/sqlite is SQLite transpiled to Go rather than bound
60# to it, so the binary is static and needs no libc.
61RUN CGO_ENABLED=0 go build -tags embed -trimpath -ldflags="-s -w" \
62 -o /app .
63
64FROM alpine:3.23@sha256:fd791d74b68913cbb027c6546007b3f0d3bc45125f797758156952bc2d6daf40
65
66# chromium is for Lighthouse and nothing else, and typst wants the font packages
67# instead. No nodejs and no npm anywhere, because `bun run --bun` symlinks node
68# to bun so the lighthouse shim's `#!/usr/bin/env node` shebang resolves.
69RUN apk add --no-cache \
70 ca-certificates \
71 chromium \
72 font-jetbrains-mono \
73 ttf-dejavu \
74 ttf-liberation \
75 fontconfig && \
76 fc-cache -f
77
78COPY --from=oven/bun:1-alpine@sha256:07235578f79ef8c6f97d94aee7938e76f5cdba5f21ae5dbfdd3d3d38058437eb /usr/local/bin/bun /usr/local/bin/bun
79COPY --from=typst /usr/local/bin/typst /usr/local/bin/typst
80COPY --from=build /app /app
81
82# SITE_ROOT is where lighthouse.go looks for node_modules/.bin/lighthouse and
83# what typst resolves absolute paths against, so it doubles as the fence on what
84# a compile is allowed to read.
85WORKDIR /srv
86COPY --from=lighthouse /src/node_modules /srv/node_modules
87COPY reports/ /srv/reports/
88
89ENV SITE_DATA=/data
90ENV SITE_ROOT=/srv
91# Chromium ships in the image, so findChromium has nothing to walk.
92ENV CHROMIUM_BIN=/usr/bin/chromium-browser
93# fontconfig wants somewhere writable, or typst warns on every compile.
94ENV XDG_CACHE_HOME=/tmp
95
96# chown before the VOLUME, and the order matters: Docker seeds a fresh volume
97# from the image path it mounts over and carries its ownership. Without it the
98# mountpoint is root-owned and the first boot dies on a database open error.
99RUN mkdir -p /data && chown 65532:65532 /data
100VOLUME ["/data"]
101
102# UID 65532 like the scratch images here, so one number owns every /data volume.
103# -H because this user has no home and writes only to /data and /tmp.
104RUN addgroup -g 65532 app && adduser -u 65532 -G app -D -H app
105USER 65532:65532
106
107# The binary probes itself rather than shelling out to wget, which this image
108# does have, because the scratch images cannot. Compose sets the same check.
109HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
110 CMD ["/app", "-healthcheck"]
111
112EXPOSE 8000
113ENTRYPOINT ["/app"]