Skip to content

Commit 4b65764

Browse files
beetlebugorgclaude
andcommitted
Merge feat/docker-static-musl: tiny FROM-scratch multi-arch Docker image
Static musl binary (zig cc -linux-musl, fully static, 25MB) → FROM scratch image (25.8MB) with CA bundle. Multi-arch amd64+arm64 via zig-cc cross-compile (no QEMU). Docker is now the recommended deployment for the server-hub model. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2 parents 38f22b7 + b46ea0c commit 4b65764

8 files changed

Lines changed: 411 additions & 12 deletions

File tree

‎.dockerignore‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Docker build context trimming. The image build only needs the Go source, the
2+
# embedded web assets (web/embed.go go:embed list), go.mod/go.sum, and
3+
# scripts/xbuild-tile57.sh — everything else just bloats the context. The tile57
4+
# engine is git-cloned inside the builder, not copied from here.
5+
6+
# VCS + agent/editor state
7+
.git/
8+
.github/
9+
.claude/
10+
.idea/
11+
.vscode/
12+
.DS_Store
13+
14+
# Build output / release staging
15+
bin/
16+
dist/
17+
release-assets/
18+
*.test
19+
*.out
20+
coverage.*
21+
22+
# Local Go workspace override (must never reach the builder — it would repoint the
23+
# engine replace at a path that doesn't exist in the image).
24+
go.work
25+
go.work.sum
26+
27+
# Baked tile archives + generated (non-embedded) client assets
28+
*.pmtiles
29+
web/*.pmtiles
30+
web/colortables.json
31+
web/linestyles.json
32+
web/patterns.json
33+
web/patterns.png
34+
web/sprite.json
35+
web/sprite.png
36+
web/chartplotter.wasm
37+
web/charts-index.json
38+
web/charts-user.*
39+
40+
# Large test/data material + local artifacts (not needed to build the binary)
41+
testdata/
42+
examples/
43+
*.cpx
44+
/chartplotter
45+
46+
# The docs site (Docusaurus) is not part of the binary build
47+
docs/node_modules/
48+
docs/build/
49+
docs/.docusaurus/
50+
51+
# Docker + compose files themselves
52+
Dockerfile
53+
.dockerignore
54+
compose.yaml

‎.github/workflows/docker.yml‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
name: Docker
2+
3+
# Build + publish the multi-arch container image to GHCR. On a v* tag we push the
4+
# version + `latest`; on main we push `edge` (a rolling pre-release image). The
5+
# image is a FROM scratch wrapper around the fully-static musl binary (the
6+
# "go-dims" pattern) — see Dockerfile.
7+
#
8+
# Multi-arch is done by zig-cc CROSS-COMPILE inside the builder stage, NOT QEMU:
9+
# the builder is pinned to the native BUILDPLATFORM and links each TARGETARCH with
10+
# `zig cc`, so buildx emits linux/amd64 + linux/arm64 from one fast amd64 runner.
11+
# The engine (public github.com/beetlebugorg/tile57, whose submodules hold the IHO
12+
# S-101 catalogues) is git-cloned inside the Dockerfile, so no sibling checkout is
13+
# needed here. Same IHO-embed caveat as the binary releases (accepted — the image
14+
# embeds the catalogue via libtile57).
15+
#
16+
# NOTE: correct-by-construction but UNVALIDATED until a real push runs on GitHub
17+
# (buildx needs GHCR auth + the runner, neither available locally).
18+
19+
on:
20+
push:
21+
branches: [main]
22+
tags: ["v*"]
23+
24+
permissions:
25+
contents: read
26+
packages: write
27+
28+
env:
29+
IMAGE: ghcr.io/beetlebugorg/chartplotter
30+
31+
jobs:
32+
image:
33+
runs-on: ubuntu-latest
34+
steps:
35+
- name: Checkout chartplotter
36+
uses: actions/checkout@v7
37+
38+
- name: Set up QEMU
39+
# Only needed so buildx can DECLARE the arm64 platform; the actual build is
40+
# a native cross-compile (the builder stage pins --platform=$BUILDPLATFORM),
41+
# so no guest code is emulated.
42+
uses: docker/setup-qemu-action@v3
43+
44+
- name: Set up Docker Buildx
45+
uses: docker/setup-buildx-action@v3
46+
47+
- name: Log in to GHCR
48+
uses: docker/login-action@v3
49+
with:
50+
registry: ghcr.io
51+
username: ${{ github.actor }}
52+
password: ${{ secrets.GITHUB_TOKEN }}
53+
54+
- name: Derive image tags
55+
id: meta
56+
uses: docker/metadata-action@v5
57+
with:
58+
images: ${{ env.IMAGE }}
59+
tags: |
60+
type=semver,pattern={{version}}
61+
type=semver,pattern={{major}}.{{minor}}
62+
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
63+
type=raw,value=edge,enable=${{ github.ref == 'refs/heads/main' }}
64+
65+
- name: Build + push (linux/amd64, linux/arm64)
66+
uses: docker/build-push-action@v6
67+
with:
68+
context: .
69+
platforms: linux/amd64,linux/arm64
70+
push: true
71+
tags: ${{ steps.meta.outputs.tags }}
72+
labels: ${{ steps.meta.outputs.labels }}
73+
build-args: |
74+
VERSION=${{ github.ref_type == 'tag' && github.ref_name || format('edge-{0}', github.sha) }}
75+
cache-from: type=gha
76+
cache-to: type=gha,mode=max

‎Dockerfile‎

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# syntax=docker/dockerfile:1
2+
3+
# =============================================================================
4+
# chartplotter — a tiny multi-arch image built from a FULLY STATIC musl binary
5+
# (the "go-dims" pattern). The Go binary is already self-contained: the web
6+
# assets, the S-101 catalogue, and the native libtile57 engine are all embedded
7+
# / statically linked, so a static musl build dropped into `FROM scratch` yields
8+
# an image that is basically just the ~25 MB binary (+ a CA bundle).
9+
#
10+
# Multi-arch WITHOUT QEMU emulation: the builder runs on the native BUILDPLATFORM
11+
# and cross-links each TARGETARCH with `zig cc` (the same recipe as `make musl`),
12+
# so one fast builder produces both linux/amd64 and linux/arm64 by target swap.
13+
# docker buildx build --platform linux/amd64,linux/arm64 -t chartplotter .
14+
# A plain `docker build` builds only the host arch.
15+
# =============================================================================
16+
17+
# ---- builder: Go 1.26 + Zig 0.16, cross-links the static musl binary ---------
18+
# Pinned to BUILDPLATFORM so it stays native (no emulation) even when the target
19+
# is a different arch — Go + zig do the cross-compile.
20+
FROM --platform=$BUILDPLATFORM golang:1.26-bookworm AS builder
21+
22+
# Zig 0.16 is the C toolchain for the CGO cross-link (see scripts/xbuild-tile57.sh).
23+
ARG ZIG_VERSION=0.16.0
24+
# Predefined by buildx: BUILDARCH = the builder's own arch (fetch the matching
25+
# host Zig); TARGETARCH = the arch we cross-compile FOR.
26+
ARG BUILDARCH
27+
ARG TARGETARCH
28+
# Stamped into the binary (main.version). Pass --build-arg VERSION=v1.2.3 in CI.
29+
ARG VERSION=docker
30+
# Optional: pin the engine to a branch/tag for reproducible builds (default: the
31+
# repo's default branch). The engine ABI lives in include/ + bindings/go.
32+
ARG TILE57_REF=
33+
34+
# Install Zig 0.16 (host arch) — the C cross-compiler `zig cc` links libtile57 +
35+
# the cgo objects against musl for any target triple from this one host. xz-utils
36+
# is needed to unpack Zig's .tar.xz (not in the golang image by default).
37+
RUN set -eux; \
38+
apt-get update; \
39+
apt-get install -y --no-install-recommends xz-utils; \
40+
rm -rf /var/lib/apt/lists/*; \
41+
case "$BUILDARCH" in \
42+
amd64) zarch=x86_64 ;; \
43+
arm64) zarch=aarch64 ;; \
44+
*) echo "unsupported build arch: $BUILDARCH" >&2; exit 1 ;; \
45+
esac; \
46+
url="https://ziglang.org/download/${ZIG_VERSION}/zig-${zarch}-linux-${ZIG_VERSION}.tar.xz"; \
47+
curl -fsSL "$url" -o /tmp/zig.tar.xz; \
48+
mkdir -p /opt/zig; \
49+
tar -xJf /tmp/zig.tar.xz -C /opt/zig --strip-components=1; \
50+
rm /tmp/zig.tar.xz; \
51+
ln -s /opt/zig/zig /usr/local/bin/zig; \
52+
zig version
53+
54+
# The engine is the PUBLIC github.com/beetlebugorg/tile57, cloned as a SIBLING of
55+
# the source tree so go.mod's `replace … => ../tile57/bindings/go` and the
56+
# binding's cgo LDFLAGS resolve; its submodules carry the IHO S-101 catalogues.
57+
WORKDIR /src
58+
RUN set -eux; \
59+
if [ -n "$TILE57_REF" ]; then br="--branch $TILE57_REF"; else br=""; fi; \
60+
git clone --depth 1 $br --recurse-submodules --shallow-submodules \
61+
https://github.com/beetlebugorg/tile57 /src/tile57; \
62+
git -C /src/tile57 rev-parse --short=9 HEAD
63+
64+
# chartplotter source beside the engine.
65+
COPY . /src/chartplotter
66+
WORKDIR /src/chartplotter
67+
68+
# Cross-link the fully-static musl binary for TARGETARCH. LIBC=musl makes zig's
69+
# lld emit a static ELF (`ldd` → "not a dynamic executable"); SKIP_RESTORE=1 skips
70+
# the throwaway host-lib rebuild (the tree is discarded after this stage). The Go
71+
# build cache + module cache are mounted so rebuilds are fast.
72+
RUN --mount=type=cache,target=/root/.cache/go-build \
73+
--mount=type=cache,target=/go/pkg/mod \
74+
set -eux; \
75+
LIBC=musl SKIP_RESTORE=1 \
76+
PLATFORMS="linux/${TARGETARCH}" \
77+
OUT=/out \
78+
VERSION="${VERSION}" \
79+
TILE57=/src/tile57 \
80+
ENGINE_COMMIT="$(git -C /src/tile57 rev-parse --short=9 HEAD)" \
81+
scripts/xbuild-tile57.sh; \
82+
cp "/out/chartplotter_linux_${TARGETARCH}_musl" /chartplotter; \
83+
# Smoke-test only on a native build — a cross-built (e.g. arm64-on-amd64)
84+
# binary can't be executed by the amd64 builder (no emulation here).
85+
if [ "$TARGETARCH" = "$BUILDARCH" ]; then /chartplotter version; fi
86+
87+
# An empty, world-writable /tmp to seed into the scratch image: `serve` uses
88+
# os.MkdirTemp for the regenerated S-101 client assets, and scratch has no /tmp.
89+
RUN mkdir -m 1777 -p /image-tmp
90+
91+
# ---- final: FROM scratch — just the binary + a CA bundle --------------------
92+
FROM scratch
93+
94+
# A static musl binary has NO system trust store, and chartplotter makes outbound
95+
# HTTPS calls (charts.noaa.gov / ienccloud.us chart downloads). VERIFIED: without
96+
# a CA bundle those fetches fail with `x509: certificate signed by unknown
97+
# authority`. Bundle the roots from the builder and point Go's crypto/x509 at them.
98+
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
99+
ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
100+
101+
COPY --from=builder /chartplotter /chartplotter
102+
103+
# scratch ships no /tmp; `serve` needs one for the regenerated S-101 assets.
104+
COPY --from=builder /image-tmp /tmp
105+
106+
# Persistent chart state (source ENC + baked tiles) — back it with a named volume.
107+
VOLUME ["/data"]
108+
EXPOSE 8080
109+
110+
# `serve` defaults to --host 127.0.0.1, which is unreachable from outside the
111+
# container, so bind 0.0.0.0. Source ENC + baked tiles land under the volume.
112+
ENTRYPOINT ["/chartplotter"]
113+
CMD ["serve", "--host", "0.0.0.0", "--data", "/data", "--cache", "/data/cache"]

‎Makefile‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ CACHE ?= $(if $(XDG_CACHE_HOME),$(XDG_CACHE_HOME),$(HOME)/.cache)/chartplotter
2727
S101_PC ?= $(HOME)/Projects/s101-portrayal-catalogue/PortrayalCatalog
2828
S101_FC ?= $(HOME)/Projects/s101-feature-catalogue/S-101FC/FeatureCatalogue.xml
2929

30-
.PHONY: build build-tile57 tile57-lib serve-tile57 xbuild xbuild-tile57 test vet fmt fmt-check tidy clean clear-cache serve docs docs-shots bake-ienc bake-noaa serve-widget demo demo-chart1 serve-demo preslib-chart1 s64-pages
30+
.PHONY: build build-tile57 tile57-lib serve-tile57 xbuild xbuild-tile57 musl test vet fmt fmt-check tidy clean clear-cache serve docs docs-shots bake-ienc bake-noaa serve-widget demo demo-chart1 serve-demo preslib-chart1 s64-pages
3131

3232
# Prebaked prod test set (US Inland ENC bundle + the NOAA world archive).
3333
# NB: keep these as bare values with NO inline `#` comments — Make folds any
@@ -135,6 +135,20 @@ serve-tile57: build ## Build + serve the full app; ENC_ROOT=… also registers a
135135
xbuild xbuild-tile57: ## Cross-compile CGO+libtile57 binaries with zig cc (linux+windows; darwin builds on a Mac runner)
136136
VERSION="$(VERSION)" TILE57="$(TILE57)" ENGINE_COMMIT="$(ENGINE_COMMIT)" scripts/xbuild-tile57.sh
137137

138+
# Fully-static musl binaries for the tiny FROM scratch Docker image (the "go-dims"
139+
# pattern): CGO+libtile57 linked against musl STATICALLY via zig cc, so `ldd` reports
140+
# "not a dynamic executable" and the binary drops straight into `FROM scratch`. Builds
141+
# linux/amd64 + linux/arm64 into dist/chartplotter_linux_<arch>_musl (same zig-cc
142+
# cross-build as xbuild — no QEMU; one host cross-links both arches by target swap).
143+
# The Dockerfile calls scripts/xbuild-tile57.sh with LIBC=musl directly; this target
144+
# is the local/CI equivalent. musl-static is strictly more portable than the gnu
145+
# xbuild (no glibc floor), so it's the recommended linux artifact — the gnu xbuild
146+
# path is kept intact for anyone who needs a glibc-dynamic build.
147+
MUSL_PLATFORMS ?= linux/amd64 linux/arm64
148+
musl: ## Build fully-static musl binaries (linux amd64+arm64) into dist/ for the scratch Docker image
149+
VERSION="$(VERSION)" TILE57="$(TILE57)" ENGINE_COMMIT="$(ENGINE_COMMIT)" \
150+
LIBC=musl PLATFORMS="$(MUSL_PLATFORMS)" scripts/xbuild-tile57.sh
151+
138152
serve: build ## Serve the web frontend + provisioning API on :8080 (HOST/PORT/ASSETS overridable)
139153
$(BIN) serve --host $(HOST) --port $(PORT) --assets $(ASSETS)
140154

‎README.md‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,35 @@ of the chart — **instrument gauges**, custom overlays, routes, and more — wi
117117
forking the core. NMEA 0183 own-ship and AIS are the first slice of that; expect
118118
the surface to grow and change.
119119

120+
## 🐳 Run with Docker
121+
122+
The simplest way to run chartplotter — and the primary path for the
123+
**server-hub-on-a-boat** model (a Raspberry Pi, laptop, or mini PC that holds all
124+
chart state while every screen just points a browser at it) — is the published
125+
container image:
126+
127+
```sh
128+
docker run -p 8080:8080 -v chartplotter-data:/data \
129+
ghcr.io/beetlebugorg/chartplotter
130+
# open http://localhost:8080
131+
```
132+
133+
Or with Docker Compose ([`compose.yaml`](compose.yaml)):
134+
135+
```sh
136+
docker compose up -d
137+
```
138+
139+
The image is **multi-arch** (`linux/amd64` + `linux/arm64`), so the same command
140+
runs on a Raspberry Pi and on an amd64 box. It's built `FROM scratch` around a
141+
**fully-static musl binary**, so it's tiny — essentially just the ~26 MB binary
142+
plus a CA bundle. The named `/data` volume holds the ENC source, baked tiles, and
143+
settings, and survives image upgrades. **macOS / Windows** users run the same
144+
image via **Docker Desktop** — no native Mac/Windows binary needed.
145+
146+
Native binaries (below) remain available as a secondary option for bare-metal
147+
installs.
148+
120149
## 📦 Install & build
121150

122151
**Download a binary.** Every tagged release publishes a self-contained
@@ -249,8 +278,16 @@ make vet # go vet ./...
249278
make fmt # gofmt -w .
250279
make serve # build + serve web/ on :8080
251280
make xbuild # cross-compile with `zig cc` (linux + windows, amd64/arm64)
281+
make musl # fully-static musl binaries (linux amd64+arm64) for the Docker image
252282
```
253283

284+
The container image is built `FROM scratch` around the static musl binary (`make
285+
musl` / the [`Dockerfile`](Dockerfile)); the engine is git-cloned inside the
286+
builder, so no sibling checkout is needed to `docker build`. `zig cc`
287+
cross-compiles both arches from one native builder — no QEMU — and
288+
[`.github/workflows/docker.yml`](.github/workflows/docker.yml) pushes the
289+
multi-arch image to GHCR on each `v*` tag.
290+
254291
CGO is required — libtile57 is the sole tile/portrayal engine, so
255292
`CGO_ENABLED=0` does not build. Cross-compilation still works with **Zig as the
256293
C toolchain** (`make xbuild` covers linux and windows; darwin must be built

‎compose.yaml‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# chartplotter — the "server-hub on a boat" deployment (Pi / laptop / mini PC).
2+
# One long-running server holds all chart state; every screen (helm, tablet,
3+
# phone) points its browser at http://<this-host>:8080/ and they stay in sync.
4+
#
5+
# docker compose up -d # pull ghcr.io image + run
6+
# docker compose up -d --build # build the image locally from ./Dockerfile
7+
#
8+
# The image is multi-arch (linux/amd64 + linux/arm64), so the SAME compose file
9+
# runs on a Raspberry Pi and on an amd64 box.
10+
11+
name: chartplotter
12+
13+
services:
14+
chartplotter:
15+
image: ghcr.io/beetlebugorg/chartplotter:latest
16+
# `docker compose build` / `--build` builds this instead of pulling.
17+
build:
18+
context: .
19+
args:
20+
VERSION: dev
21+
ports:
22+
- "8080:8080"
23+
volumes:
24+
# Source ENC (downloaded district zips, raw cells) + baked tiles + client
25+
# settings. A named volume survives image upgrades and `compose down`.
26+
- chartplotter-data:/data
27+
restart: unless-stopped
28+
# NOTE: no Docker HEALTHCHECK — the FROM scratch image has no shell/curl for a
29+
# CMD probe. Check health from the host: `curl -fsS http://localhost:8080/`.
30+
31+
volumes:
32+
chartplotter-data:

0 commit comments

Comments
 (0)