# Docker setup

Run CrossWatch (CW) in Docker. Always persist `/config`.

Expose `8787`.

Use either a Docker volume or a bind mount for `/config`.

Both options work fine.

If you use Portainer or a NAS container UI, use these same values.

{% hint style="info" %}
We do **not** provide support for installing or troubleshooting container runtimes.

These pages assume Docker (or your NAS container manager) is already installed and working.

If Docker itself is broken, use your platform docs first.
{% endhint %}

### Which should I use?

Both options are perfectly fine.

Pick the one that fits your setup.

Pick a Docker volume if you want:

* the simplest setup
* Docker to manage storage for you

Pick a bind mount if you want:

* files in a known host path
* a NAS share or fixed folder layout

{% hint style="info" %}
Quick rule:

* Use a Docker volume for simpler Docker-managed storage
* Use a bind mount for a specific host folder
  {% endhint %}

{% hint style="warning" %}
Bind mounts need correct host permissions.

CrossWatch must be able to write to the mounted directory.
{% endhint %}

### Before you run

* Pick one storage type for `/config`.
  * Docker volume: `crosswatch_config`
  * Bind mount: `/srv/crosswatch/config`
* Decide which port to expose.
  * Default UI port: `8787`
* Set your timezone.
  * Example: `TZ=Europe/Amsterdam`

{% hint style="warning" %}
Do not run without persistent storage on `/config`.

You will lose state when the container is recreated.
{% endhint %}

### Health check

CW exposes `GET /healthz`.

Expected response:

```json
{"ok":true,"status":"ok"}
```

It returns success when the app is ready.

The default examples below stay minimal.

If you want container health status, use the separate examples later on this page.

### Option A: Docker volume

Create the volume once:

```bash
docker volume create crosswatch_config
```

Run CW:

{% tabs %}
{% tab title="Docker" %}

```bash
docker run -d \
  --name crosswatch \
  -p 8787:8787 \
  -v crosswatch_config:/config \
  -e TZ=Europe/Amsterdam \
  --restart unless-stopped \
  ghcr.io/cenodude/crosswatch:latest
```

Open `http://localhost:8787`.
{% endtab %}

{% tab title="Docker Compose" %}

```yaml
services:
  crosswatch:
    image: ghcr.io/cenodude/crosswatch:latest
    container_name: crosswatch
    ports:
      - "8787:8787"
    environment:
      TZ: Europe/Amsterdam
    volumes:
      - type: volume
        source: crosswatch_config
        target: /config
    restart: unless-stopped

volumes:
  crosswatch_config:
```

{% endtab %}
{% endtabs %}

### Option B: Bind mount

Create a host directory first (below is just an example!):

```bash
mkdir -p /srv/crosswatch/config
```

{% hint style="warning" %}
Check permissions before you start.

CrossWatch must be able to read and write the host directory.

By default, the container runs as UID `1000` and GID `1000`.

If needed, either:

* change the host directory owner to `1000:1000`, or
* set `APP_UID` and `APP_GID` to match your host user
  {% endhint %}

Run CW:

{% tabs %}
{% tab title="Docker" %}

```bash
docker run -d \
  --name crosswatch \
  -p 8787:8787 \
  -v /srv/crosswatch/config:/config \
  -e TZ=Europe/Amsterdam \
  --restart unless-stopped \
  ghcr.io/cenodude/crosswatch:latest
```

Open `http://localhost:8787`.
{% endtab %}

{% tab title="Docker Compose" %}

```yaml
services:
  crosswatch:
    image: ghcr.io/cenodude/crosswatch:latest
    container_name: crosswatch
    ports:
      - "8787:8787"
    environment:
      TZ: Europe/Amsterdam
    volumes:
      - type: bind
        source: /srv/crosswatch/config
        target: /config
    restart: unless-stopped
```

{% endtab %}
{% endtabs %}

### Optional: Docker health check

Use this if you want Docker to mark the container as `healthy` or `unhealthy`.

It polls `http://127.0.0.1:8787/healthz`.

Defaults used below:

* interval: `30s`
* timeout: `5s`
* retries: `3`
* start period: `20s`

#### Docker volume + health check

{% tabs %}
{% tab title="Docker" %}

```bash
docker run -d \
  --name crosswatch \
  -p 8787:8787 \
  -v crosswatch_config:/config \
  -e TZ=Europe/Amsterdam \
  --health-cmd "python -c \"import json, urllib.request; data=json.load(urllib.request.urlopen('http://127.0.0.1:8787/healthz', timeout=5)); raise SystemExit(0 if data.get('ok') is True and data.get('status') == 'ok' else 1)\"" \
  --health-interval 30s \
  --health-timeout 5s \
  --health-retries 3 \
  --health-start-period 20s \
  --restart unless-stopped \
  ghcr.io/cenodude/crosswatch:latest
```

