diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..7fc56f7
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,11 @@
+data/
+*.db
+*.db-shm
+*.db-wal
+.env
+.git/
+.claude/
+node_modules/
+out/
+tmp/
+qbank
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..69f5bcc
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,49 @@
+# ── Stage 1: build ────────────────────────────────────────────────────────────
+FROM golang:1.25-alpine AS builder
+
+WORKDIR /src
+
+# Install wget for downloading the Tailwind standalone CLI.
+RUN apk add --no-cache wget
+
+# Download Tailwind v3 standalone CLI for the target architecture.
+# Supported: linux/amd64, linux/arm64.
+RUN ARCH=$(uname -m) && \
+ case "$ARCH" in \
+ x86_64) TW_ARCH=x64 ;; \
+ aarch64) TW_ARCH=arm64 ;; \
+ *) echo "Unsupported arch: $ARCH" && exit 1 ;; \
+ esac && \
+ wget -qO /usr/local/bin/tailwindcss \
+ "https://github.com/tailwindlabs/tailwindcss/releases/download/v3.4.17/tailwindcss-linux-${TW_ARCH}" && \
+ chmod +x /usr/local/bin/tailwindcss
+
+# Fetch Go module dependencies (cached separately from source).
+COPY go.mod go.sum ./
+RUN go mod download
+
+# Copy the rest of the source.
+COPY . .
+
+# Compile Tailwind CSS and switch the template from CDN to the compiled file.
+RUN tailwindcss -i web/templates/input.css -o web/static/tailwind.css --minify && \
+ sed -i 's|||' \
+ web/templates/layout.html
+
+# Build the Go binary.
+RUN CGO_ENABLED=0 GOOS=linux go build -o /out/qbank ./cmd/server
+
+# ── Stage 2: run ──────────────────────────────────────────────────────────────
+FROM gcr.io/distroless/static-debian12:nonroot
+
+WORKDIR /app
+
+COPY --from=builder /out/qbank /app/qbank
+COPY --from=builder /src/web/templates /app/web/templates
+COPY --from=builder /src/web/static /app/web/static
+
+EXPOSE 8080
+
+USER nonroot:nonroot
+
+ENTRYPOINT ["/app/qbank"]
diff --git a/cmd/server/main.go b/cmd/server/main.go
index ae9c811..440e2f8 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -26,6 +26,11 @@ import (
)
func main() {
+ if len(os.Args) > 1 && os.Args[1] == "healthcheck" {
+ runHealthcheck()
+ return
+ }
+
cfg := config.Load()
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
@@ -127,6 +132,17 @@ func main() {
slog.Info("server stopped")
}
+func runHealthcheck() {
+ port := os.Getenv("PORT")
+ if port == "" {
+ port = "8080"
+ }
+ resp, err := http.Get("http://localhost:" + port + "/healthz")
+ if err != nil || resp.StatusCode != http.StatusOK {
+ os.Exit(1)
+ }
+}
+
func ensureIcons(dir string) {
for _, size := range []int{192, 512} {
path := filepath.Join(dir, fmt.Sprintf("icon-%d.png", size))
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..f18e75e
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,23 @@
+services:
+ qbank:
+ image: ghcr.io//qbank:latest
+ container_name: qbank
+ restart: unless-stopped
+ ports:
+ - "8080:8080"
+ environment:
+ DATA_DIR: /data
+ PORT: "8080"
+ OPENAI_API_KEY: ${OPENAI_API_KEY}
+ SESSION_SECRET: ${SESSION_SECRET}
+ ADMIN_USERS: ${ADMIN_USERS}
+ volumes:
+ - qbank-data:/data
+ healthcheck:
+ test: ["CMD", "/app/qbank", "healthcheck"]
+ interval: 30s
+ timeout: 5s
+ retries: 3
+
+volumes:
+ qbank-data:
diff --git a/web/templates/layout.html b/web/templates/layout.html
index 90acde7..ee980db 100644
--- a/web/templates/layout.html
+++ b/web/templates/layout.html
@@ -5,7 +5,7 @@
QBank
-
+