docs/CONFIG.md
Rama main · réplica de lectura del repositorio original.
# Configuration System
## Quick Start
1. **Copy the example configuration:**
```bash
cp config/config.example.json config/config.json
```
2. **Edit with your settings:**
```bash
nano config/config.json # or your preferred editor
```
3. **Validate configuration:**
```bash
npm run validate-config
```
4. **Run the service:**
```bash
npm start
```
---
## Configuration File Structure
### Complete Example
```json
{
"$schema": "../config/config.schema.json",
"servers": [
{
"name": "Production",
"url": "https://budget.example.com",
"password": "SecurePassword123!",
"syncId": "abc-123-def-456",
"dataDir": "/var/lib/actual-sync/production",
"encryptionPassword": "MyBudgetEncryptionKey"
},
{
"name": "Testing",
"url": "http://localhost:5006",
"password": "TestPassword",
"syncId": "test-sync-id",
"dataDir": "/tmp/actual-sync-test"
}
],
"sync": {
"maxRetries": 5,
"baseRetryDelayMs": 3000,
"schedule": "0 2 * * *"
},
"logging": {
"level": "info"
}
}
```
---
## Configuration Reference
### timezone (optional)
IANA timezone name, for example `"America/New_York"` or `"UTC"`. Cron schedules run in it, and the startup log, the startup notification's "next sync" and the Telegram replies show times in it. Default: the process timezone (`TZ`, else the system zone), which is what schedules used before this key existed. If `TZ` is blank (for example an empty Unraid field) or not a valid IANA name (a typo such as `Europe/Madird`), the service uses `UTC` and logs one startup WARN naming the bad value. POSIX forms such as `TZ=:/etc/localtime` or `TZ=GMT+2` count as invalid too, so schedules run in UTC even though the clock itself is local: use an IANA name in `TZ` or set the `timezone` key. An unknown name fails config loading with a message naming `timezone`, even with `CONFIG_STRICT=false`.
### servers (required)
Array of Actual Budget server configurations. At least one server is required.
**Properties:**
- **name** (required, string): Human-readable name for the server
- Must be unique across all servers
- Used in logs for identification
- Example: `"Production"`, `"Personal"`, `"Business"`
- **url** (required, string): Actual Budget server URL
- Must start with `http://` or `https://`
- HTTPS recommended for production
- Example: `"https://budget.example.com"`
- **password** (required, string): Server password
- Minimum 8 characters recommended
- Avoid default passwords like "hunter2"
- Use a password manager for secure passwords
- **syncId** (required, string): Budget sync ID
- Found in Actual Budget → Advanced Settings
- Unique identifier for your budget file
- Example: `"abc-123-def-456-789"`
- **dataDir** (required, string): Local directory for budget cache
- Must be writable by the service user
- Each server should have a unique directory
- Example: `"/var/lib/actual-sync/main"`
- **encryptionPassword** (optional, string): Budget file encryption password
- Only required if your budget uses end-to-end encryption (E2EE)
- This is **separate** from the server password
- Found in Actual Budget → Settings → Encryption
- Leave empty/omitted for unencrypted budgets
- Example: `"MySecretBudgetPassword123!"`
- ⚠️ **Security Note**: Store securely, never commit to version control
- **sync** (optional, object): Per-server sync configuration overrides
- Overrides global `sync` settings for this specific server
- All properties are optional
- See [Per-Server Sync Configuration](#per-server-sync-configuration) below
- **scheduleAlerts** (optional, object): Missing-payment alert rules for this server
- Compares this server's own Actual schedules against posted transactions after each sync
- No cost when omitted: the schedule lookup is skipped entirely for a server with no block
- See **[docs/SCHEDULE_ALERTS.md](SCHEDULE_ALERTS.md)** for the full reference
---
### sync (optional)
Global synchronization behavior configuration. These settings apply to all servers unless overridden at the server level.
**Properties:**
- **maxRetries** (optional, integer, default: 5)
- Maximum number of retry attempts for failed operations
- Range: 0-10
- Higher values = more resilience, longer failure detection
- Can be overridden per server
- Example: `5`
- **autoRetry** (optional, object; also per server)
- After a failed *scheduled* sync (every account failed, or the sync itself errored), the service schedules another run: `enabled` (default `true`), `maxAttempts` (default `1`), `delayMinutes` (default `31`).
- A failed manual sync (dashboard, Telegram, `--force-run`) never schedules a retry, and a manual sync cancels a pending one. A partial sync (some accounts failed) is not retried. Pending retries are cancelled on shutdown.
- **baseRetryDelayMs** (optional, integer, default: 3000)
- Base delay in milliseconds for exponential backoff
- Range: 1000-10000ms (1-10 seconds)
- Delay doubles with each retry attempt
- Can be overridden per server
- Example: `3000` (3 seconds)
- **phaseTimeoutSeconds** (optional, integer, default: 300)
- Seconds each Actual API call (init, download, load, query, sync, shutdown) may take
- Range: 30-3600
- A call that runs longer fails the sync with the phase named. The sync holds the queue up to one more timeout for the stuck call to finish. If it is still running, the next sync waits up to its own timeout, then fails with `ACTUAL_SESSION_BUSY` and stops waiting for that call, so later syncs proceed. When the stuck call does finish in time, a budget it left open is closed by Actual's own close-budget step when the next sync downloads or loads a budget
- Limit: the Actual API cannot cancel a call. One that is still running after both waits is no longer tracked, so if it lands later it can close or disturb the following sync, which then fails and the one after recovers
- A bank sync that times out (60 seconds) is given one more timeout to finish before the next account starts; if it is still running, the remaining accounts are skipped and reported as failed, and the final file sync and schedule alerts are skipped (Actual's shutdown syncs before closing). A bank sync that finishes during that extra wait counts as succeeded
- A late call that finally finishes is logged with its phase and outcome
- Raise it for very large budgets on slow links; bank sync keeps its own 60 second limit per account
- Can be overridden per server
- Example: `300` (5 minutes)
- **schedule** (optional, string, default: "03 03 */2 * *")
- Cron expression for sync schedule
- Format: `minute hour day month dayOfWeek` (5 fields), or 6 fields with a leading `seconds` field
- Can be overridden per server
- See [Cron Examples](#cron-examples) below
---
### logging (optional)
Logging configuration. See **[docs/LOGGING.md](LOGGING.md)** for the full reference.
**Properties:**
- **level** (string, default: `"INFO"`) — verbosity: `"DEBUG"`, `"INFO"`, `"WARN"`, `"ERROR"`.
- **format** (string, default: `"pretty"`) — console format: `"pretty"` or `"json"`.
- **fileFormat** (string, default: `"json"`) — log-file format; single-line JSON is recommended for shipping. Independent of `format`.
- **actualApiVerbose** (boolean, default `false`) - UNSAFE. The `@actual-app/api` library is verbose by default and prints transactions (payees, amounts, notes) to the console. Actual-sync passes `verbose: false` to `api.init`. Set `true` only to debug the library, and never in shared or collected logs. Global only.
- **includeAccountNames** (boolean, default `false`): when `false`, logs carry each account's id and omit its name (a joint account name can identify a person). Set `true` to log names too. Server names you choose are always logged, and notifications keep account names. Global only.
- **redact** (array) — extra metadata key names to mask, on top of the always-on defaults. The default indicators are matched as case-insensitive substrings: `password`, `passwd`, `token`, `secret`, `apikey`, `api_key`, `authorization`, `credential`, `chatid` — so keys like `encryptionPassword` and `botToken` are covered too. Redaction applies across console/file/syslog/dashboard.
- **logDir** (string|null, default: `"./logs"`) — log directory; `null` disables file logging. The directory is created with mode 0700 and log files with 0600; existing ones are tightened at startup (POSIX only; a failed chmod logs one warning and never stops the service).
- **rotation** (object) — `enabled`, `maxSize` (`"10M"`), `interval` (`"1d"`, daily), `maxFiles` (retention), `compress` (`"gzip"`).
- **syslog** (object): optional RFC 5424 syslog forwarding (`protocol`: `udp` is plaintext and not an audit log, `tcp`, or `tls` with certificate verification and optional `ca` path; see LOGGING.md).
- **performance** (object) — operation timing thresholds.
> **Scope:** `rotation`, `syslog`, `performance`, `fileFormat`, and the extra `redact` keys apply to the main application logger (log files are a single shared stream). Per-component loggers inherit `level`/`format`/`logDir`; the always-on secret redaction defaults still apply everywhere.
### notifications (optional)
Multi-channel alerts on sync results. See **[docs/NOTIFICATIONS.md](NOTIFICATIONS.md)** for the full reference and examples.
**Channels:** `email` (SMTP), `telegram` (interactive bot), `webhooks.slack`, `webhooks.discord`, `webhooks.generic` (POST a documented JSON payload to any URL: ntfy/Gotify/Home Assistant/n8n/custom), and `ntfy` (push to an ntfy topic).
**Cross-cutting controls:**
- **`notifyOnSuccess`** (`always` / `errors_only` / `activity_only` / `never`) — which sync results reach a channel. Set it on `notifications` as the global default and override it per channel, or per entry inside `webhooks.slack[]` / `webhooks.discord[]` / `webhooks.generic[]`. Defaults to `always`. `activity_only` adds a successful sync only when it imported or updated transactions. `never` turns a channel off entirely (failures included); dashboard test notifications always send.
- **`thresholds`** (when to alert) and **`rateLimit`** (anti-spam): both apply to **`failure` results only**. They do not affect success or partial results; use `notifyOnSuccess` for those.
### scheduleAlerts (optional, per server)
Compares a server's own Actual schedules against posted transactions and notifies when an expected
recurring payment is late, missing, wrong-amount, or cannot be checked (stale bank connection). Set
under each server that needs it, in `servers[].scheduleAlerts`. See
**[docs/SCHEDULE_ALERTS.md](SCHEDULE_ALERTS.md)** for the full field reference, event types, and
message-template variables.
```json
"scheduleAlerts": {
"alerts": [
{ "id": "rent", "schedule": "Rent - Apartment", "graceDays": 6, "earlyDays": 2 }
]
}
```
`alerts` may be empty. An empty block only switches the feature on for the server, and the rules are then added from the dashboard (see [SCHEDULE_ALERTS.md](SCHEDULE_ALERTS.md#rules-from-the-dashboard)):
```json
"scheduleAlerts": { "alerts": [] }
```
- **`enabled`** (default `true`): `false` switches the alert off for good. It is still evaluated and shown in the Payments tab with a "Disabled in config" badge, but sends nothing, and the dashboard cannot switch it on or mute it. The dashboard's own on/off switch and mute (see [DASHBOARD.md](DASHBOARD.md#turning-alerts-off-and-muting)) are stored separately, never written to this file, and lose to this setting.
- **`graceDays`** (default `3`): days after the occurrence date before it counts as overdue.
- **`earlyDays`** (default `2`, 0-31): days before the occurrence date a matching transaction still counts as on time. `graceDays + earlyDays` must stay below the schedule's shortest interval between occurrences, otherwise the rule is skipped with a `cannotCheck` (reason `interval-violation`) and a warning.
- **Timezone**: occurrence dates, deadlines and "today" use the top-level [`timezone`](#timezone-optional) (default: `TZ`, then the system zone, then `UTC`).
- An account that was never bank-synced (`last_sync` is null) always gives `cannotCheck`, never `missing`.
### healthCheck (optional)
HTTP server for health probes, Prometheus metrics, and the web dashboard.
**Properties:**
- **port** (integer, default: `3000`, range 1024-65535) — port for `/health`, `/ready`, `/metrics`, `/metrics/prometheus`, and `/dashboard`.
- **host** (string, default: `"0.0.0.0"`) — bind address. **Keep `0.0.0.0` in containers.** Setting it to the host's LAN IP is not bindable in bridge mode (`EADDRNOTAVAIL`) and makes the dashboard unreachable; startup warns if it is set to anything other than `0.0.0.0`/`::`/`::1`/`127.0.0.1`/`localhost`.
- **trustProxy** (default: `false`): set this only when a reverse proxy you control (nginx, Traefik, Caddy) is in front. It makes Express read the client address from `X-Forwarded-For`, so the 60 requests/minute rate limit and the dashboard login throttle (10 failures per 15 minutes) apply per client, and auth-failure logs name the real client, not the proxy. Without it, 10 bad logins through the proxy lock out every user behind it. Startup cannot see this, so the first request that carries `X-Forwarded-For` while this is unset logs a one-time warning. Accepted values:
- `false` (default): direct exposure. Behind a proxy, every client shares one rate-limit bucket.
- a hop count, an integer `0` to `10`: `1` means one proxy in front (the usual choice), `0` trusts nothing. Non-integers and objects are rejected at config load even with `CONFIG_STRICT=false`.
- an IP/CIDR string or array, for example `"172.20.0.10"` (the proxy container's fixed IP) or `["loopback", "10.0.0.5"]`: trust only those proxy addresses. The Express keyword `loopback` also works. Avoid `uniquelocal` and wide ranges such as `172.16.0.0/12`: they cover the Docker bridge networks, so any container on the host could forge `X-Forwarded-For`.
- `true`: trusts every hop. **Do not use it if the port is reachable without the proxy**: any client could then send its own `X-Forwarded-For`, pick any IP and bypass rate limiting. Startup warns when it is set.
An invalid IP/CIDR fails at startup with a message naming `healthCheck.trustProxy`.
- **hsts** (boolean, default: `false`): send the `Strict-Transport-Security` header (`max-age=15552000`, 180 days, without `includeSubDomains`, so other hosts under the same domain are not affected). Leave it off unless browsers reach the dashboard over HTTPS. Sent over plain HTTP it does nothing, and the service is plain HTTP in most deployments; TLS and HSTS are normally the reverse proxy's job (set it there, or enable this when the service is the TLS endpoint behind a proxy that does not add it). Once a browser has seen it, that host refuses plain HTTP for 180 days, so enable it only when HTTPS is permanent.
- **auth.type** (string, default: `"none"`): credential for `GET /metrics` and `GET /metrics/prometheus` only. `"none"`, `"basic"` (requires **auth.username** and **auth.password**) or `"token"` (requires **auth.token**, sent as `Authorization: Bearer <token>`; the recommended choice, use a long random value). Independent of `dashboard.auth`, so rotating one never breaks the other, and `dashboard.enabled` has no effect on it. `/health` and `/ready` are never authenticated: they are liveness and readiness probes. With `basic` or `token` the credential must be set, or startup fails naming the missing key. Once set, add the same credential to the Prometheus scrape config ([PROMETHEUS.md](PROMETHEUS.md#2-configure-prometheus)). Off by default so an upgrade changes nothing; see [HEALTH_CHECK.md](HEALTH_CHECK.md#which-endpoints-are-authenticated).
- **dashboard.enabled** (boolean, default: `true`) — serve the web dashboard.
- **dashboard.auth.type** (string, default: `"none"`) — `"none"`, `"basic"`, or `"token"`.
- `"basic"` requires **username** and **password**.
- `"token"` requires **token** (sent as `Authorization: Bearer <token>`).
- A missing or blank credential locks the dashboard out (every request gets 500 `Invalid authentication configuration`), so set the credentials when you enable auth.
- **dashboard.allowedOrigins** (array of `scheme://host[:port]`, default: `[]`) - extra browser origins allowed to open the `/ws/logs` live log stream and to send state-changing requests (the four dashboard POSTs: `sync`, `dismiss-error`, `reset-history`, `test-notification`). Same-origin is always allowed, and a request with no `Origin` header (a script, not a browser) is not origin-checked. Any other browser origin is refused with 403 at every auth type (`origin_rejected` on a POST); this is the guard against Cross-Site WebSocket Hijacking and cross-site request forgery. Listing an origin lets that site act as the dashboard, so list only origins you control. Same-origin means `Origin` equals the `Host` header the service receives, port included, so a proxy that rewrites `Host` causes a 403 (see the troubleshooting in [DASHBOARD.md](DASHBOARD.md)). Add an entry only if the dashboard page is served under a different origin than the host the browser connects to, for example `["https://budget.example.com"]`.
- **dashboard.allowConfigWrites** (boolean, default: `false`) - opt in to dashboard routes that change configuration. The dashboard is read-only for configuration until this is `true`. Even then, writes are refused while `dashboard.auth.type` is `none`. Every configuration change is recorded in the "Configuration changes" list (Settings tab, `GET /api/dashboard/config-audit`) with secrets redacted. The service logs an ERROR at startup when writes are enabled with auth `none`, and a WARN naming `allowedOrigins` when that list is non-empty.
```json
"healthCheck": {
"port": 3000,
"host": "0.0.0.0",
"auth": { "type": "token", "token": "a-long-random-value" },
"dashboard": { "enabled": true, "auth": { "type": "basic", "username": "admin", "password": "change-me" } }
}
```
---
## Single-server configuration via environment variables
For the common case of **one budget**, you can skip `config.json` entirely and configure the single server with environment variables. This is the simplest path for Docker/Unraid: set the variables and start, no config file or config folder to prepare.
| Variable | Required | Description |
|----------|----------|-------------|
| `ACTUAL_SYNC_SERVER_URL` | yes | Actual Budget server URL (e.g. `https://actual.example.com`) |
| `ACTUAL_SYNC_SERVER_PASSWORD` | yes | Server password |
| `ACTUAL_SYNC_SERVER_SYNC_ID` | yes | Budget Sync ID (Actual Budget → open budget → Settings → Advanced → "Sync ID") |
| `ACTUAL_SYNC_SERVER_NAME` | no | Display name (default `Default`) |
| `ACTUAL_SYNC_SERVER_ENCRYPTION_PASSWORD` | no | End-to-end encryption password (only for E2EE budgets) |
| `ACTUAL_SYNC_SERVER_DATA_DIR` | no | Data directory (default `data/<name-slug>`) |
| `ACTUAL_SYNC_SERVER_SCHEDULE` | no | Per-server cron schedule (defaults to the global schedule) |
The three core variables (`URL`, `PASSWORD`, `SYNC_ID`) must all be set to activate this path.
**Precedence / merge rules:**
- **No `config.json`, env vars set** → runs that single server (no file or first-run seeding needed).
- **`config.json` present, env vars set for a *different* budget** → both are synced (the env server is merged into the list; if its name collides with a file server it is auto-renamed, e.g. `Default (2)`).
- **Both set for the *same* budget** (same `url` + `syncId`) → the `config.json` entry wins and the env vars are ignored, with a warning. A budget is never synced twice.
For **multiple** budgets, use `config.json` (the env-var path is single-server only). The env-built server is schema- and logic-validated exactly like a file-defined one.
### Other environment variables
These are global runtime flags, not per-server config:
| Variable | Description |
|----------|-------------|
| `CONFIG_STRICT` | Migration escape hatch. Default (unset) = strict: a schema-invalid config stops startup (#121). Set to `false` (or `0`/`no`/`off`) to **temporarily** downgrade schema validation errors to warnings so the service starts while you fix the config. It only affects schema validation, not the business-logic checks, and should be removed once the config is valid. |
| `PUID` / `PGID` | User/group IDs to run as (Docker/Unraid; default `1001:1001`, Unraid `99:100`). |
| `TZ` | Container timezone for scheduling and log timestamps. Used when the `timezone` config key is not set. A blank or invalid value falls back to `UTC` with a startup WARN. |
| `SHUTDOWN_WAIT_MS` | How long a SIGTERM waits for a running sync before closing the session (default `8000`). Keep it about 2 s below the container's stop timeout. See [DOCKER_DEPLOYMENT.md](DOCKER_DEPLOYMENT.md#stopping-the-container). |
---
## Cron Examples
| Schedule | Cron Expression | Description |
|----------|----------------|-------------|
| Every day at 2 AM | `0 2 * * *` | Once daily |
| Every 6 hours | `0 */6 * * *` | Four times daily |
| Every hour | `0 * * * *` | 24 times daily |
| Every other day at 3:03 AM | `03 03 */2 * *` | Default schedule |
| Every Monday at 9 AM | `0 9 * * 1` | Weekly |
| Every 30 minutes | `*/30 * * * *` | 48 times daily |
| Twice daily (6 AM and 6 PM) | `0 6,18 * * *` | Morning and evening |
**Cron Format:** `minute (0-59) hour (0-23) day (1-31) month (1-12) dayOfWeek (0-6, Sunday=0)`. A 6-field form with a leading `seconds (0-59)` field is also accepted.
---
## Per-Server Sync Configuration
You can override global sync settings for specific servers by adding a `sync` object to the server configuration. This allows different synchronization strategies for different servers.
### Example Configuration
```json
{
"servers": [
{
"name": "Critical-Production",
"url": "https://budget.example.com",
"password": "password",
"syncId": "prod-sync-id",
"dataDir": "/var/lib/actual-sync/prod",
"sync": {
"schedule": "*/30 * * * *",
"maxRetries": 8
}
},
{
"name": "Testing",
"url": "http://localhost:5006",
"password": "password",
"syncId": "test-sync-id",
"dataDir": "/tmp/actual-sync-test",
"sync": {
"maxRetries": 2,
"baseRetryDelayMs": 1000
}
},
{
"name": "Personal",
"url": "https://personal.example.com",
"password": "password",
"syncId": "personal-sync-id",
"dataDir": "/var/lib/actual-sync/personal"
}
],
"sync": {
"maxRetries": 5,
"baseRetryDelayMs": 3000,
"schedule": "0 3 * * *"
}
}
```
In this example:
- **Critical-Production**: Syncs every 30 minutes with 8 retries (more aggressive)
- **Testing**: Uses global schedule but fewer retries (2) and faster retry delay (1s)
- **Personal**: Uses all global defaults (daily at 3 AM, 5 retries, 3s delay)
### Use Cases
**Frequent Syncing for Critical Servers:**
```json
{
"name": "Production",
"sync": {
"schedule": "*/15 * * * *"
}
}
```
**Fewer Retries for Unreliable Connections:**
```json
{
"name": "Flaky-Server",
"sync": {
"maxRetries": 2,
"baseRetryDelayMs": 1000
}
}
```
**Off-Hours Syncing for Low-Priority Servers:**
```json
{
"name": "Archive",
"sync": {
"schedule": "0 4 * * 0"
}
}
```
**Aggressive Retry for Important Servers:**
```json
{
"name": "Business-Critical",
"sync": {
"maxRetries": 10,
"baseRetryDelayMs": 5000
}
}
```
### Configuration Merging
The system merges per-server and global settings using these rules:
1. If a property is specified in `server.sync`, it takes precedence
2. If a property is not specified in `server.sync`, the global `sync` value is used
3. You can override any combination of `schedule`, `maxRetries`, `baseRetryDelayMs`, `phaseTimeoutSeconds` and `autoRetry` (one, several, or all five)
4. Zero values (e.g., `maxRetries: 0`) are treated as explicit values, not fallbacks
**Example:**
```json
{
"servers": [
{
"name": "Server1",
"sync": {
"schedule": "0 2 * * *"
}
}
],
"sync": {
"maxRetries": 5,
"baseRetryDelayMs": 3000,
"schedule": "0 3 * * *"
}
}
```
Server1 will use:
- `maxRetries`: 5 (from global)
- `baseRetryDelayMs`: 3000 (from global)
- `schedule`: "0 2 * * *" (from server override)
### Scheduling Behavior
When servers have different schedules, the service creates separate cron jobs for each unique schedule. Servers with the same schedule are grouped together and synced in sequence.
**Example:**
- Servers with schedule `"0 2 * * *"` → one cron job at 2 AM
- Servers with schedule `"0 4 * * *"` → separate cron job at 4 AM
- Servers with schedule `"*/30 * * * *"` → separate cron job every 30 minutes
This ensures efficient scheduling with minimal overhead.
---
## Validation
The configuration system performs multiple validation checks:
### Syntax Validation
- Valid JSON format
- No trailing commas
- Proper quotation marks
### Schema Validation (hard-fail)
Checks the config against `config.schema.json` (required fields, data types, URL
patterns, enums, ranges). **Since #121 these rules hard-fail at startup:** an
invalid `config.json` stops the service from starting with a single aggregated,
actionable message (in a container that surfaces as the container failing to
start / `CrashLoopBackOff`). Run **`npm run validate-config` before starting** to
catch problems without a restart loop.
**Escape hatch:** set `CONFIG_STRICT=false` to temporarily downgrade these
hard-fails to warnings during a migration. Remove it once the config is fixed;
it exists only to unblock an upgrade, not as a permanent mode.
**Unknown keys stay advisory (warn, never fatal).** Every config block flags keys
it does not recognise, so a typo (`maxRetires` instead of `maxRetries`,
`notifcations` instead of `notifications`) or a leftover/legacy key produces a
warning — but unknown keys are ignored at runtime and do **not** stop startup
(deliberate: a stray `"_comment"`/`"//"` key, a legacy key, or a forward/backward
-compatible field must not crash-loop a deploy). The one block that accepts
arbitrary keys by design is `notifications.webhooks.generic[*].headers` (an open
map of HTTP header names). Run `npm run validate-config` to see every warning and
error together.
### Business Logic Validation (hard-fail)
These stop startup today:
- At least one server configured
- Required server fields present (`name`, `url`, `password`, `syncId`, `dataDir`)
- Unique server names
- Retry settings in range (`maxRetries` 0-10, `baseRetryDelayMs` >= 1000)
- A 5- or 6-field cron schedule (6 = leading seconds)
It also **warns** (without stopping) about duplicate budgets (same `url` +
`syncId`), shared `dataDir`s, and a non-bindable `healthCheck.host`.
### Security Warnings
The system warns about:
- HTTP connections (non-localhost)
- Weak passwords (<8 characters)
- Default/example passwords
- Common security issues
---
## Security Best Practices
### 1. Protect config.json
```bash
chmod 600 config.json # Owner read/write only
```
### 2. Never Commit config.json
The file is in `.gitignore` - verify:
```bash
grep config.json .gitignore
```
### 3. Use Strong Passwords
- Minimum 16 characters
- Mix of letters, numbers, symbols
- Unique per server
- Use a password manager
### 4. Use HTTPS in Production
```json
{
"url": "https://budget.example.com" // ✅ HTTPS
}
```
Not:
```json
{
"url": "http://budget.example.com" // ❌ HTTP
}
```
---
## Troubleshooting
### Configuration file not found
**Error:**
```
❌ Failed to load configuration:
Configuration file not found: ./config.json
```
**Solution:**
```bash
cp config/config.example.json config/config.json
```
---
### Invalid JSON
**Error:**
```
Invalid JSON in configuration file: Unexpected token...
```
**Solutions:**
- Check for missing/trailing commas
- Validate at https://jsonlint.com/
- Use `node -e "JSON.parse(require('fs').readFileSync('config.json'))"`
---
### Configuration is invalid — does not match the schema (startup error)
**Error (startup is blocked, #121):**
```
Configuration is invalid — it does not match the schema:
- /servers/0/password: must be string
Fix config.json and restart. Run `npm run validate-config` to check it before
starting. Set CONFIG_STRICT=false to temporarily downgrade these to warnings
during migration.
```
Since #121 a schema rule violation **stops startup** (in a container, it fails to
start / `CrashLoopBackOff`). Fix the field(s) listed, or set `CONFIG_STRICT=false`
to start anyway while you migrate. Common issues:
- Missing quotes around strings
- Wrong data type
- Missing required fields
- An out-of-range or wrongly-formatted value
A **typo'd / unknown property** is the exception: it only **warns** (`unknown
property ...`) and does not block startup, since unknown keys are ignored at
runtime.
---
### Duplicate Server Names
**Error:**
```
Duplicate server names found: Main
Each server must have a unique name.
```
**Solution:**
Rename one of the servers:
```json
{
"servers": [
{ "name": "Main-Production", ... },
{ "name": "Main-Testing", ... }
]
}
```
---
## Migration from Environment Variables
If upgrading from the old `.env` approach, see [MIGRATION.md](./MIGRATION.md) for detailed instructions.
**Quick Migration:**
1. Create config.json from example
2. Copy values from .env to config.json
3. Test configuration
4. Remove old environment variables from .env
---
## Advanced Usage
### Multiple Environments
Create different config files for different environments:
```bash
config.production.json
config.staging.json
config.development.json
```
Specify config file:
```javascript
const loader = new ConfigLoader('./config.production.json');
```
### Programmatic Access
```javascript
const ConfigLoader = require('./src/lib/configLoader');
const loader = new ConfigLoader();
const config = loader.load();
// Get all servers
const servers = loader.getServers();
// Get specific server
const prodServer = loader.getServer('Production');
// Access configuration
console.log('Schedule:', config.sync.schedule);
console.log('Max retries:', config.sync.maxRetries);
```
---
## Encrypted Budgets (E2EE)
Actual Budget supports end-to-end encryption (E2EE) for budget files. If your budget is encrypted, you need to provide the encryption password.
### How to Configure
1. **Check if your budget is encrypted:**
- Open Actual Budget web interface
- Go to Settings → Encryption
- If encryption is enabled, you'll see "Budget is encrypted"
2. **Add encryption password to config:**
```json
{
"servers": [
{
"name": "Encrypted Budget",
"url": "https://actual.example.com",
"password": "server-password-here",
"syncId": "your-sync-id",
"dataDir": "/app/data/encrypted",
"encryptionPassword": "your-budget-encryption-password"
}
]
}
```
### Important Notes
- **Server Password ≠ Encryption Password**:
- `password`: Authenticates to the Actual Budget server
- `encryptionPassword`: Decrypts the budget file itself
- **Security Best Practices**:
- Never commit encryption passwords to version control
- Use environment variables or secrets management
- Keep encryption passwords separate from server passwords
- Regularly rotate encryption passwords
- **Troubleshooting**:
- If sync fails with "decryption error", verify encryption password
- Unencrypted budgets don't need `encryptionPassword` field
- Empty string is treated as no encryption (budget must be unencrypted)
### Example with Docker
`config.json` does **not** expand `${VAR}` placeholders — values are used
verbatim. There are two supported ways to keep the encryption password out of a
committed file:
**Single budget — configure the whole server from the environment** (no
`config.json` needed; see "Single-server configuration via environment
variables" above):
```yaml
services:
actual-sync:
image: actual-sync:latest
environment:
- ACTUAL_SYNC_SERVER_URL=https://actual.example.com
- ACTUAL_SYNC_SERVER_PASSWORD=...
- ACTUAL_SYNC_SERVER_SYNC_ID=...
- ACTUAL_SYNC_SERVER_ENCRYPTION_PASSWORD=...
```
**Multiple budgets — keep `config.json` out of version control** and put the
literal `encryptionPassword` in it (the file already holds the server password),
then protect it with file permissions (see Security Best Practices):
```json
{
"servers": [{
"encryptionPassword": "your-actual-e2ee-password"
}]
}
```
---
## Schema Reference
The configuration is validated against `config.schema.json` using JSON Schema Draft 7.
View the full schema:
```bash
cat config.schema.json
```
Validate manually:
```bash
npm install -g ajv-cli
ajv validate -s config.schema.json -d config.json
```
---
## Support
- **Documentation:** See [docs/README.md](./README.md)
- **Migration Guide:** [MIGRATION.md](./MIGRATION.md)
- **Architecture:** [docs/ARCHITECTURE.md](./ARCHITECTURE.md)
- **Security:** [docs/SECURITY_AND_PRIVACY.md](./SECURITY_AND_PRIVACY.md)
---
**Last Updated:** December 4, 2025