* feat(ai): resolve the Claude backend from the environment Tier 1 of #1406: a self-hosted deployment can now run every AI feature on a plain ANTHROPIC_API_KEY, with no AWS account. Hosted behaviour is unchanged. lib/ai/provider.ts resolves the backend once, from the environment: AI_PROVIDER explicit override, bedrock|anthropic AWS static key pair Bedrock ANTHROPIC_API_KEY the direct Anthropic API nothing set Bedrock, so the AWS credential provider chain (instance profile, IRSA) still resolves Bedrock deliberately wins when both credential sets are present. EU residency in eu-north-1 is a BFL/GDPR posture rather than a default, so adding an Anthropic key for an experiment must not silently move production inference out of the region. AI_PROVIDER is the way to say you meant it. Model ids are written bare in code and prefixed to eu.anthropic.* only for Bedrock, which needs the cross-region inference profile for on-demand throughput. An operator override that already carries a prefix passes through untouched, so BEDROCK_MODEL_ID and friends keep working as written. Converted call sites: the agent composer, invoice-inbox extraction, the document-extraction model label, and both receipt-hunt clients. The last two are not named in the issue, which predates receipt-hunt landing in main. @anthropic-ai/sdk is declared at 0.95.0, the version @anthropic-ai/bedrock-sdk 0.29.1 already pulled in transitively, so the lockfile dedupes to one copy with no new download. scripts/smoke-bedrock.ts becomes scripts/smoke-ai.ts and grows two steps. Unit tests can only prove which provider and model id get resolved; they cannot prove the resulting request is one the backend accepts. The script now sends real traffic over all three shapes the app uses: a plain create, a streamed turn carrying adaptive thinking, an effort level, an hour-long cache breakpoint and a tool, and document extraction end to end when given a file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Bjorn Bergenheim <29535152+bjornbergenheim@users.noreply.github.com> * docs(self-hosting): document the AI smoke test The script added alongside the provider split is what closes the #1406 acceptance criterion ("document extraction and the assistant both work"), so a self-hoster needs to know it exists. Covers both invocations and states that it exits non-zero, which is what makes it usable as a post-deploy check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Bjorn Bergenheim <29535152+bjornbergenheim@users.noreply.github.com> * test(ai): split the smoke test's thinking probe from its tool probe The combined probe could not falsify what it claimed to. It asked a question that needs a tool call, so the tool was used and adaptive thinking correctly declined to reason about it: the zero thinking-block count that came back was uninformative rather than a signal. 2a keeps the tool and drops thinking. 2b asks a question with several dependent steps (reverse charge, then a partial deduction, then the affected boxes) so that a model honouring the parameter must reason, and reports the thinking text length as well as the block count, since display:"summarized" can yield blocks with empty text. The cached system prompt is also padded past the 1024-token minimum cacheable prefix. Below that the API caches nothing and reports no error, so the old probe's cache counters read zero whether or not caching worked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Bjorn Bergenheim <29535152+bjornbergenheim@users.noreply.github.com> * fix(document-extraction): stop requiring AWS_REGION in the manifest The extension now needs one of two credential sets, AWS static keys or ANTHROPIC_API_KEY, and the manifest schema cannot express "one of". Since requiredEnvVars only drives a build-time warning and never gates anything, listing AWS_REGION told every self-hoster running the direct API to set a variable that has no effect for them. The description was also still promising Sonnet 4.6 via Bedrock specifically, which is no longer what the extension does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Bjorn Bergenheim <29535152+bjornbergenheim@users.noreply.github.com> * fix(ai): read documentKind defensively in the smoke test The field arrived with the receipt-aware extraction work, so referencing it directly stops the script compiling against any checkout from before that landed. tsconfig includes **/*.ts and next.config does not disable type checking, so on such a checkout this failed the production build rather than just the script: caught while preparing a test branch for a self-hosted instance that had not synced yet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Bjorn Bergenheim <29535152+bjornbergenheim@users.noreply.github.com> * fix(deps): restore the nested @swc/helpers entry in the lockfile Declaring @anthropic-ai/sdk with `npm install --package-lock-only` also pruned node_modules/next-intl/node_modules/@swc/helpers@0.5.23, an optional peer entry the local npm 11 considers redundant and the image's npm 10.9.8 does not. The result passed every local check and failed `npm ci` inside the Docker build, which is the only place the lockfile is actually enforced. The lockfile is now the previous one plus the single root dependency line, verified with `npm ci --dry-run`. @anthropic-ai/sdk needed nothing else: it was already in the tree as a transitive dependency of @anthropic-ai/bedrock-sdk. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Bjorn Bergenheim <29535152+bjornbergenheim@users.noreply.github.com> * Update DECISIONS.md Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update Docker documentation for AI provider credentials Clarify the role of credentials in AI provider selection and document extraction requirements. * Update SELF-HOSTING.md with smoke-ai script details Clarify usage of smoke-ai script for credential checks and document extraction. * Improve error handling and logging in smoke-ai script * fix(ai): complete plain-key self-hosting path Signed-off-by: Emil <emilmattsson14@gmail.com> --------- Signed-off-by: Bjorn Bergenheim <29535152+bjornbergenheim@users.noreply.github.com> Signed-off-by: Emil <emilmattsson14@gmail.com> Co-authored-by: Bjorn Bergenheim <29535152+bjornbergenheim@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
11 KiB
Self-Hosting Accounted with Docker
Prerequisites
- Docker and Docker Compose (v2)
- A Supabase 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
mkdir Accounted && cd Accounted
# 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
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 Accounted (e.g. https://gnubok.example.com) |
CRON_SECRET |
Any random string: openssl rand -hex 32 works |
Once .env is filled in, restrict its permissions so other users on the host can't read your service-role key or cron secret:
chmod 600 .env
3. Start
docker compose up -d
The app is now reachable on loopback only at http://127.0.0.1:3000. This is intentional: direct internet exposure over HTTP is not safe for an accounting app. The next section enables HTTPS.
4. Verify
# Should return {"status":"healthy",...}
curl http://localhost:3000/api/health
Synology DSM and Xpenology
Use a Container Manager Project, not the single-container wizard. The project path is the working directory for every relative path in the Compose file. Create that directory first and put all of these files in it before deploying the project:
docker-compose.yml
.env
docker/
cron.Dockerfile
crontab.self-hosted
Uploading only docker-compose.yml is not enough: the cron service is built
from docker/cron.Dockerfile and bind-mounts
docker/crontab.self-hosted. Keep .env readable only by the administrator
and Container Manager because it contains the Supabase service-role key.
Container Manager ships its own Compose build, and supported keys vary by DSM
release. Accounted's base Compose file avoids the optional cpus and
healthcheck.start_interval keys for compatibility. Set a CPU limit through
Container Manager's resource controls or a local override if needed. When
updating an existing deployment that relied on the previous two-CPU cap,
reapply that limit in the host controls before restarting the project.
If you run Docker Compose 2.20.2 or newer against Docker Engine 25.0 or newer, the optional resource overlay restores the previous two-CPU cap and faster startup health checks while keeping the base file compatible. Download the overlay from the same Accounted tag or full commit as the base Compose file:
ACCOUNTED_REF=replace-with-the-same-tag-or-full-commit
curl -fsSLo docker-compose.resources.yml \
"https://raw.githubusercontent.com/erp-mafia/accounted/${ACCOUNTED_REF}/docker-compose.resources.yml"
docker compose -f docker-compose.yml -f docker-compose.resources.yml up -d
Compose only applies the files named in each invocation. Keep the resource
overlay in every later up command, after any other overlay. For example:
# HTTPS with Caddy
docker compose -f docker-compose.yml -f docker-compose.caddy.yml -f docker-compose.resources.yml up -d
# Local image build
docker compose -f docker-compose.yml -f docker-compose.build.yml -f docker-compose.resources.yml up --build -d
Do not use this overlay if Container Manager rejects either key or the Docker Engine is older than 25.0. The memory and PID limits remain active in the base file either way.
Accounted itself does not use PostgreSQL port 5432 and does not need a database data folder when connected to Supabase Cloud. If Supabase is also running on the NAS, follow the fully self-hosted notes for its separate project, bind mounts, ports, and JWKS configuration.
Enable HTTPS (recommended)
Ship a Caddy reverse proxy alongside the app: it auto-provisions Let's Encrypt certificates and renews them forever.
1. Point a domain at the host
gnubok.example.com → <your-public-ip> (A record). Ports 80 and 443 must be reachable from the internet (Let's Encrypt's HTTP-01 challenge uses port 80).
2. Set DOMAIN in .env
DOMAIN=gnubok.example.com
NEXT_PUBLIC_APP_URL=https://gnubok.example.com
3. Download the overlay + Caddyfile
curl -fsSLO https://raw.githubusercontent.com/gnubok/gnubok/main/docker-compose.caddy.yml
mkdir -p docker
curl -fsSL -o docker/Caddyfile \
https://raw.githubusercontent.com/gnubok/gnubok/main/docker/Caddyfile
4. Start with the overlay
docker compose -f docker-compose.yml -f docker-compose.caddy.yml up -d
Caddy obtains a cert on first boot (takes ~10 s). Visit https://gnubok.example.com.
If you already have nginx / a managed load balancer / Cloudflare in front, skip Caddy and point your existing proxy at 127.0.0.1:3000: set NEXT_PUBLIC_APP_URL to match the public URL.
Optional Extensions
The self-hosted image ships with a curated set of general extensions, including email, invoice inbox, document extraction, push notifications, calendar, and the MCP server. Enable Banking is excluded because it 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 (document-extraction, invoice-inbox, AI assistant)
All AI runs Claude. Provide either a direct Anthropic API key:
ANTHROPIC_API_KEY=sk-ant-...
or AWS credentials with Bedrock model access to Claude, which keeps inference in eu-north-1:
AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...
AWS_REGION=eu-north-1
If both are set, Bedrock is used; AI_PROVIDER=bedrock|anthropic forces the choice. OPENAI_API_KEY from earlier versions is not read by any code path. See SELF-HOSTING.md for optional model overrides.
The stock self-hosted image includes both invoice-inbox and
document-extraction, so the same provider credentials cover emailed invoices
and documents uploaded in the app.
Email (invoice sending, reminders)
RESEND_API_KEY=re_...
RESEND_FROM_EMAIL=faktura@your-domain.com
RESEND_WEBHOOK_SECRET=whsec_...
Push Notifications
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
The default IMAGE_TAG=latest follows main and updates on every docker compose pull. For production, pin to a specific release so updates are deliberate:
# .env
IMAGE_TAG=1.2.3
Browse available tags at https://github.com/erp-mafia/gnubok/pkgs/container/gnubok. For maximum integrity, pin by digest:
IMAGE_TAG=1.2.3@sha256:abcdef...
Apply updates:
docker compose pull
docker compose up -d
The cron sidecar is a small Alpine image built locally: it rebuilds automatically on up --build if you re-download docker/cron.Dockerfile. Base-image digests (node, alpine, caddy) are pinned in source and bumped manually when upstream ships security updates.
Building from Source
If you prefer to build locally instead of pulling the pre-built image:
# Clone the repo
git clone https://github.com/gnubok/gnubok.git
cd Accounted
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 |
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 image is built with placeholder values (e.g. __NEXT_PUBLIC_SUPABASE_URL__) baked into the JavaScript bundles. At container start, docker-entrypoint.sh runs as root, sed-substitutes the placeholders with your runtime env vars, then runs chmod -R a-w /app/.next/static and drops privileges with su-exec nextjs:nodejs before exec'ing Node. The served JS bundle is owned by root and read-only by the time the application starts: a runtime RCE in the Node process cannot rewrite what other users will receive.
Ports
The app listens on port 3000 inside the container. The base compose binds it to 127.0.0.1:3000 on the host: change PORT in .env to remap. To expose on all interfaces (only do this if you're putting your own reverse proxy in front), override the port binding in a local docker-compose.override.yml:
services:
app:
ports: !override
- "${PORT:-3000}:3000"
Reverse Proxy
The preferred path is the bundled Caddy overlay: see Enable HTTPS. If you already run nginx, Traefik, or sit behind Cloudflare, leave the app on 127.0.0.1:3000 and point your existing proxy at it. Set NEXT_PUBLIC_APP_URL to the public URL.
Example nginx upstream:
server {
server_name gnubok.example.com;
listen 443 ssl http2;
# ssl_certificate / ssl_certificate_key / etc.
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Troubleshooting
Container exits immediately
docker compose logs app
Most common cause: missing required env vars. Check that all 5 required values in .env are set.
Health check fails
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
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