Status: design-only, nothing shipped. No Dockerfile, no
docker-compose.yml, no published image. This doc is the first pass
at whether containerized GiGot is worth doing and what the image
should look like if we do.
The README documents exactly one install path: go build -o gigot .,
drop the binary on a host, run it under systemd (see README §11.1
"Standalone"). The release.yml workflow ships prebuilt tarballs
for linux/amd64, linux/arm64, and windows/amd64 on tag push —
still native binaries, not images.
That is fine for the "one box, one sysadmin" case. It starts to hurt the moment someone wants to:
- Try GiGot without installing a Go toolchain (
docker run gigot:latestinstead of a checkout + build). - Run it on a NAS / Synology / Unraid / home server where the natural unit of deployment is a container, not a systemd unit.
- Run it on Kubernetes (a team running Formidable at scale may already have a k8s cluster and no place for a one-off binary).
- Pin a version.
ghcr.io/petervdpas/gigot:v0.3.1is a single immutable artifact; a tarball + config + data dir requires the operator to reassemble state correctly after an upgrade.
Neither of those paths is blocked today — you can write your own Dockerfile in ten lines. The question is whether GiGot should ship one as a first-class artifact and commit to keeping it working.
- Distribution parity with the binary. A tagged release already
produces tarballs via
release.yml. Adding an image to the same workflow is a small incremental cost and meaningfully lowers the "try it" barrier. - Immutable surface. The image bundles the exact
gigotbinary, the embedded scaffold templates, and the known-good Go build flags. Operators stop having to match a tarball to a host libc or a Go version. - Home-lab / self-host audience. GiGot is a 15-person team tool (see accounts design doc); many of those teams run Portainer, Unraid, or a docker-compose file on a homelab box, not a full Linux server. The image is the install path for that audience.
- Reproducible CI. Integration tests and Formidable-against-GiGot smoke tests can pin a tag instead of rebuilding from source.
- Two install paths to support. Every config change (new sealed store, new flag, path handling tweak) has to be re-tested against the container layout, not just a systemd unit. That is ongoing cost, not one-time.
- State + image tension. Everything load-bearing lives outside
the image (
data/,repos/,gigot.json). The image is almost pure code — which means users who get the bind-mounts wrong lose keys, tokens, or repos. Bad UX that the binary path doesn't have. - Admin bootstrap is stdin-interactive.
gigot -add-admin aliceprompts for a password; that is natural in a shell, awkward indocker run. See §6.
On balance: the cost is real but bounded (one Dockerfile, one compose file, one release-workflow job), and it unlocks a use-case that the binary can't reach without friction. Worth doing — but only if we treat it as a first-class artifact the same way tarballs are, not a "community Dockerfile" that silently rots.
The binary is the only code artifact, so the image is almost entirely a runtime shell around it. Everything else — config, keys, tokens, repos — is state and must live on a mounted volume.
# Stage 1: build
FROM golang:1.25-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG VERSION=0.0.0-dev
RUN CGO_ENABLED=0 go build \
-trimpath \
-ldflags "-s -w -X main.appVersion=${VERSION}" \
-o /out/gigot .
# Stage 2: runtime
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/gigot /gigot
WORKDIR /var/lib/gigot
USER nonroot:nonroot
EXPOSE 3417
ENTRYPOINT ["/gigot"]
CMD ["-config", "/etc/gigot/gigot.json"]
Why distroless-static:
CGO_ENABLED=0already gives us a fully-static binary; we don't need libc or a shell.nonroot(uid 65532) forces correct volume-permission discipline at build time rather than discovering it in production.- ~2 MB base vs. ~7 MB Alpine; no package manager surface to patch.
Why not scratch: distroless gives us /etc/ssl/certs/ca-certificates.crt
out of the box, which GiGot needs for OAuth discovery
(login.microsoftonline.com, github.com) and for mirror-sync
pushes to https:// upstreams. Scratch would force us to copy that
file in manually and track CA bundle updates.
/gigot # the binary (ENTRYPOINT)
/var/lib/gigot/ # WORKDIR; mount point for state
data/ # must be a volume
repos/ # must be a volume
/etc/gigot/gigot.json # must be a mounted file (or bundled default)
Keeping data/ and repos/ under a single WORKDIR parent means the
operator can bind-mount one host directory and get the whole
persistent state of the server, which matters for backup.
- No default
gigot.json. Shipping a default config invites running against it. Instead, the image fails fast if/etc/gigot/gigot.jsonis missing, with an error that points atgigot -init(see §6.1). Contrast: the bare binary falls back to in-memory defaults, which is fine on a laptop and wrong for a long-lived container. - No default keypair.
server.keymust be generated inside the mounteddata/volume on first boot, so it survivesdocker rm. - No admin account.
-add-adminis a one-shot that the operator runs once against the mounted volume (§6.2).
The config reference (README §3) already resolves all relative paths
against the directory of gigot.json. That single rule is what makes
containerization clean: we pick one canonical set of absolute paths
and bake them into the image's default config.
{
"server": { "host": "0.0.0.0", "port": 3417 },
"storage": { "repo_root": "/var/lib/gigot/repos" },
"auth": { "enabled": true, "type": "token" },
"crypto": {
"private_key_path": "/var/lib/gigot/data/server.key",
"public_key_path": "/var/lib/gigot/data/server.pub",
"data_dir": "/var/lib/gigot/data"
},
"logging": { "level": "info" }
}Two things are different from the standalone README example:
server.hostis0.0.0.0, not127.0.0.1. Inside a container,127.0.0.1means "unreachable from outside the container."auth.enabledistrueby default. The binary defaults tofalsebecause it targets a laptop dev loop; the image targets deployed use, where open/api/*is a footgun.
We do not read config from env vars. The precedent in the repo
is a single JSON config, and parallel env-var overrides become their
own maintenance burden. Instead, the operator mounts a gigot.json.
Two named volumes, one mount point for config:
| Mount | Contents | Backup critical? |
|---|---|---|
/var/lib/gigot/data |
keys, sealed stores | Yes — losing this = total data loss |
/var/lib/gigot/repos |
bare repos + audit chains | Yes |
/etc/gigot/gigot.json |
config | Low (re-creatable) |
Both data/ and repos/ need to be genuinely persistent (named
volume, bind mount, or a CSI volume on k8s). A tmpfs mount here
loses every key, every token, every repo on docker restart.
data/ and repos/ are deliberately separate mounts so an
operator can snapshot repos independently (e.g. push repos/ to a
mirror host while keeping data/ local). They remain siblings under
/var/lib/gigot/ so tar czf gigot-backup.tgz /var/lib/gigot is
one command when an operator just wants the lot.
Distroless-nonroot runs as uid 65532. Host directories created by
docker volume create inherit root ownership by default, so the
process can't write. Two mitigations:
- Documented: tell operators to
chown -R 65532:65532the host dirs before first run. Simple, ugly. - Preferred: ship a tiny init step in the compose/helm wrapper that chowns the mount on startup. But that requires root and defeats distroless-nonroot.
We go with the documented path and accept the ugliness. The README section on Docker will call this out front and center.
This is where containerization bites hardest. The binary's first-run
story is "run -init, run -add-admin, run the server" — three
interactive commands in the same shell. In a container, each of
those is a separate docker run.
Not run inside the image at all. The operator writes their own
gigot.json (or copies the default from §4) and bind-mounts it.
-init is a convenience for the binary path; the container path
skips it entirely.
This is unavoidable and has to be run once against the mounted
data/ volume before the server is useful. Two options:
Option A (recommended): one-shot container.
docker run --rm -it \
-v gigot-data:/var/lib/gigot/data \
-v gigot-repos:/var/lib/gigot/repos \
-v $(pwd)/gigot.json:/etc/gigot/gigot.json:ro \
ghcr.io/petervdpas/gigot:latest \
-add-admin aliceThe -it is required for the password prompt. This works today
because -add-admin is a mutually-exclusive one-shot (README §2).
Option B: env-var password.
Introduce GIGOT_ADMIN_PASSWORD so operators can run non-interactively.
Tempting, but it breaks the existing promise that passwords never
come in through an argv/env channel, and it would bake into process
listings and docker inspect output. Rejected.
Same pattern as §6.2 — run a transient container with the same volumes mounted, the flag as argv. Nothing needs to change.
The compose file is the recommended self-host path because it
captures the volume + port + config-mount invariants in one place
that docker alone doesn't.
services:
gigot:
image: ghcr.io/petervdpas/gigot:latest
restart: unless-stopped
ports:
- "3417:3417"
volumes:
- gigot-data:/var/lib/gigot/data
- gigot-repos:/var/lib/gigot/repos
- ./gigot.json:/etc/gigot/gigot.json:ro
healthcheck:
test: ["CMD", "/gigot", "-healthcheck"]
interval: 30s
timeout: 3s
retries: 3
volumes:
gigot-data:
gigot-repos:Two things the compose file implies we need to add to the binary:
-healthcheckflag. Currently no way to probe "is the server alive" from inside the container (distroless has nocurlorwget). A-healthcheckone-shot that hitsGET /on the configuredserver.host:server.portand exits 0/1 is cheap and avoids shelling out. Scoped separately from this doc's primary question.- Graceful shutdown on SIGTERM.
docker stopsends SIGTERM then SIGKILL after 10s. We need to verify the current server drains in-flight git pushes cleanly, and add a signal handler if it doesn't. (Open question — see §11.)
Use the existing release.yml tag flow. Add a job that:
- Runs on the same
v*tag trigger. - Builds a multi-arch image (
linux/amd64+linux/arm64) viadocker/buildx-action. - Pushes to
ghcr.io/petervdpas/gigottagged as both:v0.3.1and:latest. - Only runs after
test+buildpass — same gate as tarballs.
GHCR is the right registry because:
- It lives in the same GitHub account as the source, so auth is a
single
GITHUB_TOKENsecret. - It's free for public images.
ghcr.io/petervdpas/gigot:v0.3.1is an obvious, discoverable name.
Docker Hub is explicitly not the target — two registries means two rate-limit surfaces, two auth flows, and two places for a tag to drift. If someone asks for it later, add a mirror job; don't start there.
A Helm chart is a natural follow-up but is not part of the first
image ship. GiGot is a single-instance service (see README §12 note
on persistent admin sessions: "A true multi-instance-HA setup still
needs a shared store like Redis"). That means the k8s story is
StatefulSet with one replica + two PVCs — three dozen lines of
YAML, nothing that benefits from a chart yet.
Revisit when:
- Someone actually asks for a chart, or
- We grow a shared-state story that makes
replicas > 1meaningful.
Until then: document the Deployment/StatefulSet shape in the
README, don't ship a chart.
- No arm/v7 or 32-bit images. The binary matrix already limits
to
amd64+arm64; the image follows. - No Windows container image. GiGot runs on Windows as a native
binary (
release.ymlbuildswindows/amd64), but the container story is Linux-only. Windows containers are a different operator audience and not one we're serving. - No "dev mode" image variant. One image, one purpose. The
hot-reload loop stays
go run .on the host. - No docker-compose-bundled TLS. Operators who want TLS run nginx / caddy / traefik in front; the image exposes plain HTTP on 3417 and stays that way. This matches README §11.1's stance on the binary.
- SIGTERM draining. Does the current HTTP server gracefully
close in-flight git pushes on shutdown? If not,
docker stopcan truncate agit pushmid-packfile and corrupt the pending transaction. Needs a spike before we publish an image. - Healthcheck endpoint.
GET /currently returns the landing page; is a dedicated/healthzworthwhile, or is "HTTP 200 on anything" enough? Lean toward enough-for-now; add/healthzonly if a container orchestrator forces the question. - Image scanning. Distroless-static has close to zero CVE
surface, but we should still wire up
trivyorgrypein CI so we notice when that changes. Separate task from this doc.
If we decide to do this:
- Slice 1 — Dockerfile (multi-stage, distroless-nonroot), verified locally with bind-mounted config + volumes. README gets a "Docker" subsection under §11 "Deployment Modes."
- Slice 2 —
docker-compose.ymlin repo root, with the healthcheck wired once the-healthcheckflag exists. - Slice 3 — Add a
publish-imagejob torelease.ymlso every tagged release pushesghcr.io/petervdpas/gigot:<ver>alongside the tarballs. - Later — Kubernetes manifest snippets in README, only if someone asks.
Slices 1 and 2 are operator-visible but don't change the Go code. Slice 3 is release-plumbing. None of them touch the auth, policy, or sync layers, so risk is bounded to "the container doesn't start" rather than "production data is at risk."