diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..feaf88b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +node_modules +.git +.gitignore +*.md +.env* +.wrangler +dist +coverage +*.log +.github diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..7a3d4d3 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,64 @@ +name: Docker Build & Push + +on: + push: + branches: [main] + tags: ["v*.*.*"] + pull_request: + branches: [main] + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + # Multi-arch support: linux/amd64 and linux/arm64 + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + # Login only on push, not on PRs from forks + - name: Login to GHCR + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Auto-generates tags: latest, v1.2.3, v1.2, sha-abc1234 + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha,prefix=sha- + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + provenance: true + sbom: true diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8a8fa74 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,66 @@ +# ─── Stage 1: Builder ──────────────────────────────────────────────────────── +FROM node:22-slim AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm ci --frozen-lockfile +COPY . . +RUN npm run build --if-present + +# ─── Stage 2: Runner (final image) ─────────────────────────────────────────── +# node:22-slim (Debian) instead of Alpine: workerd binary requires glibc +FROM node:22-slim + +# Apply security patches + install runtime dependencies: +# - dumb-init: PID 1 process manager for correct SIGTERM handling in Kubernetes +# - wget: used by the HEALTHCHECK to ping the HTTP endpoint +# - ca-certificates: required by workerd for HTTPS calls to GitHub (payload fetch) +RUN apt-get update && apt-get upgrade -y --no-install-recommends && \ + apt-get install -y --no-install-recommends \ + dumb-init \ + wget \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Create a dedicated non-root user with fixed UID/GID 1001 +# -m creates the home directory /home/appuser +# wrangler writes logs to ~/.config/.wrangler/ so the home dir is required +RUN groupadd -g 1001 appgroup && \ + useradd -u 1001 -g appgroup -m -s /bin/sh appuser + +WORKDIR /app +COPY --chown=appuser:appgroup package*.json ./ + +# npm ci runs here (not copied from builder) to ensure platform-native workerd binary +# --include=optional ensures workerd is downloaded as an optional dependency +# wrangler@latest fixes the mkdtemp bug present in the bundled 4.42.0 +RUN npm ci --frozen-lockfile --include=optional && \ + npm install wrangler@latest && \ + mkdir -p /app/.wrangler-tmp && \ + chown -R appuser:appgroup /app + +# Copy only what is needed at runtime from the builder stage +COPY --from=builder --chown=appuser:appgroup /app/app ./app +COPY --chown=appuser:appgroup wrangler.toml . + +# wrangler needs HOME to locate ~/.config/.wrangler/ +ENV HOME=/home/appuser +# Disable telemetry +ENV WRANGLER_SEND_METRICS=false +# Prevent wrangler from opening a browser tab +ENV BROWSER=none +# Use /app/.wrangler-tmp instead of /tmp to avoid noexec mount issues in Docker Desktop +ENV TMPDIR=/app/.wrangler-tmp + +USER appuser +EXPOSE 8787 + +# Check every 30s that the server is responding +# start-period gives wrangler 20s to boot before health checks begin +HEALTHCHECK --interval=30s --timeout=10s --start-period=20s --retries=3 \ + CMD wget -qO- http://localhost:8787/ || exit 1 + +# dumb-init as PID 1 for correct signal handling in Kubernetes +ENTRYPOINT ["dumb-init", "--"] +# --ip 0.0.0.0: required to be reachable outside the container (default is 127.0.0.1) +# --local: runs without Cloudflare authentication +CMD ["npx", "wrangler", "dev", "--ip", "0.0.0.0", "--port", "8787", "--local"]