Files
accounted/DOCKER.md
T
Jakob Wennberg c74b19df1b Accounted rebrand + swarm-skill cleanup + bank-reconciliation fixes (#643)
* feat(reconciliation): close the bank-feed loop on voucher links and re-tag mis-typed opening balances

Two related fixes to bank reconciliation correctness:

1. Auto-reconcile on voucher link. Linking an invoice or supplier invoice to
   an existing voucher previously advanced only the invoice — the bank
   transaction that paid it kept sitting in the Transactions inbox with a null
   journal_entry_id. linkInvoiceToVoucher / linkSupplierInvoiceToVoucher now
   call autoReconcileTransactionForLinkedVoucher (lib/reconciliation), which
   links the bank transaction to the same verifikat when exactly one unbooked
   line matches it. Best-effort and post-commit: a failure here never fails the
   link. The result surfaces reconciledTransactionId; the inbox row leaves the
   list and the UI shows link_success_tx_reconciled.

2. Re-tag mis-typed opening balances. getReconciliationStatus and the GL-line
   matching RPCs identify a cash account's ingående balans solely by
   journal_entries.source_type='opening_balance'. Companies migrated from other
   systems often booked the bank IB as an ordinary voucher (source_type
   'import' or 'manual'), so it was never excluded and surfaced as a phantom
   reconciliation difference equal to the opening balance. Adds:
   - migration mark_entry_as_opening_balance: a GUC-gated carve-out in the
     immutability trigger plus a SECURITY DEFINER RPC that validates the entry
     (balance-sheet lines only, dated on a fiscal-period boundary), flips the
     source_type, and writes an audit row — no blanket data sweep.
   - POST /api/reconciliation/bank/mark-opening-balance + MarkOpeningBalanceSchema.
   - BankReconciliationView action to trigger it from the IB diff.

The gnubok_create_voucher executor now accepts a typed is_opening_balance flag
and derives source_type='opening_balance' only after validating class 1/2 lines
on the period start, so new IBs land correctly typed.

Covered by lib/reconciliation auto-reconcile tests, voucher-executors tests,
and a mark-entry-as-opening-balance pg-real test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: rebrand gnubok → Accounted and prune swarm agent skills

Product rebrand and skills housekeeping. No runtime behaviour change.

Rebrand: replace user-visible "gnubok" with "Accounted" across docs, READMEs,
in-code comments, doc-site content, MCP skill/resource prose, and the
gnubok-mcp package description. The MCP resource URI scheme is moved gnubok://
→ Accounted:// consistently across resource registrations, the event-type
comment, and the resource/skill tests. Deliberately preserved as stable
identifiers (NOT rebranded): the gnubok-company-id cookie, gnubok_sk_ / gnubok_inv_
token prefixes, the gnubok-mcp npm bridge name, and the AGI <gem:Programnamn>
value (kept 'gnubok' per its source comment — it is the software identifier sent
to Skatteverket and must not churn across visual rebrands).

Skills: remove the 27 swarm-* agent SKILL.md atoms (no longer used; already
absent from the agent_atom_registry in prod), refresh the remaining skill docs,
add the .claude/rules/ path-scoped rule set, and regenerate the
seed_agent_atom_bodies migration + .skill-body-manifest.json via
`npm run skills:generate` so the DB-backed skill bodies match the trimmed set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 10:52:01 +02:00

7.7 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

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 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)

ANTHROPIC_API_KEY=sk-ant-...
OPENAI_API_KEY=sk-...

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; Dependabot opens PRs weekly 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