# Deploy with Docker (https://portunus.bybee.dev/en/docs/deployment/docker)



Docker Compose is the recommended deployment path for most installs. The
release pipeline publishes multi-arch Linux images to GitHub Container
Registry; the examples below use the `:latest` tag by default:

```sh
docker pull ghcr.io/zingerlittlebee/portunus-server:latest
docker pull ghcr.io/zingerlittlebee/portunus-client:latest
```

For fully repeatable production deploys, replace `:latest` with a pinned
release tag such as `:1.7.0`.

## Server compose [#server-compose]

Create `compose.yaml` on the server host:

```yaml
services:
  server:
    image: ghcr.io/zingerlittlebee/portunus-server:latest
    container_name: portunus-server
    ports:
      - "7443:7443"
      - "127.0.0.1:7080:7080"
    volumes:
      - portunus-data:/var/lib/portunus
    command:
      - --data-dir
      - /var/lib/portunus
      - serve
      - --operator-http-listen
      - 0.0.0.0:7080
    restart: unless-stopped

  operator:
    profiles:
      - cli
    image: ghcr.io/zingerlittlebee/portunus-server:latest
    network_mode: "service:server"
    volumes:
      - portunus-data:/var/lib/portunus
    entrypoint:
      - portunus-server
      - --data-dir
      - /var/lib/portunus
    command:
      - list-clients

  volume-tools:
    profiles:
      - ops
    image: alpine:3.20
    volumes:
      - portunus-data:/data
      - .:/backup
    working_dir: /backup
    command:
      - "true"

volumes:
  portunus-data:
    name: portunus-data
```

Start the server:

```sh
docker compose up -d server
```

The named volume keeps generated TLS material, the SQLite database, and other
daemon-owned state under `/var/lib/portunus`. If
`/var/lib/portunus/server.toml` is absent, the server uses built-in defaults.
The Web UI and operator API bind to `0.0.0.0:7080` inside the container and are
published only on the server host loopback interface as
`http://127.0.0.1:7080/`. The client control-plane listener remains reachable
on `0.0.0.0:7443`.

For first-run onboarding, read the setup token from the running server logs:

```sh
docker compose logs server | grep 'Portunus onboarding setup token'
```

Open `http://127.0.0.1:7080/`, paste the setup token, and create the first
`superadmin` password. The token expires after 30 minutes and is rotated every
time the server starts while onboarding is incomplete.

If you need overrides, mount an optional `server.toml` into the same data dir:

```yaml
services:
  server:
    volumes:
      - portunus-data:/var/lib/portunus
      - ./server.toml:/var/lib/portunus/server.toml:ro
```

> The interactive installer (`curl … | bash`) can manage a Docker
> Compose deployment end to end — see
> [Installation → Interactive manager](/en/docs/getting-started/installation).

## Operator commands [#operator-commands]

The runtime image is distroless, so it has no shell. The `operator` service is
a one-shot CLI sidecar that shares the server network namespace and the same
named volume. `docker compose run` overrides the service `command`, so each
invocation can run a different subcommand:

Operator commands fall into two groups, and they have very different runtime
requirements.

**HTTP API commands** (`push-rule`, `list-rules`, `remove-rule`, `rule-stats`)
talk to the running server's operator HTTP API, so they require an API token
and can run while `server` is up. After Web onboarding, create a token from the
Web UI Credentials page and pass it as `PORTUNUS_OPERATOR_TOKEN`:

```sh
docker compose run --rm \
  -e PORTUNUS_OPERATOR_TOKEN="$PORTUNUS_OPERATOR_TOKEN" \
  operator push-rule edge-01 18080 backend.internal:8080
```

**Store-direct commands** (`enroll-client`, `revoke`, `list-clients`,
`reset-password`, `onboarding-token`) open the SQLite store directly and take an
exclusive lock. They do **not** read `PORTUNUS_OPERATOR_TOKEN`, and they cannot
run while `server` holds the store — a running server makes them exit with
`store_in_use` (exit code 75). Stop `server` first:

```sh
docker compose stop server
docker compose run --rm operator enroll-client edge-01
docker compose up -d server
```

Run the printed `portunus-client enroll 'portunus://...'` command on the edge
host.

`onboarding-token` is mainly an offline maintenance command; a later `serve`
start rotates the setup token again, so browser onboarding normally uses the
token printed in the `server` logs. `reset-password` follows the same
stop-server pattern:

```sh
docker compose stop server
docker compose run --rm operator reset-password admin --temporary
docker compose up -d server
```

Use the actual superadmin user ID. For `bootstrap-superadmin` installs that ID
is `_superadmin`; for Web onboarding it is the ID chosen during setup, for
example `admin`.

## Client [#client]

The client image's entrypoint is already `portunus-client`, so pass
`enroll …` as arguments to override the default `CMD`. Run **both** the
one-shot enroll and the long-lived container as your host user — the
image defaults to `nonroot` (UID 65532), which cannot write a
host-owned bind mount nor read a host-UID `0600` bundle:

