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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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`
|
||||
+63
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Executable
+15
@@ -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 "$@"
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
{"extensions": ["enable-banking", "ai-categorization", "ai-chat", "email"]}
|
||||
@@ -0,0 +1 @@
|
||||
{"extensions": ["ai-categorization", "ai-chat", "receipt-ocr", "invoice-inbox", "email", "push-notifications", "calendar"]}
|
||||
Reference in New Issue
Block a user