* feat(entitlements): partition the self-host bypass so connector capabilities fall through to grants; capability_grants.source accepts 'connector' Sovereign plan WS3 PR3: ships dark, nothing changes for hosted. - lib/entitlements/keys.ts: CONNECTOR_CAPABILITIES = bank_sync, skatteverket, org_lookup, migration (services Accounted operates that a self-hosted instance cannot provide itself) + isConnectorCapability(). Separate from PAID_CAPABILITIES and outside the trial-seed trigger on purpose: a hosted company can never hold a connector grant. - lib/entitlements/has-capability.ts: isPaywallBypassed() -> isBypassedFor(key). Hosted: byte-identical (dev / DISABLE_PAYWALL bypass, FORCE_PAYWALL wins, else the grant lookup). Self-host: local capabilities always on (FORCE_PAYWALL included, as the existing test demands); connector capabilities behave like hosted, i.e. dev bypass, FORCE_PAYWALL, else the grant lookup where the connector sync will write source='connector' rows. getCompanyEntitlements on a self-host: local paid keys + active connector keys, state 'paid' with an active connector grant else 'none' (never the hosted trial copy). - Migration 20260820122000: capability_grants.source CHECK gains 'connector', found through pg_constraint (the CHECK was declared inline and auto-named; Postgres stores IN as = ANY, matched accordingly). pg-real test: connector accepted, unknown source rejected, upsert on the (scope, key, source) identity, trial seed writes no connector rows. - Tests: self-hosted connector matrix (local all-on without DB, connector gated by grant/expiry, dev bypass all-on, FORCE_PAYWALL gates connector keys only, bulk resolution, entitlements shape); two pre-existing tests that asserted the old "self-host holds connector keys" contract updated to the new one. Verified: full unit suite green, pg-real suite for lib/entitlements green against a local supabase/postgres with every migration applied, lint ratchet, guards. Deferred to the instance-wiring PR: adding the connector extensions to the self-host Docker preset (dead-end upsells until a key can be issued). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(entitlements): fold the self-host branch into the existing grants query One .or(scopeFilter), not two: the duplicated helper pushed the no-phantom-columns unresolvable-expression count to 380/379. Behaviour is unchanged; the self-host matrix tests still pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(connect): hosted connector-key registry + validate RPC + entitlements endpoint; instance sync writes connector grants hourly Sovereign plan WS3 PR4 ("key infra enabling manual sales"), stacked on the entitlement partition (#1747). Nothing is purchasable yet; this is the plumbing both ends need before the first manually issued key. Hosted side: - Migration 20260820123000: connector_keys (SHA-256 key_hash, prefix, org_number, pinned instance_url, scopes, status, Stripe ids, current_period_end, per-minute rate limit, active_company_count, last_seen/synced) and connector_usage_events (per-request metering, separate from metered_events whose company_id references hosted companies). RLS on, NO policies: service role only. RPC validate_and_increment_connector_key copies the api_keys pattern (FOR UPDATE, minute window, suspended reported not counted, revoked = no row) and is REVOKEd from PUBLIC/anon/authenticated, GRANTed to service_role. pg-real test covers validate/count, unknown+revoked, suspended, rate limit, execute privileges per role, RLS invisibility, usage cascade. - lib/connect/contract.ts (shared wire types), lib/connect/hosted/keys.ts (generate/hash/validate -> 401/403/429 mapping), with-connector-auth.ts (Bearer or X-Connector-Key, one usage row per request, 500 envelope on handler throw), /api/connect/entitlements GET + POST (records active_company_count, pins instance_url on first report, never moves a pinned one), scripts/issue-connector-key.ts (dry run unless --confirm, prints the key once + the .env lines). Instance side: - lib/connect/instance/config.ts (GNUBOK_CONNECTOR_KEY, GNUBOK_CONNECT_URL default https://app.gnubok.se), sync.ts: reports the active company count and writes source='connector' grants for every company x covered scope, expires_at = min(now+72h, period_end+3d); 401/403 or a non-active status deletes them (freeze-and-retain); network/5xx/429 leave them alone. /api/connector/sync/cron (hourly) runs it; not_configured without a key. - Crontab generator gains EXTRA_JOBS (variant-only jobs not in vercel.json, with reasons) + drift tests; docker/crontab.self-hosted regenerated with the hourly sync. Docs (SELF-HOSTING connector section, env templates), DECISIONS. Tests: 52 new unit tests (keys, auth wrapper, route, config, sync outcomes and grant arithmetic, cron route, crontab EXTRA_JOBS) + 7 pg-real tests run locally against supabase/postgres with every migration applied. no-phantom-columns ceiling +1 with a reason (the bulk grant upsert). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(connect): Enable Banking proxy for self-hosted instances, with a secret-free ownership ledger and a global rate budget Sovereign plan WS3 PR5a, stacked on the connector-key infra (#1748). A self-hosted instance with a `bank_sync`-scoped connector key can now connect a bank through Arcim's PSD2 credentials; the bank session id and all transaction data stay in the instance's own database (founder decision: tokens on the instance, proxy stateless). - Migration 20260820124000: `connector_connections` (secret-free ledger: sha256 of the EB session id + account uids, service-role only), `connector_upstream_counters` + RPC `connector_reserve_upstream` (global budget under EB Annex 1 §5's 300/min, shared with hosted), and `connector_keys.limits` jsonb; validate RPC v2 returns limits. All RPCs REVOKEd from PUBLIC/anon/authenticated, GRANTed service_role. pg-real covers all of it. - EB JWT minting moved to lib/connect/upstreams/enable-banking-jwt.ts (core must not import @/extensions/); the extension re-exports it, tests unchanged. - lib/connect/hosted/{state,ledger,upstream-budget}.ts: HMAC-signed connector state (15-min TTL) so the consent redirect can use OUR registered EB callback and bounce back to the instance, no per-instance redirect URI at EB; the callback route gains that connector branch. - app/api/connect/bank/[...path]: path allowlist (aspsps, auth, sessions, accounts/{uid}/{balances,transactions}), never open passthrough. POST /auth enforces the per-company connection quota + rewrites redirect/state; reads/deletes verify ledger ownership; every upstream call takes the global budget (429 + Retry-After when exhausted). - issue-connector-key.ts: scopes default bank_sync,skatteverket (TIC out of v1), --bank/skv-connections-per-company + --sync-min-interval. - Docs (SELF-HOSTING: bank connector live), DECISIONS. Verified: 52 connect unit tests + 13 pg-real (run locally against supabase/postgres with all migrations) + EB extension suite (225, jwt relocation intact); full unit suite 15 979 green; tsc, guards, lint clean. Not in this PR: SKV broker (PR5b) and instance wiring (PR6). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(connect): Skatteverket broker + data proxy for self-hosted instances (tokens stay on the instance) Sovereign plan WS3 PR5b, stacked on the bank proxy (#1751). A self-hosted instance with a `skatteverket`-scoped connector key can now run the BankID consent, file VAT/AGI and sync skattekonto through Arcim's registered Skatteverket client; the SKV tokens are returned to the instance and stored (encrypted) there. - lib/connect/upstreams/skatteverket-oauth.ts: core-side SKV OAuth + data helpers (authorize URL, code/refresh exchange with Arcim's client secret, the four backing-API base URLs, the API-gateway Client_Id/Client_Secret headers). Core can't import @/extensions/, so this duplicates the extension's endpoints/scope set (one integrator = Arcim), mirroring the EB JWT relocation. - app/api/connect/skv/oauth/authorize-url: builds the authorize URL against OUR registered redirect_uri + a signed connector state, per-company SKV connection quota, pending ledger row. - app/api/connect/skv/oauth/token: exchanges/refreshes and RETURNS the tokens to the instance; the ledger keeps only sha256(access_token) + sha256(refresh_token). - app/api/connect/skv/api/[...path]: allowlist over moms / skattekonto / agd-inlamning / agd-period. The instance sends the user's SKV Bearer (as X-Connector-Upstream-Authorization) + X-Connector-Key; the proxy checks the token hash against the ledger, adds Arcim's gateway credentials (never exposed to the instance), forwards. Same per-key + global budget as bank. - The Skatteverket extension /callback gains the connector branch (isConnectorState -> 302 back to the instance; code never exchanged there). - Docs (SELF-HOSTING: SKV connector live) + DECISIONS. Tests: SKV oauth lib, authorize-url, token, data proxy, callback connector branch (all green; 74 connect + 425 connect/SKV). tsc, guards, lint clean; no-phantom-columns held at 380 (literal update branches). Not in this PR: instance-side wiring (PR6). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(connect): self-host connector enablement: EB/SKV in the preset, connector-mode seam, status endpoint Sovereign plan WS3 PR6 (enablement layer), stacked on the SKV broker (#1757). - docker/extensions.self-hosted.json += enable-banking, skatteverket: a connector-key self-host now ships the bank + Skatteverket extensions; a key with the matching scope makes them work, without one they show the existing capability_blocked upsell (unconfigured extensions no-op). - lib/connect/instance/upstreams.ts: the connector-mode seam. An upstream is in connector mode only when GNUBOK_CONNECTOR_KEY is set AND the instance has no own credentials for it (hasOwnEnableBankingCredentials / hasOwnSkatteverketCredentials). Hosted always has own credentials, so hosted is provably never in connector mode: the guard is what keeps hosted byte-identical. Base URLs GNUBOK_CONNECT_URL/api/connect/{bank,skv}, headers X-Connector-Company / X-Connector-Upstream-Authorization. - GET /api/connector/status: the operator's wiring view (self_hosted, per upstream own_credentials|connector|unconfigured, key prefix never the key, granted connector capabilities). Hosted returns self_hosted:false. - Docs (SELF-HOSTING: status endpoint + extensions ship in the image), DECISIONS. Tests: connector-mode detection matrix (off without a key, off with own creds incl. the _PRODUCTION EB variants, on via the proxy, CONNECT_URL override) + status route (self-host vs hosted, unconfigured, per-upstream mode, prefix-not-key). 83 connect/connector tests green; tsc, guards, lint. DEFERRED to PR6b (needs a live connector key + a real bank/SKV to verify end to end, touches the live consent path): wiring the EB api-client / consent callback and the SKV oauth / api-client to call the proxy in connector mode, and the "Synka nu" settings row (UI, needs visual sign-off). The seam + preset + status route make PR6b a contained follow-up. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(connect): upstreams seam reuses lib/entitlements/own-credentials Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb * test(connector): status route tests pass the Next params argument (post-merge withRouteContext signature) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb * docs(self-host): collapse the re-duplicated connector section; correct the crontab generator's preset comment Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb * fix(self-host): UpgradeNote and SKV tooltip name the connector key, never the hosted subscription; SOVEREIGN.md updated to merged reality Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb * fix(self-host): BankSyncNowButton gate copy branches like UpgradeNote (connector key, not hosted billing) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Emil <emilmattsson14@gmail.com>
628 lines
38 KiB
Markdown
628 lines
38 KiB
Markdown
# Self-Hosting Accounted
|
|
|
|
This guide walks you through deploying Accounted on your own infrastructure using Docker.
|
|
|
|
## Prerequisites
|
|
|
|
- Docker and Docker Compose v2+
|
|
- A Supabase project (free tier works: create one at [supabase.com](https://supabase.com))
|
|
|
|
## 1. Create a Supabase Project
|
|
|
|
1. Go to [supabase.com](https://supabase.com) and create a new project.
|
|
2. Note these values from **Settings > API**:
|
|
- `Project URL` (e.g., `https://abcdefgh.supabase.co`)
|
|
- `anon` public key
|
|
- `service_role` secret key
|
|
|
|
## 2. Configure Supabase Auth
|
|
|
|
In the Supabase dashboard under **Authentication > URL Configuration**:
|
|
|
|
1. Set **Site URL** to your deployment URL (e.g., `https://gnubok.example.com`).
|
|
2. Add `https://gnubok.example.com/auth/callback` to the **Redirect URLs** allowlist.
|
|
|
|
Accounted uses email + password authentication with magic link as a fallback. The default Supabase email auth settings work out of the box. For production, configure a custom SMTP provider under **Authentication > SMTP Settings** to avoid Supabase's built-in rate limits.
|
|
|
|
MFA (two-factor authentication via TOTP) is **not enforced** for self-hosted deployments: the Docker image sets `NEXT_PUBLIC_SELF_HOSTED=true` by default, which disables MFA enforcement. Users can still optionally enable 2FA in Settings > Säkerhet if they wish. Idle and absolute session timeouts are also off by default for self-hosted installs; operators can opt in with the variables below.
|
|
|
|
## 3. Apply Database Migrations
|
|
|
|
The `supabase/migrations/` directory contains the ordered SQL files that set up the full schema, including tables, RLS policies, triggers, and functions.
|
|
|
|
**Option A: Supabase CLI (recommended):**
|
|
|
|
```bash
|
|
# Install the Supabase CLI
|
|
npm install -g supabase
|
|
|
|
# Link to your project
|
|
supabase link --project-ref <your-project-ref>
|
|
|
|
# Push all migrations
|
|
supabase db push
|
|
```
|
|
|
|
**Option B: SQL Editor:**
|
|
|
|
Run each file in `supabase/migrations/` in order in the Supabase SQL Editor. They must be applied sequentially: later migrations depend on earlier ones.
|
|
|
|
### PostgreSQL Extensions
|
|
|
|
The migrations automatically enable these extensions:
|
|
|
|
| Extension | Migration | Purpose |
|
|
|-----------|-----------|---------|
|
|
| `uuid-ossp` | 001 | UUID generation |
|
|
| `vector` (pgvector) | 033 | Created by an early migration; no current code path stores embeddings, the extension only needs to exist for the migration to apply |
|
|
| `btree_gist` | 042 | Fiscal period overlap prevention |
|
|
| `pg_cron` | 048 | In-database scheduled jobs |
|
|
|
|
These are all available on Supabase hosted. `pg_cron` requires a paid plan: if you are on the free tier, migration 048 will fail. You can safely skip it; the cron sidecar container handles the equivalent job via HTTP instead.
|
|
|
|
## 4. Configure Environment
|
|
|
|
**Option A: Setup script (recommended):**
|
|
|
|
```bash
|
|
git clone https://github.com/erp-mafia/accounted.git
|
|
cd accounted
|
|
./setup.sh
|
|
```
|
|
|
|
The script checks prerequisites, prompts for your Supabase credentials, auto-generates `CRON_SECRET`, and writes everything to `.env`.
|
|
|
|
**Option B: Manual:**
|
|
|
|
```bash
|
|
git clone https://github.com/erp-mafia/accounted.git
|
|
cd accounted
|
|
cp .env.docker.example .env
|
|
```
|
|
|
|
Edit `.env` with your values:
|
|
|
|
```bash
|
|
# ─── Required ───
|
|
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
|
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
|
|
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
|
|
NEXT_PUBLIC_APP_URL=https://your-domain.com
|
|
CRON_SECRET=<generate with: openssl rand -hex 32>
|
|
```
|
|
|
|
`NEXT_PUBLIC_APP_URL` must match your public-facing URL. It is used in invoice reminder emails, calendar feed links, and PSD2 callbacks. If left as a placeholder, links will be broken.
|
|
|
|
Automatic logout is opt-in per user: sessions only expire for users who have
|
|
enabled "Automatic logout" in Settings > Security (stored on
|
|
`user_preferences.auto_logout`, default off). For opted-in users, hosted
|
|
Accounted defaults to a 30-minute idle limit, a 12-hour absolute limit, and a
|
|
warning 2 minutes before expiry. Self-hosted installations leave both limits
|
|
disabled unless you opt in (values are milliseconds):
|
|
|
|
```bash
|
|
NEXT_PUBLIC_SESSION_IDLE_TIMEOUT_MS=1800000
|
|
NEXT_PUBLIC_SESSION_ABSOLUTE_TIMEOUT_MS=43200000
|
|
NEXT_PUBLIC_SESSION_WARNING_MS=120000
|
|
```
|
|
|
|
Set either timeout to `0` to disable only that limit. To enforce the timeouts
|
|
for every user regardless of their per-user preference (the pre-2026-08
|
|
behavior), also set:
|
|
|
|
```bash
|
|
NEXT_PUBLIC_SESSION_TIMEOUT_FORCE_ALL=true
|
|
```
|
|
|
|
Timeout state is signed
|
|
with `SUPABASE_SERVICE_ROLE_KEY` by default; set `SESSION_TIMEOUT_SECRET` to a
|
|
separate random value if you want to rotate it independently. Changing either
|
|
signing secret invalidates existing timeout cookies and requires users to sign
|
|
in again.
|
|
|
|
## 5. Start the Application
|
|
|
|
```bash
|
|
docker compose up -d
|
|
```
|
|
|
|
This starts two containers:
|
|
|
|
| Container | Purpose |
|
|
|-----------|---------|
|
|
| `app` | Next.js application (`ghcr.io/erp-mafia/gnubok:latest`) |
|
|
| `cron` | Scheduled jobs via [supercronic](https://github.com/aptible/supercronic) |
|
|
|
|
The cron container waits for the app health check to pass before starting.
|
|
|
|
Verify the deployment:
|
|
|
|
```bash
|
|
curl http://localhost:3000/api/health
|
|
# {"status":"healthy","timestamp":"...","version":"1.0.0"}
|
|
# `version` is the build commit SHA prefix when VERCEL_GIT_COMMIT_SHA or
|
|
# NEXT_PUBLIC_BUILD_ID was set at build time, otherwise "1.0.0".
|
|
```
|
|
|
|
> **Note:** The health check queries the database, so migrations must be applied before it returns healthy.
|
|
|
|
### Building from Source
|
|
|
|
To build the Docker image locally instead of pulling from GHCR:
|
|
|
|
```bash
|
|
docker compose -f docker-compose.yml -f docker-compose.build.yml up --build
|
|
```
|
|
|
|
The locally-built image runs **unprivileged** (`USER nextjs`): the entrypoint
|
|
populates the `.next`/`public` tmpfs mounts and substitutes the `NEXT_PUBLIC_*`
|
|
placeholders as the `nextjs` user, so the container needs no Linux capabilities
|
|
and runs as-is under the hardened compose defaults (`cap_drop: ALL`,
|
|
`read_only: true`).
|
|
|
|
### Custom Port
|
|
|
|
Set `PORT` in your `.env` or environment to change the host port (the container always listens on 3000 internally):
|
|
|
|
```bash
|
|
PORT=8080 docker compose up -d
|
|
```
|
|
|
|
## 6. First Login
|
|
|
|
1. Open your deployment URL in a browser.
|
|
2. Click "Skapa konto" (Create account) and register with email + password.
|
|
3. Check your email and click the confirmation link.
|
|
4. Complete the 5-step onboarding wizard:
|
|
- **Step 1**: Choose entity type (enskild firma or aktiebolag)
|
|
- **Step 2**: Company name and org number
|
|
- **Step 3**: Fiscal year, VAT registration, accounting method
|
|
- **Step 4**: Preliminary tax amount (optional, skip if unsure)
|
|
- **Step 5**: Bank details for invoices (optional)
|
|
|
|
There is no admin account: any email address can sign up (unless you turn public signup off, see the `AUTH_SIGNUPS_DISABLED` note under [Email](#email-invoice-sending-invitations-and-reminders)). You can also use the magic link option on the login page if preferred.
|
|
|
|
To bring in more users, invite them from **Settings > Company > Members** (company-scoped) or **Settings > Team** (consultant teams). Invitations work without a mail provider: the accept link is returned to the inviter right after the invite is created (shown under the pending list with a copy button), so it can be shared over any channel. Configuring Resend only adds automatic delivery. The link is shown once and cannot be re-sent for company invites: revoke the invitation and invite again to get a fresh link.
|
|
|
|
## Scheduled Jobs
|
|
|
|
The cron sidecar runs the schedule in [`docker/crontab.self-hosted`](../docker/crontab.self-hosted). That file is generated from the `crons` array in `vercel.json` (the single source of truth, shared with the hosted service) by `npm run crontabs:generate`, and a test fails CI if it drifts, so this guide does not repeat the table: open the file for the exact jobs and times. They fall into these groups:
|
|
|
|
- **Every minute / every few minutes**: webhook dispatch, WhatsApp and invoice-inbox sweeps (crash recovery for staged uploads).
|
|
- **Hourly**: recurring invoices, cloud-backup auto-sync, idempotency-key cleanup.
|
|
- **Nightly (UTC)**: deadline statuses, tax deadlines, document-archive SHA-256 verification, event and pending-operation cleanup, sandbox cleanup, booking-template sync, bank sync, skattekonto sync, accrual posting, receipt hunt, WhatsApp retention.
|
|
- **Skatteverket receipts**: AGI every 15 minutes, VAT every two hours.
|
|
|
|
Extension endpoints are listed unconditionally: one whose extension is not enabled answers a cheap no-op, so enabling it later needs no crontab change.
|
|
|
|
All cron endpoints are authenticated with `Authorization: Bearer <CRON_SECRET>`. The cron container calls the app over the internal Docker network (`http://app:3000`), so these endpoints are not exposed publicly.
|
|
|
|
Additionally, migration 048 schedules a `pg_cron` job inside the database that marks overdue supplier invoices daily at 06:00 UTC.
|
|
|
|
## Optional Features
|
|
|
|
### AI Features
|
|
|
|
All AI features (automatic interpretation of uploaded receipts and invoices via the `document-extraction` and `invoice-inbox` extensions, and the in-app AI assistant) run on one configured backend. There are three ways to provide one; pick one. Note that the agent surface most integrations use, the MCP server, needs no AI backend at all: it is your own agent (Claude, Codex, a local model) talking to the ledger, so a deployment without any of the credentials below is still fully usable that way.
|
|
|
|
The stock self-hosted image includes both extraction extensions, so these credentials cover emailed invoices and documents uploaded in the app.
|
|
|
|
**Option 1: the direct Anthropic API.** The simplest option for self-hosting, since it needs nothing but a key from [console.anthropic.com](https://console.anthropic.com). Billing is your own, separate from any Claude subscription.
|
|
|
|
```bash
|
|
ANTHROPIC_API_KEY=sk-ant-...
|
|
```
|
|
|
|
**Option 2: AWS Bedrock.** Requires an AWS account with Bedrock model access to Claude. This is what the hosted service runs, because it keeps inference inside the EU (the `eu.` cross-region inference profile; `AWS_REGION` is the API endpoint, not a pin to one region): choose it if you need the AI calls to stay in the EU, which the direct API does not guarantee.
|
|
|
|
```bash
|
|
AWS_ACCESS_KEY_ID=...
|
|
AWS_SECRET_ACCESS_KEY=...
|
|
AWS_REGION=eu-north-1 # default
|
|
```
|
|
|
|
Set both static AWS keys explicitly. The AI assistant's client can fall back to the standard AWS credential provider chain (instance profile, IRSA) when they are absent, but document extraction requires `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` and silently returns empty results without them.
|
|
|
|
**Option 3: any OpenAI-compatible endpoint.** Any server that implements the chat-completions API: a Swedish inference provider for a fully sovereign deployment, or a **local model** on the same machine (llama.cpp's `server`, Ollama's `/v1`, LM Studio, vLLM). Document extraction (receipts, invoices, HTML mail invoices), the assistant's question-and-answer (on both `/chat` and the docked assistant sheet, via `/api/agent/ask`), and one-tap transaction categorization run here on any provider. A set of specialized conversational flows still needs an Anthropic-family backend; see **What runs on any model** below.
|
|
|
|
```bash
|
|
AI_BASE_URL=http://localhost:11434/v1 # the endpoint's OpenAI-compatible base URL (here: a local Ollama)
|
|
AI_MODEL=qwen3.8 # a model id is required: there is no default for an arbitrary endpoint
|
|
# AI_API_KEY=... # OPTIONAL: only when the endpoint needs auth. A local server usually
|
|
# # has none, so leave it unset; a hosted provider gives you a key.
|
|
# AI_EXTRACTION_MODEL=... # optional: a vision model for document reading, if AI_MODEL is not one
|
|
```
|
|
|
|
Three things about such endpoints are declared rather than probed, because the app cannot tell from the outside:
|
|
|
|
- `AI_VISION=false` says the configured model cannot read images. Images and PDFs are then skipped honestly (the inbox row lands with the empty skeleton and the "AI-tolkning kördes inte" hint) instead of failing with a 400 on every upload; HTML mail invoices still extract as text on any model.
|
|
- `AI_PDF_MODE` defaults to `rasterize` here: most such endpoints have no PDF input, so the first `AI_PDF_MAX_PAGES` pages (default 4) are rendered to images with poppler's `pdftoppm`, which the self-host image installs (`apk add poppler-utils`, the only system package beyond the base image; page images are written to `/tmp`, a tmpfs in `docker-compose.yml`). If the binary is missing, PDFs are skipped with `pdf_rasterizer_missing` rather than failing; `AI_PDF_RASTERIZER_BIN` points at a non-standard install. A provider that accepts the OpenAI `file` content part can use `AI_PDF_MODE=native`.
|
|
- `AI_STRICT_JSON=true` asks for `response_format: json_schema` on providers that enforce it. The default (JSON answered in prose, then parsed and validated) works on every model and is what hosted runs.
|
|
|
|
#### What runs on any model
|
|
|
|
Most AI surfaces run on any of the three backends above, an OpenAI-compatible or local model included:
|
|
|
|
- **Document extraction**: receipts, invoices, and HTML mail invoices.
|
|
- **The AI assistant's question-and-answer**, on both `/chat` and the docked assistant sheet (`/api/agent/ask`), including its read-only ledger tools.
|
|
- **One-tap transaction categorization** (`/api/agent/categorize`): the deterministic-candidates then model-select cascade shown on the transactions page.
|
|
|
|
A few **specialized conversational flows** still run on the older streaming runtime (`/api/agent/invoke`), which requires an Anthropic-family model (AWS Bedrock or the direct Anthropic API). On an OpenAI-compatible or local backend these specific actions return `503`; everything above keeps working. They are:
|
|
|
|
- **Operation-staging flows** (they draft a change for you to approve): the invoice-inbox "Fraga assistenten" categorize, bulk-book, invoice draft, supplier-invoice review, and verifikat draft.
|
|
- **Context-bound assistant helpers**: VAT review, KPI explanation, settings help, the bokslut step-through, and onboarding.
|
|
|
|
Migrating these to the single-call surface, so a fully local deployment covers them too, is tracked in [#1800](https://github.com/erp-mafia/accounted/issues/1800).
|
|
|
|
Optional model overrides, in any setup:
|
|
|
|
```bash
|
|
AI_MODEL=... # default model for every tier (OpenAI-compatible: required)
|
|
AI_EXTRACTION_MODEL=... # document extraction model
|
|
AI_HEAVY_MODEL=... # assistant model, heavy intents
|
|
AI_ASSISTANT_MODEL=... # assistant model, standard intents
|
|
AI_EXTRACTION_MAX_TOKENS=8192 # output cap for document extraction
|
|
AI_PROVIDER=bedrock|anthropic|openai-compatible # force the backend (see below)
|
|
```
|
|
|
|
The pre-existing names `BEDROCK_MODEL_ID`, `BEDROCK_OPUS_MODEL_ID`, `BEDROCK_SONNET_MODEL_ID` and `BEDROCK_MAX_TOKENS` keep working as the same overrides (extraction, heavy, standard, extraction cap) on every backend; the `AI_*` names take precedence when both are set. Claude deployments default every tier to `claude-sonnet-5`.
|
|
|
|
When several credential sets are present, Bedrock wins, then the direct Anthropic API, then the OpenAI-compatible endpoint, so that adding a key for an experiment cannot silently move production inference out of the EU. Set `AI_PROVIDER` to say which you mean. A model id written without a provider prefix is adapted to whichever backend is active; an id that already carries one (`eu.anthropic.…`) is used as-is.
|
|
|
|
Without working credentials the rest of the app runs normally: uploads are stored but not auto-interpreted (the upload UI sees that immediately rather than waiting for a timeout), and the AI assistant answers `503 ai_unconfigured`.
|
|
|
|
#### Verifying the setup
|
|
|
|
`scripts/smoke-ai-provider.ts` is the "is AI wired up?" command. It works the same on every backend because it only talks to the app's AI service: it prints the resolved provider, the model per tier, the PDF mode (and whether `pdftoppm` is installed when PDFs are rasterized), then sends real traffic: one small text generation per tier model, one schema-shaped answer, and, when you pass a file, the exact document-extraction path an uploaded receipt takes. It exits non-zero if any step fails, so it works as a post-deploy check. Run it from a checkout next to the env file your deployment uses (`.env.local`, then `.env` are read):
|
|
|
|
```bash
|
|
npx tsx scripts/smoke-ai-provider.ts # provider, models, text + structured calls
|
|
npx tsx scripts/smoke-ai-provider.ts ./receipt.pdf # also runs document extraction end to end
|
|
```
|
|
|
|
A skipped extraction is reported as a failure with the reason: a text-only model (`ai_no_vision`, pick a vision model for `AI_EXTRACTION_MODEL`), a missing rasterizer (`pdf_rasterizer_missing`, install poppler-utils or set `AI_PDF_MODE=native`), or no credentials/model at all.
|
|
|
|
On the Anthropic family, `scripts/smoke-ai.ts` additionally probes the in-app assistant's full parameter set (a streamed turn with a tool, adaptive thinking, effort and the prompt cache), which the assistant still sends through the Anthropic SDK directly:
|
|
|
|
```bash
|
|
npx tsx scripts/smoke-ai.ts # credentials, models, chat loop
|
|
# Note: this check only detects static credentials (ANTHROPIC_API_KEY or
|
|
# AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY). Bedrock deployments using an
|
|
# instance profile or IRSA won't be picked up automatically: set
|
|
# AI_PROVIDER=bedrock to run the probes against the AWS credential chain
|
|
# anyway.
|
|
npx tsx scripts/smoke-ai.ts ./receipt.pdf # also runs document extraction
|
|
```
|
|
|
|
> **Note:** `OPENAI_API_KEY` from earlier versions is not read by any code path. To use OpenAI itself, point Option 3 at `https://api.openai.com/v1`; the app has no provider-specific OpenAI integration, only the OpenAI-compatible one. Background: [#1406](https://github.com/erp-mafia/accounted/issues/1406).
|
|
|
|
### Email (Invoice Sending, Invitations and Reminders)
|
|
|
|
Outbound mail (invoices, reminders, payslips) goes through one of two providers. Auth/account mail is sent by Supabase Auth and is not affected.
|
|
|
|
**Option 1: Resend** (what hosted runs):
|
|
|
|
```bash
|
|
RESEND_API_KEY=re_...
|
|
RESEND_FROM_EMAIL=noreply@your-domain.com
|
|
```
|
|
|
|
Requires a [Resend](https://resend.com) account with a verified sender domain.
|
|
|
|
**Option 2: your own SMTP relay** (a Swedish mail provider, a Microsoft 365 / Google Workspace relay, Postfix on the host):
|
|
|
|
```bash
|
|
EMAIL_PROVIDER=smtp
|
|
SMTP_HOST=smtp.example.se
|
|
SMTP_PORT=587 # default 587; 465 with SMTP_SECURE=true
|
|
SMTP_SECURE=false # true = implicit TLS, false = STARTTLS required (set SMTP_REQUIRE_TLS=false only for a plaintext LAN relay)
|
|
SMTP_USER=... # optional for an internal relay
|
|
SMTP_PASS=...
|
|
SMTP_FROM_EMAIL=faktura@your-domain.se
|
|
# SMTP_REQUIRE_TLS=false # only for a plaintext relay on a trusted LAN: without it a relay that cannot STARTTLS fails the send instead of leaking credentials and invoice PDFs in cleartext
|
|
# SMTP_TLS_REJECT_UNAUTHORIZED=false # only for a LAN relay with a self-signed certificate
|
|
```
|
|
|
|
`EMAIL_PROVIDER` is optional: with a `RESEND_API_KEY` present Resend is used, otherwise `SMTP_HOST` selects SMTP, so adding SMTP variables next to an existing Resend key never moves mail by accident. Set it explicitly when both are configured. The From header is built identically on both providers (the company or brand name as display name, the platform address from `RESEND_FROM_EMAIL` or `SMTP_FROM_EMAIL` unless the company has a verified sending domain); the delivery-status webhook is Resend-only.
|
|
|
|
Without either, invoices can still be generated as PDFs but cannot be emailed.
|
|
|
|
**Invitations do not require Resend.** When no mail provider is configured, the invite is still created and the accept link is returned to the inviter in the app (a copy button under the pending invitations list, plus a warn-level log record whose msg is `email service not configured: invite email skipped`; the Docker image logs JSON, so grep for the message text, not a `WARN` prefix; the token is never logged). Share the link manually; it is valid until the invitation expires. A mail provider (Resend, or your own relay with `EMAIL_PROVIDER=smtp`, both above) is only needed if you want the invitation mailed automatically. There is no re-send for company invitations: revoke and invite again for a new link.
|
|
|
|
Note the two separate mail paths: this variable drives the app's own mail (invoices, invitations, reminders); account mail from GoTrue (signup confirmation, password reset, and the account-provisioning invite when `AUTH_SIGNUPS_DISABLED=true`) goes through the Supabase **Authentication > SMTP Settings** described in [Configure Authentication](#2-configure-supabase-auth) and [Troubleshooting](#troubleshooting).
|
|
|
|
```bash
|
|
AUTH_SIGNUPS_DISABLED=true
|
|
```
|
|
|
|
Set this when you have turned public signup off in GoTrue (`disable_signup`). The invite route then provisions the invitee's account through the auth admin API before writing the invitation, and GoTrue mails its own set-password link via the Supabase SMTP settings; the in-app accept link is still returned to the inviter.
|
|
|
|
### Connector subscription (self-hosted instances)
|
|
|
|
Everything a self-hosted instance runs itself is free (AGPL). Four capabilities depend on services only Accounted operates and are therefore gated on a self-host: bank sync (our PSD2/AISP credentials), Skatteverket API submission and skattekonto sync (our API client registration), company lookup (TIC) and migration from Fortnox/Visma/Bokio/Björn Lundén (the migration gateway). A **connector key** unlocks them for every company on the instance; it is priced per active company at parity with hosted and is issued manually by Accounted for now (self-serve later).
|
|
|
|
```bash
|
|
GNUBOK_CONNECTOR_KEY=gnubok_ck_... # issued by Accounted, shown once
|
|
# GNUBOK_CONNECT_URL=https://app.gnubok.se # default: the hosted connector service
|
|
```
|
|
|
|
The cron sidecar calls `/api/connector/sync/cron` hourly (it is listed in `docker/crontab.self-hosted` only): the instance reports its active company count, the hosted service answers with the key's status and scopes, and the instance writes `capability_grants` rows with `source = 'connector'` that expire after **72 hours** (or three days past the paid period, whichever is sooner). Those rows are the offline cache: a hosted outage shorter than that changes nothing, a revoked or lapsed key freezes the connector capabilities within days, and nothing in the instance phones home for permission to run the bookkeeping. An instance without a key answers `not_configured` and stays unaffected. To run the sync once by hand after pasting the key:
|
|
|
|
```bash
|
|
curl -sf -H "Authorization: Bearer $CRON_SECRET" http://localhost:3000/api/connector/sync/cron
|
|
```
|
|
|
|
The **bank** and **Skatteverket** connector proxies are live (`app.gnubok.se/api/connect/bank/*` and `/api/connect/skv/*`): with `bank_sync` / `skatteverket` in your key's scopes, the instance connects a bank through Arcim's PSD2 credentials and files VAT/AGI + syncs skattekonto through Arcim's registered Skatteverket client, while all tokens (the bank session id, the SKV BankID tokens) stay encrypted in the instance's own database. Company lookup and migration through the connector ship in following releases, and so does the instance-side client wiring that makes the bank/Skatteverket clients call the proxies: until that wiring lands, a key is validated and its grants are written, and the services stay unconfigured on the instance. On the instance, Skatteverket still needs `SKATTEVERKET_ENABLED=true` and `SKATTEVERKET_TOKEN_ENCRYPTION_KEY` (the tokens are stored there, so the encryption key is the operator's).
|
|
|
|
With this release the self-host image also ships the `enable-banking` and `skatteverket` extensions in its preset: without a key (or own credentials) they show the connector upsell instead of being absent, and `GET /api/connector/status` shows the operator how each upstream would be routed. The client wiring that makes a scoped key actually carry bank/Skatteverket traffic still ships in a following release.
|
|
|
|
### Push Notifications
|
|
|
|
```bash
|
|
NEXT_PUBLIC_VAPID_PUBLIC_KEY=...
|
|
VAPID_PRIVATE_KEY=...
|
|
VAPID_SUBJECT=mailto:you@example.com
|
|
```
|
|
|
|
Generate VAPID keys with `npx web-push generate-vapid-keys`. Push notifications require HTTPS.
|
|
|
|
### Error Tracking
|
|
|
|
There is no Sentry integration. `SENTRY_DSN` and `NEXT_PUBLIC_SENTRY_DSN` are not read by the app: setting them changes nothing. Error-level events go to the container logs (structured JSON on stdout/stderr); `lib/observability/sink.ts` is a provider-agnostic seam that stays a no-op until an adapter is registered with `registerObservabilitySink()`, so a self-hosted build carries no third-party error-tracking dependency. If you want alerting, ship the container logs to your log system and alert there. See [docs/security/logging-and-observability.md](security/logging-and-observability.md).
|
|
|
|
## Storage Buckets
|
|
|
|
Migration 024 automatically creates the `documents` storage bucket (private, 50 MB limit, WORM, no update/delete). No other buckets need to be created manually.
|
|
|
|
## Updating
|
|
|
|
Pull the latest image and restart:
|
|
|
|
```bash
|
|
docker compose pull
|
|
docker compose up -d
|
|
```
|
|
|
|
If a new release includes database migrations, apply them before restarting:
|
|
|
|
```bash
|
|
supabase db push
|
|
```
|
|
|
|
Check the [release notes](https://github.com/erp-mafia/accounted/releases) for migration instructions.
|
|
|
|
## Architecture Overview
|
|
|
|
```
|
|
┌─────────────────────┐ ┌──────────────────┐
|
|
│ Docker: app │ │ Docker: cron │
|
|
│ (Next.js) │◄────│ (supercronic) │
|
|
│ Port 3000 │ │ Bearer auth │
|
|
└────────┬────────────┘ └──────────────────┘
|
|
│
|
|
│ HTTPS
|
|
▼
|
|
┌─────────────────────┐
|
|
│ Supabase │
|
|
│ - PostgreSQL + RLS │
|
|
│ - Auth (email+pw) │
|
|
│ - Storage (docs) │
|
|
└─────────────────────┘
|
|
```
|
|
|
|
The Next.js app is stateless: all data lives in Supabase. The Docker entrypoint injects your `NEXT_PUBLIC_*` environment variables into the pre-built JS bundles at container startup, so a single image works with any Supabase project.
|
|
|
|
## Fully Self-Hosted (No Supabase Cloud)
|
|
|
|
The setup above relies on a Supabase project at supabase.com. If you also want to host the database, auth, and storage yourself (to keep all data on-premises, avoid the SaaS dependency, or run air-gapped) you can pair Accounted with [Supabase's official Docker self-hosting stack](https://supabase.com/docs/guides/self-hosting/docker) instead. For the fully Swedish variant of this (Swedish hosting, Swedish object storage with retention locks for the 7-year archive, AI on Swedish GPUs, backup/restore runbook) see [SOVEREIGN.md](SOVEREIGN.md); the mechanics below apply there too.
|
|
|
|
This is a more involved path. You take responsibility for backups, TLS certificates, image upgrades, and Postgres operations. It is intended for operators already running Docker services who are comfortable with PostgreSQL.
|
|
|
|
### Architecture
|
|
|
|
```mermaid
|
|
flowchart LR
|
|
user((User))
|
|
proxy["Reverse proxy + TLS<br/>(Caddy / Traefik / nginx)"]
|
|
user -- HTTPS --> proxy
|
|
|
|
subgraph dnet["shared Docker network"]
|
|
subgraph app_stack["Accounted stack (this repo)"]
|
|
app["app<br/>Next.js · :3000"]
|
|
cron["cron<br/>supercronic"]
|
|
cron -. Bearer CRON_SECRET .-> app
|
|
end
|
|
|
|
subgraph supabase_stack["Supabase self-host stack"]
|
|
kong["kong<br/>API gateway · :8000"]
|
|
studio["studio<br/>dashboard"]
|
|
db[("postgres<br/>+ pg_cron")]
|
|
auth["gotrue"]
|
|
rest["postgrest"]
|
|
rt["realtime"]
|
|
storage["storage-api<br/>(+ imgproxy)"]
|
|
kong --- auth & rest & rt & storage & studio
|
|
auth & rest & rt & storage --- db
|
|
end
|
|
|
|
app -- "@supabase/supabase-js" --> kong
|
|
end
|
|
|
|
proxy -- app.example.com --> app
|
|
proxy -- supabase.example.com --> kong
|
|
proxy -- studio.example.com --> studio
|
|
```
|
|
|
|
### Setup outline
|
|
|
|
1. **Bring up Supabase** following [supabase.com/docs/guides/self-hosting/docker](https://supabase.com/docs/guides/self-hosting/docker). Generate your own `JWT_SECRET`, `ANON_KEY`, and `SERVICE_ROLE_KEY` (Supabase ships `sh utils/generate-keys.sh`). Pick a hostname for the API gateway (e.g. `supabase.example.com`) and point `SUPABASE_PUBLIC_URL` at it and `API_EXTERNAL_URL` at it **including the `/auth/v1` path** (upstream docker 0.7.0, July 2026, changed this). The API gateway depends on the upstream release you check out: `self-hosted/v0.7.x` runs Kong by default and offers Envoy through the `docker-compose.envoy.yml` overlay; `self-hosted/v0.8.0` and later run Envoy by default and keep Kong available through `docker-compose.kong.yml`. The diagram above says `kong`; the role is the same, the container name follows your release and overlays.
|
|
|
|
2. **Apply the Accounted migrations** directly via `psql`: the Supabase CLI (`db push`) assumes a cloud project, so run the SQL files against the self-hosted database container:
|
|
|
|
```bash
|
|
# From the repo root, stream each migration straight into the supabase-db
|
|
# container: glob order is already sorted, and nothing is left behind on the
|
|
# host or in the container.
|
|
for f in supabase/migrations/*.sql; do
|
|
echo "Applying $f..."
|
|
docker exec -i supabase-db psql -v ON_ERROR_STOP=1 -U postgres -d postgres < "$f" || exit 1
|
|
done
|
|
```
|
|
|
|
3. **Configure `.env`** with your self-hosted endpoints (extract the keys from your Supabase `.env`):
|
|
|
|
```bash
|
|
NEXT_PUBLIC_SUPABASE_URL=https://supabase.example.com
|
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=<ANON_KEY from supabase .env>
|
|
SUPABASE_SERVICE_ROLE_KEY=<SERVICE_ROLE_KEY from supabase .env>
|
|
NEXT_PUBLIC_APP_URL=https://app.example.com
|
|
CRON_SECRET=<openssl rand -hex 32>
|
|
NEXT_PUBLIC_SELF_HOSTED=true
|
|
```
|
|
|
|
4. **Allowlist the callback URLs** in GoTrue's redirect list (the Supabase stack's `.env`), then recreate the auth container so it picks up the change:
|
|
|
|
```bash
|
|
ADDITIONAL_REDIRECT_URLS=https://app.example.com/auth/callback,https://app.example.com/api/auth/callback
|
|
```
|
|
```bash
|
|
cd <your-supabase-dir> && docker compose up -d auth
|
|
```
|
|
|
|
5. **Reverse proxy** in front of both hosts. The app container and the Supabase `kong` container must share an external Docker network so the proxy can route to them by name.
|
|
|
|
### Synology DSM and Xpenology notes
|
|
|
|
Run Accounted and Supabase as two separate Container Manager Projects with two
|
|
separate project directories. Accounted owns the Compose files in this
|
|
repository. Supabase owns its database, Auth, Realtime, Storage, and pooler
|
|
configuration. Choose one upstream release tag or full commit and copy the
|
|
complete `docker/` directory from that immutable revision, following the
|
|
[official Supabase Docker guide](https://supabase.com/docs/guides/self-hosting/docker).
|
|
Do not copy individual snippets into Accounted's Compose file or mix files from
|
|
different upstream revisions.
|
|
|
|
For the **Accounted project**, follow the
|
|
[Accounted Container Manager file layout](DOCKER.md#synology-dsm-and-xpenology).
|
|
For the **Supabase project**:
|
|
|
|
1. Copy the entire upstream `supabase/docker/` directory into the project
|
|
directory. Do not upload only its `docker-compose.yml`: it bind-mounts SQL,
|
|
gateway, function, pooler, and Storage files from the accompanying
|
|
`volumes/` tree.
|
|
2. Create the two runtime directories that upstream deliberately excludes from
|
|
Git before the first deployment. File Station is fine, or from the Supabase
|
|
project directory use:
|
|
|
|
```bash
|
|
mkdir -p volumes/db/data volumes/storage
|
|
```
|
|
|
|
Container Manager must be able to write to both directories. Use the
|
|
narrowest NAS ACL that works for the container runtime; do not make the
|
|
whole shared folder world-writable.
|
|
3. Supavisor publishes two host ports. Before starting the project, make sure
|
|
both `POSTGRES_PORT` and `POOLER_PROXY_PORT_TRANSACTION` in the **Supabase**
|
|
`.env` are unused on the NAS. If the defaults conflict, examples are
|
|
`POSTGRES_PORT=5433` for session mode and
|
|
`POOLER_PROXY_PORT_TRANSACTION=6544` for transaction mode. Changing only
|
|
`POSTGRES_PORT` does not resolve a conflict on the transaction port.
|
|
Accounted's `PORT` only changes the web app port and cannot resolve either
|
|
database conflict. Do not expose the Supabase `db` container directly just
|
|
to solve a conflict: the official stack exposes PostgreSQL through
|
|
Supavisor.
|
|
|
|
Supabase's default Supavisor port mappings listen on every host interface.
|
|
Accounted does not need either database port over the network, so on a
|
|
shared NAS bind both mappings to loopback in the version-matched Supabase
|
|
Compose file:
|
|
|
|
```yaml
|
|
services:
|
|
supavisor:
|
|
ports:
|
|
- "127.0.0.1:${POSTGRES_PORT}:5432"
|
|
- "127.0.0.1:${POOLER_PROXY_PORT_TRANSACTION}:6543"
|
|
```
|
|
|
|
If another trusted machine must connect, bind to a specific private NAS
|
|
address and restrict both ports to trusted source addresses in the DSM
|
|
firewall. Never forward either database port to the public internet.
|
|
4. The default Accounted integration uses Supabase's legacy `ANON_KEY` and
|
|
`SERVICE_ROLE_KEY`, so asymmetric keys and `JWT_JWKS` are optional. Leave
|
|
the upstream JWKS lines commented when using legacy-only mode. If you enable
|
|
Supabase's new asymmetric keys, generate them with the upstream
|
|
`utils/add-new-auth-keys.sh` script and follow the
|
|
[official authentication-key guide](https://supabase.com/docs/guides/self-hosting/self-hosted-auth-keys).
|
|
|
|
Some older Compose parsers reject the inline JSON fallback in Supabase's
|
|
optional Realtime setting:
|
|
|
|
```yaml
|
|
API_JWT_JWKS: ${JWT_JWKS:-{"keys":[]}}
|
|
```
|
|
|
|
After `JWT_JWKS` has been generated and saved in the Supabase `.env`, use the
|
|
direct substitution documented by Supabase for limited Compose parsers:
|
|
|
|
```yaml
|
|
# Supabase PostgREST
|
|
PGRST_JWT_SECRET: ${JWT_JWKS}
|
|
|
|
# Supabase Realtime
|
|
API_JWT_JWKS: ${JWT_JWKS}
|
|
|
|
# Supabase Storage
|
|
JWT_JWKS: ${JWT_JWKS}
|
|
```
|
|
|
|
Do not invent an empty or placeholder JWKS for a production deployment. Either
|
|
keep asymmetric authentication disabled or configure the generated value
|
|
consistently for every Supabase service that verifies tokens.
|
|
|
|
Accounted's base Compose file intentionally omits the optional `cpus` and
|
|
`healthcheck.start_interval` settings because older Container Manager Compose
|
|
builds can reject them. Operators who need a CPU cap can set one through DSM's
|
|
resource controls or a local Compose override. Existing deployments that
|
|
relied on the previous two-CPU cap must reapply it before restarting with the
|
|
new base file. Command-line deployments on Docker Compose 2.20.2 or newer and
|
|
Docker Engine 25.0 or newer can use the version-controlled
|
|
`docker-compose.resources.yml` overlay to restore both the cap and faster
|
|
startup health checks; older NAS container stacks should keep using the
|
|
portable base file alone.
|
|
|
|
### What you give up vs. cloud Supabase
|
|
|
|
- **Backups** are entirely your responsibility. The repo ships `scripts/self-host/backup.sh` / `restore.sh` (`pg_dump` custom format with ACLs kept, ACL manifest, storage tar, optional db-config volume, to any S3-compatible bucket with Object Lock; see [SOVEREIGN.md, section 5](SOVEREIGN.md#5-backup-and-restore-ship-it-do-not-improvise-it)); scheduling and monitoring them is still on you. As a portable, vendor-neutral *logical* backup on top of the raw dump, you can also export each fiscal period as a standard **SIE4** file via the API and archive it: any Swedish bookkeeping system can re-import it:
|
|
|
|
```bash
|
|
curl -fsS -H "Authorization: Bearer <reports:read API key>" \
|
|
"$NEXT_PUBLIC_APP_URL/api/v1/companies/<companyId>/reports/sie-export?period_id=<periodId>" \
|
|
-o "export_<periodId>.se"
|
|
```
|
|
- **Storage**: the included `storage-api` defaults to the local-filesystem backend. For production durability, use the `docker-compose.s3.yml` overlay and point it at S3 / MinIO.
|
|
- **SMTP**: no built-in mailer for auth mail. Either set `ENABLE_EMAIL_AUTOCONFIRM=true` for dev/staging, or wire `SMTP_*` env vars in the Supabase stack to a provider (Resend, Postmark, etc.), the self-hosted equivalent of the **Authentication > SMTP Settings** step in [Configure Authentication](#2-configure-supabase-auth) and [Troubleshooting](#troubleshooting). If you also disable public signup in GoTrue, set `AUTH_SIGNUPS_DISABLED=true` on the app so invites provision accounts through the admin API (see [Email](#email-invoice-sending-invitations-and-reminders)). Inviting users never depends on this: the accept link is always returned in-band to the inviter.
|
|
- **Upgrades**: you sync the `supabase/postgres` image yourself; your data lives in the DB volume, so a Postgres image bump needs no migration re-run. When you pull a newer Accounted release, apply only the **new** migration files added since your last deploy (the SQL is not idempotent, so re-running already-applied migrations will error). Track which migrations you've applied, e.g. with a checksum/version table.
|
|
|
|
### Notes
|
|
|
|
- **`pg_cron`** is included in the `supabase/postgres` image, so the `pg_cron` migration succeeds (unlike on the Supabase free tier, see the standard self-hosting flow above).
|
|
- **MFA**: as on the standard path, `NEXT_PUBLIC_SELF_HOSTED=true` disables enforcement; users may still enable TOTP voluntarily.
|
|
|
|
## Troubleshooting
|
|
|
|
**Health check fails with "unhealthy":**
|
|
Migrations have not been applied, or the Supabase credentials are wrong. Check that `NEXT_PUBLIC_SUPABASE_URL` and `SUPABASE_SERVICE_ROLE_KEY` are correct and that migrations have been pushed.
|
|
|
|
**Confirmation email not arriving:**
|
|
Check the Supabase dashboard under **Authentication > Users** to verify the signup attempt was received. On the free tier, Supabase rate-limits emails to 4/hour. Configure custom SMTP under **Authentication > SMTP Settings** for production use.
|
|
|
|
**Auth callback redirects to error:**
|
|
Ensure `https://your-domain.com/auth/callback` is in the Supabase **Redirect URLs** allowlist and that **Site URL** matches your `NEXT_PUBLIC_APP_URL`.
|
|
|
|
**`pg_cron` migration fails:**
|
|
`pg_cron` requires a paid Supabase plan. On the free tier, you can safely comment out migration 048 or let it fail: the overdue supplier invoice check is non-critical and can be triggered manually.
|
|
|
|
**Container restarts in a loop:**
|
|
Check logs with `docker compose logs app`. The app requires all five core env vars (`NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`, `NEXT_PUBLIC_APP_URL`, `CRON_SECRET`) and will crash on startup if any are missing.
|