diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9d9544a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,26 @@ +# Keep the build context small and free of secrets and local state. +.git +.gitignore +.dockerignore +Dockerfile +.remember/ +.claude/ +design/ +**/.DS_Store +node_modules +web/node_modules +web/dist +# Generated embed output; the Go stage takes the PWA from the web stage. +internal/frontend/dist/ +bin/ +*.db +*.db-* +.env* +**/.env* +deploy/secrets/ +**/*.pem +**/*.key +**/*.p12 +**/*.pfx +**/*.jks +**/*.keystore diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9ef5a49 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,42 @@ +# syntax=docker/dockerfile:1 +# Base images are pinned by digest (multi-arch index); tags are for readability. + +FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS web +WORKDIR /src/web +COPY web/package.json web/package-lock.json ./ +RUN npm ci +COPY web/ ./ +RUN npm run build + +FROM golang:1.25-alpine@sha256:1ae0735f00daffa3aaf1363a5184c0d2dc55c78e3db4ec70241cdac97bf84b59 AS server +ARG VERSION=dev +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +# Replace the placeholder with the built PWA; recreate the directory so no +# stale file survives into the embed. +RUN rm -rf internal/frontend/dist +COPY --from=web /src/web/dist/ internal/frontend/dist/ +RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" \ + -o /out/mealquest ./cmd/server + +# Alpine ships the CA bundle and busybox wget for the health check. +FROM alpine:3.22@sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8 +RUN addgroup -S -g 10001 mealquest \ + && adduser -S -D -H -u 10001 -G mealquest mealquest \ + && mkdir /data && chown mealquest:mealquest /data +COPY --from=server /out/mealquest /usr/local/bin/mealquest +ENV MEALQUEST_HTTP_ADDR=:8080 \ + MEALQUEST_DATA_DIR=/data +VOLUME /data +EXPOSE 8080 +USER 10001:10001 +# Probe the listener from MEALQUEST_HTTP_ADDR: host is everything before the +# last colon (IPv6 keeps its brackets). Wildcard hosts map to loopback, as +# does localhost: Go listens on its IPv4 address but wget would try ::1. +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD host="${MEALQUEST_HTTP_ADDR%:*}"; \ + case "$host" in ""|0.0.0.0|"[::]"|localhost) host=127.0.0.1 ;; esac; \ + wget -q -O /dev/null "http://${host}:${MEALQUEST_HTTP_ADDR##*:}/health" || exit 1 +ENTRYPOINT ["/usr/local/bin/mealquest"] diff --git a/Makefile b/Makefile index 3917323..e9f157a 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,44 @@ -# Build entry point: build the PWA, embed it, then build the server binary. +# MealQuest build entry points. +# +# make dev Go server on :8080 plus Vite dev server on :5173 (proxies +# /api, /auth, /media to :8080). Needs the MEALQUEST_* env set. +# make test gofmt check, go vet, go test -race, npm typecheck and test. +# make build Build the PWA, embed it, build the static binary bin/mealquest. +# make docker Build the container image from ./Dockerfile. FRONTEND_DIST := internal/frontend/dist +# Dirty means a tracked change outside the generated embed output, which +# `make embed` rewrites; plain `--dirty` would flag every rebuild. +GIT_DIRTY := $(shell git status --porcelain --untracked-files=no -- . ':(exclude)$(FRONTEND_DIST)' 2>/dev/null) +VERSION ?= $(shell git describe --tags --always 2>/dev/null || echo dev)$(if $(GIT_DIRTY),-dirty) +IMAGE ?= mealquest:$(VERSION) +LDFLAGS := -s -w -X main.version=$(VERSION) -.PHONY: build web embed check-embed-clean +.PHONY: dev test go-test web-test build web embed check-embed-clean docker + +# Run both servers; Ctrl-C (or either exiting) stops both. The binary is built +# first and run directly because `go run` would orphan its child on kill. +dev: SHELL := /bin/bash +dev: + go build -ldflags "$(LDFLAGS)" -o bin/mealquest-dev ./cmd/server + @trap 'kill $$(jobs -p) 2>/dev/null; wait' EXIT; \ + bin/mealquest-dev & \ + (cd web && exec npm run dev) & \ + wait -n + +test: go-test web-test + +go-test: + @unformatted="$$(gofmt -l .)"; \ + if [ -n "$$unformatted" ]; then echo "gofmt needed:"; echo "$$unformatted"; exit 1; fi + go vet ./... + go test -race ./... + +web-test: + cd web && npm ci && npm run typecheck && npm test build: embed - go build -o bin/mealquest ./cmd/server + CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -o bin/mealquest ./cmd/server web: cd web && npm ci && npm run build @@ -22,3 +55,6 @@ check-embed-clean: touch $(FRONTEND_DIST)/.stale-embed-check $(MAKE) embed test ! -e $(FRONTEND_DIST)/.stale-embed-check + +docker: + docker build --build-arg VERSION=$(VERSION) -t $(IMAGE) .