# Docker Deployment Guide This guide covers deploying Actual-sync using Docker and Docker Compose with real-world configuration examples. ## Table of Contents - [Quick Start](#quick-start) - [Docker Compose Setup](#docker-compose-setup) - [Configuration](#configuration) - [Common Issues](#common-issues) - [Production Tips](#production-tips) - [Verifying the image](#verifying-the-image) ## Quick Start ### Using Docker Run ```bash docker run -d \ --name actual-sync \ --user 1026:100 \ --cap-drop ALL --security-opt no-new-privileges:true \ -v ./config.json:/app/config/config.json:ro \ -v ./data:/app/data \ -v ./logs:/app/logs \ -e TZ=Europe/Madrid \ --restart unless-stopped \ ghcr.io/agigante80/actual-sync:latest ``` ### Using Docker Compose See [Docker Compose Setup](#docker-compose-setup) below for a complete example. ## Docker Compose Setup ### Complete Example with Multiple Actual Budget Servers ```yaml version: '3.8' networks: actual-net: name: actual-net services: # Actual Budget Server 1 actual-main: image: actualbudget/actual-server:latest container_name: finance-actual-budget-main ports: - "5006:5006" environment: - TZ=Europe/Madrid volumes: - ./data-main:/data restart: unless-stopped networks: - actual-net # Actual Budget Server 2 actual-secondary: image: actualbudget/actual-server:latest container_name: finance-actual-budget-secondary ports: - "5008:5006" environment: - TZ=Europe/Madrid volumes: - ./data-secondary:/data restart: unless-stopped networks: - actual-net # Actual-sync Service actual-sync: image: ghcr.io/agigante80/actual-sync:latest container_name: actual-sync-service user: "1026:100" # Important: Match your host user UID:GID cap_drop: - ALL # with `user` set, the entrypoint needs no capability security_opt: - no-new-privileges:true volumes: - ./actual-sync/config.json:/app/config/config.json:ro - ./actual-sync/data:/app/data - ./actual-sync/logs:/app/logs environment: - TZ=Europe/Madrid depends_on: - actual-main - actual-secondary restart: unless-stopped networks: - actual-net ``` ### Directory Structure Create the following directory structure before running Docker Compose: ```bash mkdir -p actual-sync/{data,logs} ``` Your final structure should look like: ``` . ├── docker-compose.yml ├── data-main/ # Actual Budget server 1 data ├── data-secondary/ # Actual Budget server 2 data └── actual-sync/ ├── config.json # Actual-sync configuration ├── data/ # Sync data and database └── logs/ # Application logs ``` ## Configuration ### First run (auto-seeded template) On first start, if the mounted config directory has **no `config.json`**, the container writes a **`config.example.json` template** into it and exits with instructions. Fill that in and rename it to `config.json`: - For each budget, set `url`, `password`, and `syncId`. - **Where to get the Sync ID:** in Actual Budget, open the budget → **Settings → Advanced → "Sync ID"**. - Rename `config.example.json` → `config.json` and restart the container. (The schema used for validation is bundled in the image, so validation works even though the config directory is a mounted volume.) ### Basic Configuration Example Create `actual-sync/config.json`: ```json { "$schema": "./config.schema.json", "servers": [ { "name": "Budget 1", "url": "http://actual-main:5006", "password": "your_password_here", "syncId": "your_sync_id_here", "dataDir": "/app/data/budget1", "sync": { "schedule": "0 5 * * 2" } }, { "name": "Budget 2", "url": "http://actual-main:5006", "password": "your_password_here", "syncId": "another_sync_id", "dataDir": "/app/data/budget2", "sync": { "schedule": "0 5 * * 1,3,5" } } ], "sync": { "maxRetries": 5, "baseRetryDelayMs": 3000 }, "logging": { "level": "INFO", "format": "pretty" }, "healthCheck": { "port": 3000, "host": "0.0.0.0" }, "syncHistory": { "dbPath": "data/sync-history.db", "retentionDays": 90 }, "prometheus": { "enabled": true, "includeDefaultMetrics": true }, "notifications": { "telegram": { "enabled": true, "botToken": "123456789:YOUR-BOT-TOKEN", "chatId": "YOUR_CHAT_ID" } } } ``` ### Cron Schedule Format The `schedule` field uses standard cron format: `minute hour day month weekday` Common examples: - `0 5 * * *` - Daily at 5:00am - `0 5 * * 1` - Every Monday at 5:00am - `0 5 * * 1,3,5` - Monday, Wednesday, Friday at 5:00am - `30 5 * * 2,4` - Tuesday, Thursday at 5:30am - `0 */6 * * *` - Every 6 hours ## Common Issues ### Issue 1: Permission Denied / Database Write Errors **Symptom:** ``` Failed to initialize sync history database unable to open database file ``` **Solution:** The container runs as a non-root user (UID 1001 by default). You must either: **Option A: Use the `user` directive (Recommended)** ```yaml actual-sync: image: ghcr.io/agigante80/actual-sync:latest user: "1026:100" # Your host user UID:GID ``` Find your UID and GID: ```bash id -u # Gets your UID id -g # Gets your GID ``` **Option B: Change directory ownership** ```bash sudo chown -R 1001:1001 actual-sync/data actual-sync/logs ``` ### Issue 2: Configuration Validation Errors **Symptom:** ``` Server 1 (unnamed): Missing required field 'name' ``` **Solution:** Ensure each server has all required fields: - `name` - Display name for the budget - `url` - Actual Budget server URL - `password` - Server password - `syncId` - Budget sync ID - `dataDir` - Local data directory path ### Issue 3: Network Connectivity **Symptom:** Container can't reach Actual Budget servers. **Solution:** Ensure all services are on the same Docker network: ```yaml networks: actual-net: name: actual-net services: actual-sync: networks: - actual-net ``` Use service names (e.g., `http://actual-main:5006`) instead of `localhost`. ### Issue 4: Database Path Issues **Symptom:** ``` unable to open database file ``` **Solution:** Use relative paths in config for mounted volumes: ```json { "syncHistory": { "dbPath": "data/sync-history.db" // ✅ Relative path // NOT "/app/data/sync-history.db" ❌ Absolute path } } ``` ### Issue 5: Dashboard unreachable in a container (`EADDRNOTAVAIL`) **Symptom:** ``` listen EADDRNOTAVAIL: address not available 192.168.x.x:3000 ``` The dashboard can't be reached, often only working with `--network host`. **Cause:** `healthCheck.host` was set to the host's LAN IP. A bridge-networked container can't bind the host's external IP — only `0.0.0.0` (all interfaces), `127.0.0.1`, or `localhost`. `healthCheck.host` is the address inside the container; who can reach it from outside is decided by the `ports` mapping (see [Container hardening](#container-hardening-253)). **Solution:** set `healthCheck.host` to `0.0.0.0` (the default) and reach the dashboard via the published port: ```json { "healthCheck": { "port": 3000, "host": "0.0.0.0" } } ``` As of the `EADDRNOTAVAIL` fix the container also **falls back to `0.0.0.0` automatically** (with a warning) if a host is set that it can't bind — but the correct config is still `0.0.0.0`. ## The Image The published image is built from a multi-stage `Dockerfile` and ships **only production dependencies** — the builder installs with `npm ci --omit=dev`, so devDependencies (`jest`, `puppeteer` and its bundled Chromium, etc.) are **not present** in `/app/node_modules`. This keeps the image small and reduces its attack surface. Tests are run in CI, not inside the image build. The container starts as root only to apply `PUID`/`PGID` and fix ownership of the writable volumes, then drops to a non-root user (see [Issue 1: Permission Denied](#issue-1-permission-denied--database-write-errors)). The dashboard needs no outbound access: its chart library (Chart.js) is a production dependency installed into the image, so the dashboard works with egress blocked. ## Verifying the image Every published image carries a signed build provenance attestation and an SBOM (software bill of materials), produced by the publish job in CI (#252). The attestation ties the image digest to the exact commit and workflow run that built it, so you can check that the tag you pulled was built by this repository and not re-tagged from somewhere else. **Check the provenance** with the GitHub CLI (`gh` 2.49 or newer). The command resolves the tag to its digest, fetches the attestation from the registry and checks the signature against GitHub's trust root: ```bash gh attestation verify oci://ghcr.io/agigante80/actual-sync:1.22.0 --repo agigante80/Actual-sync ``` Docker Hub works the same way with `oci://index.docker.io/agigante80/actual-sync:1.22.0`. A successful run ends with a line like: ``` ✓ Verification succeeded! The following 2 attestations matched the policy criteria - Attestation #1 - Build repo:..... agigante80/Actual-sync - Build workflow:. .github/workflows/ci-cd.yml@refs/tags/v1.22.0 - Signer workflow: .github/workflows/ci-cd.yml@refs/tags/v1.22.0 ``` An image that was built elsewhere and pushed under the same tag has no matching attestation, and the command exits non-zero with `no attestations found`. Pin the digest the command printed in your compose file (`image: ghcr.io/agigante80/actual-sync@sha256:...`) if you want later pulls to keep matching what you verified. **Read the SBOM.** The SPDX SBOM is attached to each GitHub release as `actual-sync-vX.Y.Z-linux-amd64.spdx.json` (and `-linux-arm64`), so you can answer "does this image contain package X" without pulling the image: ```bash gh release download v1.22.0 --repo agigante80/Actual-sync --pattern '*.spdx.json' ``` The same SBOM is stored in the registry next to the image and can be read with `docker buildx imagetools inspect ghcr.io/agigante80/actual-sync:1.22.0 --format '{{ json .SBOM }}'`. **What this does and does not prove.** The attestation proves how and where the image was built. It does not prove that the commit it was built from is sound, or that the release was authorised. Treat it as one link in the chain: it stops a tampered or substituted image, not a bad commit. See [SECURITY_AND_PRIVACY.md](SECURITY_AND_PRIVACY.md#image-provenance-and-sbom-252). ## Production Tips ### 1. Security **Use Read-Only Config:** ```yaml volumes: - ./actual-sync/config.json:/app/config/config.json:ro # Note :ro flag ``` **Protect Sensitive Data:** - Never commit `config.json` with real passwords to git - Use `.gitignore`: ``` actual-sync/config.json actual-sync/data/ actual-sync/logs/ ``` #### Container hardening (#253) Since v1.22.0 the shipped `docker-compose.yml`, the README and Docker Hub examples start the container with these controls; the Unraid template applies all of them but the port row, since its WebUI stays reachable on the LAN. Each was verified with a real container run before it was committed: the service reached `/health`, served the dashboard, recorded a sync attempt in its SQLite history and wrote its log file, and the Node process held no capability. CI repeats that run on every build (`scripts/docker-hardened-smoke.sh`). | Control | What it does | How to relax it | |---------|--------------|-----------------| | `ports: "127.0.0.1:3000:3000"` | The dashboard, health and metrics port answers on the Docker host only. Nothing else on the network can reach it. | Put a reverse proxy in front (next section), or publish `"3000:3000"` after setting `healthCheck.dashboard.auth`, and `healthCheck.auth` for the metrics, in `config.json`. | | `cap_drop: [ALL]` + `cap_add: [CHOWN, SETUID, SETGID]` | Every Linux capability is dropped; the three added back are what the entrypoint needs to apply `PUID`/`PGID` (`usermod`, `groupmod`), fix volume ownership (`chown -R`) and drop to `actualuser` (`su-exec`). The application itself then runs with none. | Add `DAC_READ_SEARCH` if a bind-mounted data or logs directory is owned by another user with mode `700`. Or set `user: ":"`: the entrypoint then skips its root phase and `cap_add` can be removed entirely. | | `security_opt: [no-new-privileges:true]` | No process in the container can gain privileges through a setuid binary, whatever capabilities it holds. | Nothing in the image needs it; leave it on. | | `read_only: true` + `tmpfs: [/tmp]` (commented out) | Read-only root filesystem. | Opt in only with the default `PUID`/`PGID` (`1001:1001`): any other value needs `usermod` and `groupmod` to write `/etc/passwd` and `/etc/group`, and the container does not start. | What the container run showed, with the start-up config pointing at an unreachable Actual server so the recorded attempt is a failure: | Run | Result | |-----|--------| | `--cap-drop ALL`, nothing added | `chown: /app/data: Operation not permitted`, container exits | | CHOWN, SETUID, SETGID; `PUID`/`PGID` default | starts, history and log written as `actualuser` | | CHOWN, SETUID, SETGID; `PUID=1234`, `PGID=1234` | starts as `1234:1234`, files owned `1234:1234` | | same, bind-mounted data directory, mode `755` | starts, files owned by the `PUID` | | same, bind-mounted data directory, mode `700`, other owner | `chown: /app/data: Permission denied`; passes with `DAC_READ_SEARCH` added | | same plus `--read-only --tmpfs /tmp`, default IDs | starts | | same plus `--read-only --tmpfs /tmp`, `PUID=1234` | `groupmod: /etc/group.10: Read-only file system`, container exits | | the dashboard from another machine's address | connection refused; `200` on the Docker host | | `su-exec root` as the service user under `no-new-privileges` | `Operation not permitted` | **Upgrading from an older compose file:** if you took the previous `docker-compose.yml` as it was and opened the dashboard from another machine, that stops working after you pull the new file. The container is fine; the port is just no longer published on the LAN. Pick one of the two options in the first row above. **Behind a reverse proxy (nginx, Traefik):** tell Actual-sync how many proxies sit in front, so rate limiting and auth-failure logs use the real client IP: ```json "healthCheck": { "port": 3000, "host": "0.0.0.0", "trustProxy": 1 } ``` A minimal nginx `server` block for the dashboard, including the live log stream (`/ws/logs` needs the HTTP/1.1 upgrade headers). Pass `Host` with `$http_host`: the dashboard refuses a WebSocket whose `Origin` does not equal the `Host` it receives, and `$host` drops a non-default port. ```nginx server { listen 80; server_name actual-sync.example.com; location / { proxy_pass http://actual-sync:3000; proxy_set_header Host $http_host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /ws/logs { proxy_pass http://actual-sync:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $http_host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_read_timeout 1h; } } ``` nginx must forward the client address (`proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`); Traefik does this by default. Do not publish port 3000 as well (no `ports:` entry for it): a client that reaches the port directly can send its own `X-Forwarded-For` and pick any IP it likes. A CIDR such as the Docker bridge range is not a safe workaround, because connections through a published port can arrive from the bridge gateway address, which that range trusts. If you need a narrower rule than a hop count, put the proxy on a dedicated network with a fixed address and trust only that exact IP. ### 2. Monitoring **Health Check Endpoint:** ```bash curl http://localhost:3000/health ``` **Prometheus Metrics:** ```bash curl http://localhost:9090/metrics ``` **View Logs:** ```bash docker logs -f actual-sync-service ``` ### 3. Notifications **Telegram Setup:** 1. Create a bot via [@BotFather](https://t.me/BotFather) 2. Get your chat ID from [@userinfobot](https://t.me/userinfobot) 3. Add to config: ```json { "notifications": { "telegram": { "enabled": true, "botToken": "123456789:YOUR-BOT-TOKEN", "chatId": "YOUR_CHAT_ID" } } } ``` ### 4. Resource Management **Set Resource Limits:** ```yaml actual-sync: image: ghcr.io/agigante80/actual-sync:latest deploy: resources: limits: cpus: '0.5' memory: 512M reservations: cpus: '0.25' memory: 256M ``` ### 5. Logging **Rotate Logs with Docker:** ```yaml actual-sync: logging: driver: "json-file" options: max-size: "10m" max-file: "5" ``` ### 6. Backup Strategy **Regular Backups:** ```bash # Backup sync history and data tar -czf actual-sync-backup-$(date +%Y%m%d).tar.gz actual-sync/data/ # Backup configuration cp actual-sync/config.json actual-sync/config.json.backup ``` ### 7. Multiple Budgets on Same Server Each budget on the same Actual Budget server needs a separate entry: ```json { "servers": [ { "name": "Personal Budget", "url": "http://actual-main:5006", "password": "same_password", "syncId": "sync_id_1", "dataDir": "/app/data/personal", "sync": { "schedule": "0 5 * * 1,3,5" } }, { "name": "Family Budget", "url": "http://actual-main:5006", "password": "same_password", "syncId": "sync_id_2", "dataDir": "/app/data/family", "sync": { "schedule": "0 5 * * 2,4" } } ] } ``` ## Stopping the container On SIGTERM or SIGINT the service stops accepting new syncs (including the rest of a scheduled run that has not started yet), cancels the cron jobs and pending retries, and waits up to 8 seconds for the sync that is running to finish. If it is still running after that, the Actual session is closed and the service exits. Plain `docker stop` gives a container 10 seconds before SIGKILL, which is why the default wait is 8 seconds. A sync still running at the deadline is cut: the session is closed under it, and its changes may be incomplete until the next sync. If your bank syncs take longer, raise both together: set `stop_grace_period` (compose) or `--stop-timeout` (`docker run`) and set the `SHUTDOWN_WAIT_MS` environment variable about 2 seconds below it, for example `stop_grace_period: 60s` with `SHUTDOWN_WAIT_MS=58000`. ## Updating ### Automated Updates This project uses automated dependency monitoring to track new releases of the Actual Budget API: - **Daily checks** for `@actual-app/api` updates - **Automatic PRs** created when new versions are available - **GitHub releases** automatically built and published after PR merge When a new version is detected, you'll receive a GitHub notification with a PR containing: - Version comparison - Migration compatibility notes - Testing checklist **To apply updates:** 1. Review and merge the automated PR 2. Wait for the CI/CD pipeline to complete (~10 minutes) 3. Pull the updated image (see below) ### Manual Update **Pull Latest Image:** ```bash docker compose pull actual-sync docker compose up -d actual-sync ``` **Verify Version:** ```bash docker exec actual-sync-service node -e "console.log(require('@actual-app/api/package.json').version)" ``` ### View Release Notes Check the [GitHub Releases](https://github.com/agigante80/Actual-sync/releases) page for changelogs and upgrade notes. ### Version Compatibility ⚠️ **Important:** The `@actual-app/api` version in the container must be compatible with your Actual Budget server version. Migration mismatches will cause sync failures. **Check compatibility:** - Container API version: See Docker image tags or `package.json` - Server version: Check Actual Budget UI → Settings → About - If you see "out-of-sync-migrations" errors, update to the latest container version ## Troubleshooting ### Enable Debug Logging ```json { "logging": { "level": "DEBUG", "format": "pretty" } } ``` ### Check Container Status ```bash docker compose ps docker compose logs actual-sync ``` ### Restart Service ```bash docker compose restart actual-sync ``` ### Clean Start ```bash docker compose down docker compose up -d ``` ## Support - **Documentation**: [README.md](../README.md) - **Configuration**: [config/config.example.json](../config/config.example.json) - **Issues**: [GitHub Issues](https://github.com/agigante80/Actual-sync/issues) - **Security**: [SECURITY_AND_PRIVACY.md](./SECURITY_AND_PRIVACY.md)