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 runs here at build time and never reaches the runtime image. Posts load
5# once at startup and cannot change while the process runs, so every PDF the
6# site will ever serve is known before it boots.
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
26FROM golang:1.27-alpine@sha256:4c9fe60190a2a3350ddc51de80d0224b8a6698d12bdfc999fee45ea9d6c46dbc AS build
27WORKDIR /src
28
29COPY --from=typst /usr/local/bin/typst /usr/local/bin/typst
30# Typst embeds only DejaVu Sans Mono and its own serif, and falls back to a
31# serif for anything it cannot find without saying so.
32RUN apk add --no-cache font-jetbrains-mono ttf-dejavu ttf-liberation
33# Geist out of the frontend stage's node_modules, because @fontsource ships
34# woff2 only and Typst reads TrueType.
35COPY --from=frontend /src/node_modules/geist/dist/fonts/geist-sans /fonts/geist-sans
36
37# The bracket glob makes go.sum optional, since a plain COPY go.sum fails the
38# build outright for a module with no third party dependency.
39COPY go.mod go.su[m] ./
40COPY web/ ./web/
41COPY *.go ./
42COPY templates/ ./templates/
43# Typst resolves the absolute paths in the generated source (/typst/... and
44# /content/images/...) against --typst-root, so both directories have to sit
45# under it under the names the source uses.
46COPY typst/ ./typst/
47COPY content/ ./content/
48
49# Two compiles, in a forced order. The PDFs and cards are generated by this same
50# binary, so the generator cannot be the embedding build: //go:embed fails at
51# compile time on build/pdfs and build/og before they exist.
52RUN CGO_ENABLED=0 go build -o /generate .
53RUN /generate -pdfs ./build/pdfs -og ./build/og -typst-root . -typst-fonts /fonts/geist-sans
54
55COPY --from=frontend /build/dist ./build/dist
56# CGO off is what makes FROM scratch possible, and -tags embed is what makes the
57# image a single file.
58RUN CGO_ENABLED=0 go build -tags embed -trimpath -ldflags="-s -w" \
59 -o /app .
60
61FROM scratch
62
63# Templates, posts, the Vite bundle, every post PDF and every social card are
64# inside the executable, so nothing else has to be copied in.
65COPY --from=build /app /app
66
67# There is no /etc/passwd in a scratch image to name a user in, so this is the
68# bare numeric UID:GID. Nothing here writes to disk.
69USER 65532:65532
70
71# No shell in a scratch image for HEALTHCHECK to call, so the binary probes
72# itself. Compose sets the same check.
73HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
74 CMD ["/app", "-healthcheck"]
75
76EXPOSE 8000
77ENTRYPOINT ["/app"]