```sh
# One-shot: redeem the enrollment command into a host file.
docker run --rm --user "$(id -u):$(id -g)" -v "$PWD:/work" \
  ghcr.io/zingerlittlebee/portunus-client \
  enroll 'portunus://...' --out /work/client.bundle.json

# Long-lived forwarder.
docker run -d --name portunus-client --network host \
  --user "$(id -u):$(id -g)" \
  -v "$PWD/client.bundle.json:/etc/portunus/client.bundle.json:ro" \
  ghcr.io/zingerlittlebee/portunus-client
```

Compose equivalent (`compose.client.yaml`), after the bundle exists:

```yaml
services:
  client:
    image: ghcr.io/zingerlittlebee/portunus-client:latest
    container_name: portunus-client
    network_mode: host
    user: "${HOST_UID}:${HOST_GID}"
    volumes:
      - ./client.bundle.json:/etc/portunus/client.bundle.json:ro
    restart: unless-stopped
```

Start it with `HOST_UID=$(id -u) HOST_GID=$(id -g) docker compose -f
compose.client.yaml up -d`. Host networking is the least surprising mode
for `portunus-client`: pushed rules bind listeners on the edge host and
Docker cannot know those ports before the operator creates the rules.
`install.sh` is host-only — containers use the image directly. Binary
and enroll details: [Client Configuration](/en/docs/configuration/client).

## Backup & migration [#backup--migration]

With the server state in one named volume, backup and migration are volume
operations plus the small Compose project files:

```sh
tar -czf portunus-config.tar.gz compose.yaml
docker compose stop server
docker compose run --rm volume-tools \
  tar -czf /backup/portunus-data.tar.gz -C /data .
docker compose start server
```

Restore on a new host:

```sh
tar -xzf portunus-config.tar.gz
docker compose run --rm volume-tools \
  tar -xzf /backup/portunus-data.tar.gz -C /data
docker compose up -d server
```

If you use an override file, back up `server.toml` alongside `compose.yaml`.

## Volumes & paths [#volumes--paths]

The recommended Compose example uses one writable named volume:

| Volume          | Container path       | Purpose                                                                       |
| --------------- | -------------------- | ----------------------------------------------------------------------------- |
| `portunus-data` | `/var/lib/portunus/` | Generated TLS material, optional `server.toml`, `state.db`, and sidecar files |

## Local image build [#local-image-build]

The repository also includes [`deploy/docker/docker-compose.yml`](https://github.com/ZingerLittleBee/Portunus/blob/main/deploy/docker/docker-compose.yml)
for building runtime images from a local checkout. That compose file is for
development and release verification, not the recommended install path:

```sh
./deploy/docker/prepare-build-context.sh
docker compose -f deploy/docker/docker-compose.yml build
```

The helper builds inside `rust:1.88-bookworm` so the copied binaries are
compatible with the Debian 12 distroless runtime image. Do not copy binaries
from a newer host distribution into these images; that can produce
`GLIBC_2.38 not found` failures at container startup.

## Considerations [#considerations]

* **Distroless runtime** has no shell. Use `docker compose run --rm operator`
  for Portunus CLI commands, or use a temporary sidecar container for file
  inspection.
* **Operator API / Web UI exposure** is a two-step decision: the process binds
  `0.0.0.0:7080` inside the container so Docker can publish it, while the sample
  maps that port to host loopback only.
* **Filesystem class check** still fires inside containers — make sure
  the Docker volume backend is persistent local storage.
* **`portunus-client`** needs the bundle present at
  `/etc/portunus/client.bundle.json` (or `PORTUNUS_CLIENT_BUNDLE`).

## Health checks [#health-checks]

Do not use `list-clients` (or any other store-direct command) as a
`HEALTHCHECK` probe. It opens the SQLite store with an exclusive lock, so
running it periodically against a live server fights the server for that lock
and fails with `store_in_use` (exit code 75) — the container would never report
healthy.

The distroless runtime image also has no shell, `curl`, or `wget`, so a
container-internal `CMD` probe is impractical. Instead, drive liveness from the
orchestration layer with a TCP connect check against a listening port — for
example the operator HTTP listener on `127.0.0.1:7080` or the metrics listener
on `127.0.0.1:7081` — or an external probe. In Compose this means a `healthcheck`
that runs a TCP check from a sidecar or relies on your orchestrator's native
probes rather than an in-image `CMD`.

## Networking notes [#networking-notes]

* The control-plane listener (`7443`) is the only port that **must**
  be exposed for cross-host clients.
* The operator HTTP listener (`7080`) binds to `0.0.0.0` inside the container
  so Docker can publish it, but the sample maps it to host loopback only:
  `127.0.0.1:7080:7080`.
* The metrics listener (`7081`) remains loopback-only.
