From b5b004509d6cc579936e366b298a403149a46cb9 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Tue, 3 Mar 2026 13:40:33 +0100 Subject: [PATCH] feat: add Docker support with self-hosted and hosted presets Multi-stage Dockerfile (Node 22 Alpine), docker-compose for self-hosted and hosted deployments, CI workflow for GHCR publishing, runtime env var substitution, and cron sidecar with supercronic. Aligns hosted extension preset with dev config (enable-banking, ai-categorization, ai-chat, email). Co-Authored-By: Claude Opus 4.6 --- .dockerignore | 20 +++ .github/workflows/docker-publish.yml | 50 +++++++ DOCKER.md | 191 +++++++++++++++++++++++++++ Dockerfile | 63 +++++++++ docker-compose.build.yml | 9 ++ docker-compose.hosted.yml | 13 ++ docker-compose.yml | 29 ++++ docker-entrypoint.sh | 15 +++ docker/cron.Dockerfile | 13 ++ docker/crontab.hosted | 5 + docker/crontab.self-hosted | 4 + docker/extensions.hosted.json | 1 + docker/extensions.self-hosted.json | 1 + 13 files changed, 414 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/workflows/docker-publish.yml create mode 100644 DOCKER.md create mode 100644 Dockerfile create mode 100644 docker-compose.build.yml create mode 100644 docker-compose.hosted.yml create mode 100644 docker-compose.yml create mode 100755 docker-entrypoint.sh create mode 100644 docker/cron.Dockerfile create mode 100644 docker/crontab.hosted create mode 100644 docker/crontab.self-hosted create mode 100644 docker/extensions.hosted.json create mode 100644 docker/extensions.self-hosted.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..d2f06d2a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,20 @@ +node_modules +.next +.git +.gitignore +.env* +!.env.docker.example +supabase/ +**/__tests__/ +**/*.test.ts +**/*.test.tsx +**/*.spec.ts +tests/ +.claude/ +.github/ +.vscode/ +*.md +!README.md +!DOCKER.md +LICENSE +docker-compose*.yml diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 00000000..aca3eb86 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,50 @@ +name: Build and Push Docker Image + +on: + push: + branches: [main] + +env: + REGISTRY: ghcr.io + IMAGE_NAME: gnubok/gnubok + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - uses: actions/checkout@v4 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=latest + type=sha,prefix= + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + EXTENSIONS_PRESET=self-hosted + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/DOCKER.md b/DOCKER.md new file mode 100644 index 00000000..23896da1 --- /dev/null +++ b/DOCKER.md @@ -0,0 +1,191 @@ +# Self-Hosting gnubok with Docker + +## Prerequisites + +- Docker and Docker Compose (v2) +- A [Supabase](https://supabase.com) project (free tier works) + +You do **not** need Node.js, npm, or anything else installed locally. The pre-built image has everything. + +--- + +## Quick Start + +### 1. Download the required files + +```bash +mkdir gnubok && cd gnubok + +# Compose file + env template +curl -fsSLO https://raw.githubusercontent.com/gnubok/gnubok/main/docker-compose.yml +curl -fsSLO https://raw.githubusercontent.com/gnubok/gnubok/main/.env.docker.example + +# Cron sidecar (Dockerfile + schedule) +mkdir -p docker +curl -fsSL -o docker/cron.Dockerfile \ + https://raw.githubusercontent.com/gnubok/gnubok/main/docker/cron.Dockerfile +curl -fsSL -o docker/crontab.self-hosted \ + https://raw.githubusercontent.com/gnubok/gnubok/main/docker/crontab.self-hosted +``` + +### 2. Configure your environment + +```bash +cp .env.docker.example .env +``` + +Open `.env` and fill in the **required** values: + +| Variable | Where to find it | +|----------|-----------------| +| `NEXT_PUBLIC_SUPABASE_URL` | Supabase dashboard → Settings → API → Project URL | +| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Supabase dashboard → Settings → API → `anon` `public` key | +| `SUPABASE_SERVICE_ROLE_KEY` | Supabase dashboard → Settings → API → `service_role` key | +| `NEXT_PUBLIC_APP_URL` | The URL where you'll access gnubok (e.g. `https://gnubok.example.com`) | +| `CRON_SECRET` | Any random string — `openssl rand -hex 32` works | + +### 3. Start + +```bash +docker compose up -d +``` + +That's it. The app is now running at `http://localhost:3000` (or whatever port you set with `PORT`). + +### 4. Verify + +```bash +# Should return {"status":"healthy",...} +curl http://localhost:3000/api/health +``` + +--- + +## Optional Extensions + +The self-hosted image ships with all extensions enabled (except Enable Banking, which requires private PSD2 credentials). Each extension activates when you provide its env vars — without them, the app works normally and the feature is simply unavailable. + +### AI Features (ai-categorization, ai-chat, receipt-ocr, invoice-inbox) + +```env +ANTHROPIC_API_KEY=sk-ant-... +OPENAI_API_KEY=sk-... +``` + +### Email (invoice sending, reminders) + +```env +RESEND_API_KEY=re_... +RESEND_FROM_EMAIL=faktura@your-domain.com +RESEND_WEBHOOK_SECRET=whsec_... +``` + +### Push Notifications + +```env +NEXT_PUBLIC_VAPID_PUBLIC_KEY=... +VAPID_PRIVATE_KEY=... +VAPID_SUBJECT=mailto:you@example.com +``` + +Generate VAPID keys with: `npx web-push generate-vapid-keys` + +### Calendar + +No env vars needed — always available. + +--- + +## Updating + +```bash +docker compose pull # pulls latest app image from GHCR +docker compose up -d # recreates containers if image changed +``` + +The `latest` tag always points to the newest build from `main`. The cron sidecar is a small Alpine image built locally — it updates automatically on `up` if you re-download `docker/cron.Dockerfile`. + +--- + +## Building from Source + +If you prefer to build locally instead of pulling the pre-built image: + +```bash +# Clone the repo +git clone https://github.com/gnubok/gnubok.git +cd gnubok +cp .env.docker.example .env +# Fill in .env + +# Build and start +docker compose -f docker-compose.yml -f docker-compose.build.yml up --build -d +``` + +--- + +## Architecture + +The compose setup runs two containers: + +| Container | What it does | +|-----------|-------------| +| `app` | Next.js application server | +| `cron` | Lightweight Alpine sidecar that runs scheduled jobs (deadline checks, invoice reminders, tax deadline sync, document verification) via [supercronic](https://github.com/aptible/supercronic) | + +The cron container waits for the app's healthcheck to pass before starting. It calls the app's cron API endpoints over the internal Docker network. + +### How NEXT_PUBLIC_* injection works + +The Docker image is built with placeholder values (e.g. `__NEXT_PUBLIC_SUPABASE_URL__`) baked into the JavaScript bundles. When the container starts, `docker-entrypoint.sh` replaces those placeholders with your actual env vars via `sed`. This means the same image works for any Supabase project — no rebuilding needed. + +--- + +## Ports + +The app listens on port 3000 inside the container. To map it to a different host port: + +```env +PORT=8080 +``` + +Then access at `http://localhost:8080`. + +--- + +## Reverse Proxy + +For production, put the app behind a reverse proxy (nginx, Caddy, Traefik) that handles TLS. Example with Caddy: + +``` +gnubok.example.com { + reverse_proxy localhost:3000 +} +``` + +Make sure `NEXT_PUBLIC_APP_URL` matches the public URL (e.g. `https://gnubok.example.com`). + +--- + +## Troubleshooting + +**Container exits immediately** +```bash +docker compose logs app +``` +Most common cause: missing required env vars. Check that all 5 required values in `.env` are set. + +**Health check fails** +```bash +curl -v http://localhost:3000/api/health +``` +The health endpoint tests database connectivity. If it returns `unhealthy`, verify your Supabase URL and service role key are correct. + +**Cron container keeps restarting** +```bash +docker compose logs cron +``` +The cron container depends on the app being healthy first. If the app never becomes healthy, the cron container will wait indefinitely. + +**Port already in use** +Set a different port: `PORT=8080 docker compose up -d` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..c24dd89c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,63 @@ +# ── Stage 1: Base ── +FROM node:22-alpine AS base +RUN apk add --no-cache libc6-compat + +# ── Stage 2: Dependencies ── +FROM base AS deps +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci + +# ── Stage 3: Builder ── +FROM base AS builder +WORKDIR /app + +ARG EXTENSIONS_PRESET=self-hosted + +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Apply extension preset (must happen before build — prebuild hook +# runs setup:extensions which reads extensions.config.json) +COPY docker/extensions.${EXTENSIONS_PRESET}.json ./extensions.config.json + +# Build with placeholder sentinel values for NEXT_PUBLIC_* vars. +# These get replaced at runtime by docker-entrypoint.sh so the image +# is generic and reusable across different Supabase projects. +ENV NEXT_PUBLIC_SUPABASE_URL=__NEXT_PUBLIC_SUPABASE_URL__ +ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=__NEXT_PUBLIC_SUPABASE_ANON_KEY__ +ENV NEXT_PUBLIC_APP_URL=__NEXT_PUBLIC_APP_URL__ +ENV NEXT_PUBLIC_VAPID_PUBLIC_KEY=__NEXT_PUBLIC_VAPID_PUBLIC_KEY__ + +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN npm run build + +# ── Stage 4: Runner ── +FROM node:22-alpine AS runner +WORKDIR /app + +RUN apk add --no-cache curl + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN addgroup --system --gid 1001 nodejs && \ + adduser --system --uid 1001 nextjs + +# Copy standalone output +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +# Copy entrypoint script +COPY --chmod=755 docker-entrypoint.sh ./docker-entrypoint.sh + +USER nextjs + +EXPOSE 3000 +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 + +ENTRYPOINT ["./docker-entrypoint.sh"] +CMD ["node", "server.js"] diff --git a/docker-compose.build.yml b/docker-compose.build.yml new file mode 100644 index 00000000..ccc3474e --- /dev/null +++ b/docker-compose.build.yml @@ -0,0 +1,9 @@ +# Override for building from source instead of pulling from GHCR. +# Usage: docker compose -f docker-compose.yml -f docker-compose.build.yml up --build +services: + app: + image: !reset null + build: + context: . + args: + EXTENSIONS_PRESET: self-hosted diff --git a/docker-compose.hosted.yml b/docker-compose.hosted.yml new file mode 100644 index 00000000..62f39914 --- /dev/null +++ b/docker-compose.hosted.yml @@ -0,0 +1,13 @@ +# Override for hosted deployment with all extensions including Enable Banking. +# Usage: docker compose -f docker-compose.yml -f docker-compose.hosted.yml up --build +services: + app: + image: !reset null + build: + context: . + args: + EXTENSIONS_PRESET: hosted + + cron: + volumes: + - ./docker/crontab.hosted:/etc/supercronic/crontab:ro diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..46ca56cf --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,29 @@ +services: + app: + image: ghcr.io/gnubok/gnubok:latest + env_file: .env + ports: + - "${PORT:-3000}:3000" + security_opt: + - no-new-privileges:true + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"] + interval: 30s + timeout: 5s + start_period: 10s + retries: 3 + + cron: + build: + context: docker + dockerfile: cron.Dockerfile + depends_on: + app: + condition: service_healthy + environment: + - CRON_SECRET=${CRON_SECRET} + - APP_URL=http://app:3000 + volumes: + - ./docker/crontab.self-hosted:/etc/supercronic/crontab:ro + restart: unless-stopped diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100755 index 00000000..75cf7a58 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,15 @@ +#!/bin/sh +set -e + +# Replace build-time placeholder sentinels with runtime env vars in static JS bundles. +# This allows a single pre-built image to work with any Supabase project. +if [ -d /app/.next/static ]; then + find /app/.next/static -name '*.js' -exec sed -i \ + -e "s|__NEXT_PUBLIC_SUPABASE_URL__|${NEXT_PUBLIC_SUPABASE_URL}|g" \ + -e "s|__NEXT_PUBLIC_SUPABASE_ANON_KEY__|${NEXT_PUBLIC_SUPABASE_ANON_KEY}|g" \ + -e "s|__NEXT_PUBLIC_APP_URL__|${NEXT_PUBLIC_APP_URL}|g" \ + -e "s|__NEXT_PUBLIC_VAPID_PUBLIC_KEY__|${NEXT_PUBLIC_VAPID_PUBLIC_KEY:-}|g" \ + {} + +fi + +exec "$@" diff --git a/docker/cron.Dockerfile b/docker/cron.Dockerfile new file mode 100644 index 00000000..c7ccd7b6 --- /dev/null +++ b/docker/cron.Dockerfile @@ -0,0 +1,13 @@ +FROM alpine:3.19 + +ARG SUPERCRONIC_VERSION=v0.2.33 +ARG TARGETARCH + +RUN apk add --no-cache curl \ + && ARCH=$(case ${TARGETARCH} in amd64) echo "linux-amd64";; arm64) echo "linux-arm64";; *) echo "linux-amd64";; esac) \ + && curl -fsSL "https://github.com/aptible/supercronic/releases/download/${SUPERCRONIC_VERSION}/supercronic-${ARCH}" \ + -o /usr/local/bin/supercronic \ + && chmod +x /usr/local/bin/supercronic + +ENTRYPOINT ["supercronic"] +CMD ["/etc/supercronic/crontab"] diff --git a/docker/crontab.hosted b/docker/crontab.hosted new file mode 100644 index 00000000..086b9159 --- /dev/null +++ b/docker/crontab.hosted @@ -0,0 +1,5 @@ +0 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/ext/enable-banking/sync/cron +0 6 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/deadlines/status/cron +0 8 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/invoices/reminders/cron +0 0 2 1 * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/tax-deadlines/cron +0 3 * * 0 curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/documents/verify/cron diff --git a/docker/crontab.self-hosted b/docker/crontab.self-hosted new file mode 100644 index 00000000..ec969101 --- /dev/null +++ b/docker/crontab.self-hosted @@ -0,0 +1,4 @@ +0 6 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/deadlines/status/cron +0 8 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/invoices/reminders/cron +0 0 2 1 * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/tax-deadlines/cron +0 3 * * 0 curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/documents/verify/cron diff --git a/docker/extensions.hosted.json b/docker/extensions.hosted.json new file mode 100644 index 00000000..56ee175e --- /dev/null +++ b/docker/extensions.hosted.json @@ -0,0 +1 @@ +{"extensions": ["enable-banking", "ai-categorization", "ai-chat", "email"]} diff --git a/docker/extensions.self-hosted.json b/docker/extensions.self-hosted.json new file mode 100644 index 00000000..75e40a89 --- /dev/null +++ b/docker/extensions.self-hosted.json @@ -0,0 +1 @@ +{"extensions": ["ai-categorization", "ai-chat", "receipt-ocr", "invoice-inbox", "email", "push-notifications", "calendar"]}