Ctrl K
ComunidadGuías…

docs/DOCKER_DEPLOYMENT.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# 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: "<uid>:<gid>"`: 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)