From fa4091a673af5d16e5bd7eff7cc31038ee8a0615 Mon Sep 17 00:00:00 2001 From: Daniel Noulet Date: Sun, 20 Sep 2026 08:42:58 +0200 Subject: [PATCH] feat: add production container publishing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .dockerignore | 15 ++++++++ .github/workflows/publish-image.yml | 59 +++++++++++++++++++++++++++++ Dockerfile | 48 +++++++++++++++++++++++ README.md | 25 ++++++++++++ docker/entrypoint.sh | 27 +++++++++++++ docker/nginx.conf | 44 +++++++++++++++++++++ 6 files changed, 218 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/workflows/publish-image.yml create mode 100644 Dockerfile create mode 100755 docker/entrypoint.sh create mode 100644 docker/nginx.conf diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b2e5d7e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +.git +.github +.idea +.vscode +.env +.env.* +*.md + +**/node_modules +frontend/dist +frontend/coverage +**/*.tsbuildinfo + +backend/bin +backend/coverage.out diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml new file mode 100644 index 0000000..2fa934f --- /dev/null +++ b/.github/workflows/publish-image.yml @@ -0,0 +1,59 @@ +name: Publish Docker image + +on: + push: + branches: + - master + workflow_dispatch: + +permissions: + contents: read + +env: + IMAGE_NAME: batty1039.startdedicated.net:5000/go-guess + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + publish-image: + name: Build and push Docker image + runs-on: ubuntu-latest + + steps: + - name: Check out source + uses: actions/checkout@v5 + + - name: Configure HTTP registry + run: | + sudo mkdir -p /etc/docker + echo '{"insecure-registries":["batty1039.startdedicated.net:5000"]}' | \ + sudo tee /etc/docker/daemon.json >/dev/null + sudo systemctl restart docker + cat > /tmp/buildkitd.toml <<'EOF' + [registry."batty1039.startdedicated.net:5000"] + http = true + insecure = true + EOF + + - name: Log in to private registry + uses: docker/login-action@v4 + with: + registry: batty1039.startdedicated.net:5000 + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_PASSWORD }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + with: + buildkitd-config: /tmp/buildkitd.toml + + - name: Build and push image + uses: docker/build-push-action@v7 + with: + context: . + file: ./Dockerfile + push: true + tags: | + ${{ env.IMAGE_NAME }}:latest + ${{ env.IMAGE_NAME }}:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..76597c4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,48 @@ +# Build the React frontend. +FROM node:26-alpine AS frontend +WORKDIR /src/frontend +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci +COPY frontend/ ./ +RUN npm run build + +# Compile the API and seed binaries. +FROM golang:1.27.1-alpine AS backend +WORKDIR /src/backend +COPY backend/go.mod backend/go.sum ./ +RUN go mod download +COPY backend/ ./ +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/api ./cmd/api \ + && CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/seed ./cmd/seed + +# Build the migration CLI separately so its dependencies stay out of the app layer. +FROM golang:1.27.1-alpine AS tools +RUN GOBIN=/out go install github.com/pressly/goose/v3/cmd/goose@v3.26.0 + +# Run the frontend and API behind one HTTP endpoint. +FROM alpine:3.22 +RUN apk add --no-cache ca-certificates nginx tzdata \ + && addgroup -S app \ + && adduser -S -G app -u 10001 app \ + && mkdir -p /app/frontend /app/migrations /tmp/nginx \ + && chown -R app:app /app /tmp/nginx + +WORKDIR /app +COPY --from=frontend --chown=app:app /src/frontend/dist/ ./frontend/ +COPY --from=backend /out/api /usr/local/bin/api +COPY --from=backend /out/seed /usr/local/bin/seed +COPY --from=tools /out/goose /usr/local/bin/goose +COPY --chown=app:app backend/migrations/ ./migrations/ +COPY docker/nginx.conf /etc/nginx/nginx.conf +COPY docker/entrypoint.sh /usr/local/bin/go-guess-entrypoint + +USER app +EXPOSE 8080 +ENV ADDRESS=:8081 \ + APP_ENV=production \ + FRONTEND_URL=http://localhost:8080 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \ + CMD wget -qO- http://127.0.0.1:8080/api/health >/dev/null || exit 1 + +ENTRYPOINT ["/usr/local/bin/go-guess-entrypoint"] diff --git a/README.md b/README.md index b7b24ca..1d134fe 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,31 @@ make test-env make test-env-down ``` +## Production image + +Build the complete application as a single image: + +```bash +docker build -t go-guess . +``` + +The image serves the frontend and API on port `8080`. It applies database +migrations and shared production fixtures before startup. PostgreSQL, RabbitMQ, +and these environment variables must be provided: + +```bash +docker run --rm -p 8080:8080 \ + -e DATABASE_URL='postgres://user:password@postgres:5432/go_guess?sslmode=disable' \ + -e RABBITMQ_URL='amqp://user:password@rabbitmq:5672/' \ + -e JWT_SECRET='replace-with-at-least-32-characters' \ + -e FRONTEND_URL='https://go-guess.example.com' \ + go-guess +``` + +Pushes to `master` publish `go-guess:latest` and `go-guess:` to the +private registry configured in `.github/workflows/publish-image.yml`. The +repository must provide `REGISTRY_USERNAME` and `REGISTRY_PASSWORD` secrets. + ## Architecture Requests enter the Chi router and web handlers under `backend/internal/web`. diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 0000000..2f93ca7 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,27 @@ +#!/bin/sh +set -eu + +shutdown() { + trap - TERM INT + kill -TERM "$api_pid" "$nginx_pid" 2>/dev/null || true + wait "$api_pid" "$nginx_pid" 2>/dev/null || true + exit 0 +} + +goose -dir /app/migrations postgres "$DATABASE_URL" up +seed + +api & +api_pid=$! +nginx -g 'daemon off;' & +nginx_pid=$! + +trap shutdown TERM INT + +while kill -0 "$api_pid" 2>/dev/null && kill -0 "$nginx_pid" 2>/dev/null; do + sleep 1 +done + +kill -TERM "$api_pid" "$nginx_pid" 2>/dev/null || true +wait "$api_pid" "$nginx_pid" 2>/dev/null || true +exit 1 diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 0000000..86f7916 --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,44 @@ +worker_processes auto; +pid /tmp/nginx.pid; +error_log /dev/stderr warn; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + access_log /dev/stdout; + sendfile on; + + client_body_temp_path /tmp/nginx/client_body; + proxy_temp_path /tmp/nginx/proxy; + fastcgi_temp_path /tmp/nginx/fastcgi; + uwsgi_temp_path /tmp/nginx/uwsgi; + scgi_temp_path /tmp/nginx/scgi; + + server { + listen 8080; + server_name _; + + root /app/frontend; + index index.html; + + location /api/ { + proxy_pass http://127.0.0.1:8081; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 1h; + } + + location / { + try_files $uri $uri/ /index.html; + } + } +}