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 alpine:3.23@sha256:fd791d74b68913cbb027c6546007b3f0d3bc45125f797758156952bc2d6daf40 AS typst
5ARG TYPST_VERSION=0.14.0
6ARG TYPST_SHA256=99816d2982de08d2b091bac56b59b2faa523a10e1378ad3cdd68e35b8eb74b3d
7RUN apk add --no-cache curl tar xz && \
8 curl -fsSL -o /tmp/typst.tar.xz \
9 "https://github.com/typst/typst/releases/download/v${TYPST_VERSION}/typst-x86_64-unknown-linux-musl.tar.xz" && \
10 echo "${TYPST_SHA256} /tmp/typst.tar.xz" | sha256sum -c - && \
11 tar -xJf /tmp/typst.tar.xz -C /tmp && \
12 mv /tmp/typst-x86_64-unknown-linux-musl/typst /usr/local/bin/typst
13
14# The resume is build output, so no compiler is needed at runtime. ttf-liberation
15# is required: resume.typ names Liberation Sans and Typst falls back without
16# saying so, which moves every line break.
17FROM typst AS resume
18RUN apk add --no-cache ttf-liberation fontconfig && fc-cache -f
19COPY resume/ /resume/
20RUN typst compile --root /resume /resume/resume.typ /resume-isaac-bythewood.pdf
21
22# Its own stage because both the frontend build and the card need node_modules,
23# and having the card read it out of the frontend stage would be a cycle.
24FROM oven/bun:1-alpine@sha256:07235578f79ef8c6f97d94aee7938e76f5cdba5f21ae5dbfdd3d3d38058437eb AS deps
25WORKDIR /src
26# bunfig.toml has to arrive before install runs, or install.peer = false
27# silently does nothing.
28COPY frontend/package.json frontend/bun.lock frontend/bunfig.toml ./
29RUN bun install --frozen-lockfile
30
31# PNG, not SVG, since no social platform accepts image/svg+xml for og:image.
32# Geist comes out of the bun install because @fontsource ships woff2 only and
33# Typst reads TrueType.
34FROM typst AS card
35COPY --from=deps /src/node_modules/geist/dist/fonts/geist-sans /fonts/geist-sans
36COPY typst/card.typ /card.typ
37RUN typst compile --format png --ppi 72 --font-path /fonts/geist-sans /card.typ /card.png
38
39FROM deps AS frontend
40
41# Images are generated in their own layer, before the rest of the frontend is
42# copied in, so editing CSS or JS does not re-encode 22 variants every deploy.
43COPY frontend/scripts/ ./scripts/
44COPY images.json ../images.json
45COPY frontend/images/ ./images/
46RUN bun run images
47
48COPY frontend/ ./
49# After the frontend copy, not before: publicDir is part of that copy, so
50# landing the PDF first would get it overwritten. `**/pdfs` in .dockerignore
51# keeps a stale host copy out of the context.
52COPY --from=resume /resume-isaac-bythewood.pdf ./public/pdfs/resume-isaac-bythewood.pdf
53# Same ordering, and `**/og` in .dockerignore for the same reason.
54COPY --from=card /card.png ./public/og/card.png
55RUN bun run vite
56
57FROM golang:1.27-alpine@sha256:4c9fe60190a2a3350ddc51de80d0224b8a6698d12bdfc999fee45ea9d6c46dbc AS build
58WORKDIR /src
59# The bracket glob makes go.sum optional, since a plain COPY go.sum fails the
60# build outright for a module with no third party dependency.
61COPY go.mod go.su[m] ./
62COPY web/ ./web/
63COPY *.go ./
64# Embedded by images.go, and shared with the frontend image generator.
65COPY images.json ./
66COPY templates/ ./templates/
67# -tags embed reads build/dist at compile time, so the bundle has to land here
68# before the compile below.
69COPY --from=frontend /build/dist ./build/dist
70# CGO off is what makes FROM scratch possible, and -tags embed is what makes the
71# image a single file.
72RUN CGO_ENABLED=0 go build -tags embed -trimpath -ldflags="-s -w" \
73 -o /app .
74
75FROM scratch
76
77# The code page fetches from api.github.com over TLS and scratch has no trust
78# store, so without this the commit fetch fails x509 on every refresh.
79COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
80
81# Templates and the Vite bundle are inside the executable.
82COPY --from=build /app /app
83
84# There is no /etc/passwd in a scratch image to name a user in, so this is the
85# bare numeric UID:GID. Nothing here reads or writes the disk.
86USER 65532:65532
87
88# No shell in a scratch image for HEALTHCHECK to call, so the binary probes
89# itself. Compose sets the same check.
90HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
91 CMD ["/app", "-healthcheck"]
92
93EXPOSE 8000
94ENTRYPOINT ["/app"]