Files
Mattsson d8cf78330e docs(self-hosting): own-credentials section, stale connector lines, complete .env.example (#2146)
* docs(self-hosting): own-credentials section, stale connector lines, complete .env.example (#2131)

SELF-HOSTING.md said the Skatteverket client wiring "ships in a following
release"; PR #2103 merged it, so both bank sync and Skatteverket now carry
traffic through the hosted proxy with a key. The two stale sentences are
replaced and SOVEREIGN.md line 48 says the same thing.

New "Own credentials (no connector key)" subsection documents the path an
operator takes without a key: Enable Banking app in restricted production
mode with the callback URL, the Skatteverket developer-portal application
with the redirect URI, every variable the code reads, the five production
base URLs (all defaults point at the test environment), the kill switch,
and the rule that any own credential switches that upstream out of
connector mode.

.env.example gains the Skatteverket block, the optional Enable Banking
variables, and RESEND_INBOUND_DOMAIN / RESEND_INBOUND_WEBHOOK_SECRET, which
the invoice-inbox manifest requires but the example never listed.
DOCKER.md no longer claims Enable Banking is excluded from the self-host
preset (docker/extensions.self-hosted.json ships it).

ENABLE_BANKING_SANDBOX is removed from the enable-banking manifest and the
index.ts header: declared as optional, never read anywhere; the sandbox is
selected by ENABLE_BANKING_API_URL. Logged in DECISIONS.md.

Closes #2131

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012C6M2ZoZc6QDRxU3m9WzgE
Signed-off-by: Emil <emilmattsson14@gmail.com>

* docs(self-hosting): correct key format, AISP scope caveat, SKV scopes and rotation note (#2131)

Skeptic findings on PR #2146, one pass:
- ENABLE_BANKING_PRIVATE_KEY: the decoder base64-decodes first and wraps
  anything else as DER, so a raw PEM fails at JWT signing. The docs and
  .env.example no longer claim it is accepted.
- Enable Banking restricted mode covers the operator's own accounts only;
  an instance hosting client companies is doing licensed AIS and needs
  the connector key or its own AISP registration. Said so.
- Listed the OAuth scopes the app requests (both AGI scopes), noted that
  the kill switch gates API calls, not the BankID login, and that the
  token encryption key has no dual-key rotation.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012C6M2ZoZc6QDRxU3m9WzgE
Signed-off-by: Emil <emilmattsson14@gmail.com>

---------

Signed-off-by: Emil <emilmattsson14@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 23:08:47 +02:00

12 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/erp-mafia/accounted/main/docker-compose.yml
curl -fsSLO https://raw.githubusercontent.com/erp-mafia/accounted/main/.env.docker.example

# Cron sidecar (Dockerfile + schedule)
mkdir -p docker
curl -fsSL -o docker/cron.Dockerfile \
  https://raw.githubusercontent.com/erp-mafia/accounted/main/docker/cron.Dockerfile
curl -fsSL -o docker/crontab.self-hosted \
  https://raw.githubusercontent.com/erp-mafia/accounted/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.


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/erp-mafia/accounted/main/docker-compose.caddy.yml
mkdir -p docker
curl -fsSL -o docker/Caddyfile \
  https://raw.githubusercontent.com/erp-mafia/accounted/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 and Skatteverket are in the preset too: they run on a connector key or on your own credentials (see SELF-HOSTING.md, "Connector subscription"). 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_DELIVERY_WEBHOOK_SECRET=whsec_...

Invitations do not need Resend: the accept link is returned to the inviter in the app. See SELF-HOSTING.md.

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 build so updates are deliberate. Every merge to main publishes the image under two tags: latest and the bare 7-character commit SHA (for example 3e4b5dd), so a pin looks like:

# .env
IMAGE_TAG=3e4b5dd

Browse available tags at https://github.com/erp-mafia/accounted/pkgs/container/gnubok (the image name keeps the historical gnubok package name on purpose). For maximum integrity, pin by digest; the digest is printed in the docker-publish workflow run and by docker buildx imagetools inspect ghcr.io/erp-mafia/gnubok:<sha>:

IMAGE_TAG=3e4b5dd@sha256:abcdef...

Semver tags (1.2.3, 1.2, 1) are published only when a v*.*.* git tag is cut. No such tag exists yet, so until the first tagged release the commit SHA is the only immutable pin.

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/erp-mafia/accounted.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