> For the complete documentation index, see [llms.txt](https://wiki.crosswatch.app/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://wiki.crosswatch.app/getting-started/installation/docker-setup.md).

# Docker setup

Run 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 %}

{% hint style="success" %}
Fastest path for most users:

* use a Docker volume
* keep port `8787`
* set only `TZ`
  {% endhint %}

{% columns %}
{% column %}

#### Docker volume

Best when you want the simplest setup.

Docker manages the storage path.
{% endcolumn %}

{% column %}

#### Bind mount

Best when you want a fixed host folder.

You manage host permissions yourself.
{% endcolumn %}
{% endcolumns %}

### Quick start

{% stepper %}
{% step %}

### Pick storage

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

Volumes are simpler.
{% endstep %}

{% step %}

### Start the container

Use either the `docker run` or Docker Compose example below.

Expose `8787`.
{% endstep %}

{% step %}

### Open the UI

Open `http://localhost:8787`.

Continue with [First-time setup](/getting-started/first-time-setup.md).
{% endstep %}
{% endstepper %}

### 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.

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

### Before you run

* Pick one storage type for `/config`.
  * Docker volume: `crosswatch`
  * 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
```

Run CW:

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

```bash
docker run -d \
  --name crosswatch \
  -p 8787:8787 \
  -v crosswatch:/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
        target: /config
    restart: unless-stopped

volumes:
  crosswatch:
```

{% 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.

CW 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 the host directory owner
  {% 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 %}

### Container user and permissions

CW runs as a dedicated user inside the container.

It does not run the application as root.

Default container identity:

```
APP_USER=appuser
APP_GROUP=appuser
APP_UID=1000
APP_GID=1000
```

#### Docker managed volume

When `/config` uses a Docker managed volume, permission setup is usually not required.

Keep the defaults.

You can omit all four `APP_*` variables.

Example:

```yaml
volumes:
  - crosswatch:/config
```

CW creates the runtime user and group.

It prepares `/config` and applies the required ownership automatically.

#### Host directory bind mount

When `/config` maps to a host directory, the CW user must be able to read and write that directory.

Set `APP_UID` and `APP_GID` to match the host directory owner.

Leave `APP_USER` and `APP_GROUP` as `appuser` unless you need different names inside the container.

Example:

```yaml
services:
  crosswatch:
    image: ghcr.io/cenodude/crosswatch:latest
    environment:
      APP_UID: 1026
      APP_GID: 100
      APP_USER: appuser
      APP_GROUP: appuser
    volumes:
      - /volume1/docker/crosswatch:/config
```

In this example, CW runs inside the container as UID `1026` and GID `100`.

#### Find the correct UID and GID

Use one of these commands:

```bash
id -u
id -g
```

```bash
stat -c "%u:%g" /path/to/crosswatch
```

The first number is the UID.

The second number is the GID.

Example output:

```
1026:100
```

Use those values here:

```yaml
environment:
  APP_UID: 1026
  APP_GID: 100
```

#### Quick rules

* Docker managed volume:
  * keep the defaults
  * omit `APP_*` unless you have a specific reason
* Host directory bind mount:
  * set `APP_UID` and `APP_GID` to match the host directory owner
  * leave `APP_USER` and `APP_GROUP` as `appuser`

`APP_USER` and `APP_GROUP` are container-only names.

They do not need to match names on the Docker host.

#### Permission problems

When CW reports permission errors for `/config`, check the host directory ownership:

```bash
ls -ln /path/to/crosswatch
```

Then set `APP_UID` and `APP_GID` to the numeric owner and group shown there.

Or change the host directory ownership to match your configured values:

```bash
sudo chown -R 1000:1000 /path/to/crosswatch
```

Use the UID and GID that fit your environment.

{% hint style="warning" %}
Do not set the Docker Compose `user` property together with these variables.

CW needs startup permissions to prepare `/config`.

After that, it starts the application as the configured runtime user.
{% endhint %}

### 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 \
  -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
        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:
```

{% 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_UID` — numeric UID for the runtime user inside the container.
  * Default: `1000`
* `APP_GID` — numeric GID for the runtime group inside the container.
  * Default: `1000`
* `APP_USER` — runtime username inside the container.
  * Default: `appuser`
* `APP_GROUP` — runtime group name inside the container.
  * Default: `appuser`
* `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

* [First-time setup](/getting-started/first-time-setup.md) - connect providers and create your first pair
* [Best practices](/getting-started/best-practices.md) - safer defaults for your first pairs
* [Settings](/crosswatch/settings.md) - connect providers and configure pairs


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://wiki.crosswatch.app/getting-started/installation/docker-setup.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
