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