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 - +