Skip to content

Hardened profile

The hardened profile is the way to run ReduceWaste on a real site. It is an override file, deploy/compose/hardened.yaml, on top of the same compose.yaml the Compose install uses, plus the hardened profile:

Terminal window
docker compose -f compose.yaml -f deploy/compose/hardened.yaml --profile hardened up -d --wait

With the override file but without --profile hardened, nothing starts. That is on purpose: a half-hardened stack cannot come up by accident. Never combine it with --profile dev; the dev port forwarders are switched off in this profile anyway.

AreaHardened behaviour
Agent sandboxesgVisor (runsc) only: RW_SANDBOX_RUNTIME=runsc and RW_REQUIRE_RUNSC=1. Without runsc the control plane refuses to start. runc is a dev-only runtime.
Document parserrw-docparse runs under runsc too.
FilesystemEvery container has a read-only root filesystem. Only tmpfs paths and named volumes are writable.
Privilegescap_drop: ALL and no-new-privileges everywhere. The one-shot init jobs get back only the capability they need to hand a fresh volume to its service user.
Kernel filtersDocker’s builtin seccomp profile on every service, and AppArmor docker-default where the host runs AppArmor.
UsersServices run as non-root users (uid 10001; the historian as postgres, the broker as mosquitto).
LimitsMemory, CPU and process-count limits on every service, plus rotated logs (10 MB × 5).
HealthA healthcheck on every long-running service that can carry one, and a restart policy.
ImagesEvery third-party image is pinned by its multi-arch digest (amd64 and arm64).
SecretsNo password or token in any container environment. They come from compose secrets.
PortsOnly the app port (4441, bound to 127.0.0.1 unless you set RW_APP_BIND) is published.
  1. Install gVisor on the host and register it with Docker, following the gVisor install guide (runsc install, then restart Docker). Check it:

    Terminal window
    docker info --format '{{json .Runtimes}}' | grep -q runsc && echo runsc-ok
  2. Write the secrets (once per site):

    Terminal window
    bash deploy/compose/hardened-secrets.sh

    This creates ./.rw/secrets/ (or $RW_SECRETS_DIR) with the historian password, the matching database URL, the app-to-control-plane token, the sandbox signing key and the document parser’s database password. Existing files are kept. Keep this directory out of backups that leave the site.

  3. Build the images offline from vendored wheels (see Air-gapped install for how the wheels reach the box):

    Terminal window
    bash scripts/vendor_wheels.sh x86_64 # aarch64 on a GB10-class box
    docker compose -f compose.yaml -f deploy/compose/hardened.yaml --profile hardened build
  4. Start the broker first, so its init job writes the broker credentials the other services mount:

    Terminal window
    H="docker compose -f compose.yaml -f deploy/compose/hardened.yaml --profile hardened"
    $H up -d --wait rw-broker rw-historian
  5. Migrate the historian and set the document parser’s password. The historian has no host port in this profile, so run the migration on its network:

    Terminal window
    docker run --rm --network rw-core \
    -v "$PWD/.rw/secrets/historian-dsn:/run/secrets/env.RW_DSN:ro" -v "$PWD/db:/db:ro" \
    --entrypoint rw-env reducewaste/rw-brain:dev \
    rw db migrate --dir /db/migrations --rls-file /db/rls/agent-rls.sql
    docker exec -i rw-historian psql -U rw -d rw -v pw="$(cat .rw/secrets/docparse-db)" \
    <<< "ALTER ROLE rw_docparse PASSWORD :'pw'"
  6. Start everything:

    Terminal window
    RW_DOCKER_GID=$(stat -c %g /var/run/docker.sock) $H up -d --wait

Add --profile demo to run the simulated plant on a hardened box. rw-edge is in the edge profile, as in the plain Compose install.

The historian reads POSTGRES_PASSWORD_FILE. Services that read a database URL or token from their environment (RW_DSN, DATABASE_URL, BRAIN_TOKEN, AGENT_SCOPE_SECRET) get it from a compose secret mounted as /run/secrets/env.<NAME>. A small entrypoint wrapper in every image, rw-env, exports each such file as that variable and then starts the service. The value never appears in docker inspect or docker compose config.

The limits are sized for one GB10-class box. Set these before up to change the largest ones, or add your own override file for the rest:

VariableDefaultService
RW_HISTORIAN_MEM_LIMIT8grw-historian
RW_INFER_MEM_LIMIT96grw-infer, rw-infer-vision, rw-infer-vllm, rw-infer-sglang
RW_INFER_CPUS0 (no cap)the same inference servers
RW_VISION_MEM_LIMIT8grw-vision, rw-vision-gpu
  • The control plane still mounts the Docker socket to create one network and one container per sandbox. That is root-equivalent on the host, so the host itself must be locked down. The Helm chart uses scoped Kubernetes permissions instead.
  • An existing historian volume keeps the password it was created with. Moving a dev install to this profile means ALTER ROLE rw PASSWORD to the value in .rw/secrets/historian-db first.
  • mediamtx (demo camera feed) has no healthcheck: its image has no shell or client to probe with.
Terminal window
H="docker compose -f compose.yaml -f deploy/compose/hardened.yaml --profile hardened"
$H config -q && echo config-ok
test "$($H config | grep -c cap_drop)" -eq "$($H config --services | wc -l)" && echo all-dropped
uv run pytest -q -p no:cacheprovider -m "docker or not docker" tests/deploy/test_hardened.py