{% endtab %}

{% tab title="Docker Compose" %}

```yaml
services:
  crosswatch:
    image: ghcr.io/cenodude/crosswatch:latest
    container_name: crosswatch
    ports:
      - "8787:8787"
    environment:
      TZ: Europe/Amsterdam
    volumes:
      - type: volume
        source: crosswatch_config
        target: /config
    healthcheck:
      test:
        - CMD-SHELL
        - >-
          python -c "import json, urllib.request; data=json.load(urllib.request.urlopen('http://127.0.0.1:8787/healthz', timeout=5)); raise SystemExit(0 if data.get('ok') is True and data.get('status') == 'ok' else 1)"
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 20s
    restart: unless-stopped

volumes:
  crosswatch_config:
```

{% endtab %}
{% endtabs %}

#### Bind mount + health check

{% tabs %}
{% tab title="Docker" %}

```bash
docker run -d \
  --name crosswatch \
  -p 8787:8787 \
  -v /srv/crosswatch/config:/config \
  -e TZ=Europe/Amsterdam \
  --health-cmd "python -c \"import json, urllib.request; data=json.load(urllib.request.urlopen('http://127.0.0.1:8787/healthz', timeout=5)); raise SystemExit(0 if data.get('ok') is True and data.get('status') == 'ok' else 1)\"" \
  --health-interval 30s \
  --health-timeout 5s \
  --health-retries 3 \
  --health-start-period 20s \
  --restart unless-stopped \
  ghcr.io/cenodude/crosswatch:latest
```

{% endtab %}

{% tab title="Docker Compose" %}

```yaml
services:
  crosswatch:
    image: ghcr.io/cenodude/crosswatch:latest
    container_name: crosswatch
    ports:
      - "8787:8787"
    environment:
      TZ: Europe/Amsterdam
    volumes:
      - type: bind
        source: /srv/crosswatch/config
        target: /config
    healthcheck:
      test:
        - CMD-SHELL
        - >-
          python -c "import json, urllib.request; data=json.load(urllib.request.urlopen('http://127.0.0.1:8787/healthz', timeout=5)); raise SystemExit(0 if data.get('ok') is True and data.get('status') == 'ok' else 1)"
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 20s
    restart: unless-stopped
```

{% endtab %}
{% endtabs %}

### Optional environment variables

You normally only need `TZ`.

Override these only if you know why:

* `TZ` — container timezone (used for timestamps in logs and the UI).
* `CONFIG_BASE` — base path for `config.json`, `state.json`, `statistics.json`, etc.
  * Default: `/config` (leave as-is)
* `WEB_HOST` — bind address for the web UI inside the container.
  * Default: `0.0.0.0`
* `WEB_PORT` — port for the web UI inside the container.
  * Default: `8787`
* `APP_USER` — username created inside the container that runs CW.
  * Default: `appuser`
* `APP_GROUP` — group name created inside the container.
  * Default: `appuser`
* `APP_UID` — numeric UID used for `APP_USER` (helps match your host user).
  * Default: `1000`
* `APP_GID` — numeric GID used for `APP_GROUP`.
  * Default: `1000`
* `APP_DIR` — application directory inside the container.
  * Default: `/app` (leave as-is)
* `RUNTIME_DIR` — runtime/config directory used by the entrypoint.
  * Default: `/config` (leave as-is)
* `CW_RESET_AUTH_ONCE` — one-time UI auth recovery flag.
  * Set to `1` to clear stored auth and sessions on next start.
  * Remove it after the reset completes.
* `RELOAD` — enable Python auto-reload for development.
  * Default: `no` (set to `yes` for dev only)

{% hint style="info" %}
Use an IANA timezone name (like `Europe/Amsterdam`), not a GMT offset.
{% endhint %}

### Troubleshooting

#### UI does not load

* Confirm the container is running: `docker ps`
* Check logs: `docker logs crosswatch --tail 200`
* Confirm the port mapping matches the URL you open.
  * Example: `-p 8787:8787` -> `http://localhost:8787`

#### `/config` is not writable

This happens most often with bind mounts.

* Check the host directory exists.
* Check the container user can write to it.
* Align the host directory owner with `APP_UID` and `APP_GID`.
* On NAS platforms, also verify the shared folder ACLs.

### Next steps

* [What do you need?](https://wiki.crosswatch.app/getting-started/first-time-setup/what-do-you-need) - choose providers and a setup path
* [Best practices](https://wiki.crosswatch.app/getting-started/best-practices) - safer defaults for your first pairs
* [Settings](https://wiki.crosswatch.app/crosswatch/navigation/settings) - connect providers and configure pairs
