Skip to content
Rowsafe
Docs

Run the agent in Docker

Protect PostgreSQL in the official postgres image with a sidecar agent container.

For PostgreSQL in the official postgres:NN image (Debian or Alpine). The agent runs as a sidecar container in your compose project: add a few volumes and one service, nothing on the host. For one-click restart and whole-database rewind, add one more small service: Let Rowsafe restart the container.

Linux Docker hosts only for production

Docker Desktop (macOS, Windows) runs in a VM whose disk and fsync behaviour Rowsafe doesn't guarantee. Use it to try Rowsafe only. Kubernetes isn't covered.

Other engines have their own images: MySQL and MariaDB, MongoDB, ClickHouse, Redis or Valkey, SQLite.

How it works

 compose project
 +-------------------------------+        +------------------------------------------+
 | postgres (postgres:17, stock) |        | rowsafe-agent (ghcr.io/rowsafe/agent:    |
 |                               |        |                pg17)                     |
 |  archive_command:             |        |                                          |
 |   cp + fsync + rename --------+--+     |  spool pusher: pgbackrest archive-push   |
 |                               |  |     |    each file in order, delete after OK --+--> your bucket
 |  /var/lib/postgresql/data  <--+--+-----+-- same volume, same path, read-only      |    (encrypted)
 |  /var/run/postgresql       <--+--+-----+-- socket: SQL                            |
 |  /rowsafe-spool/<db>/      <--+--+-----+-- spool: WAL waiting to be pushed        |
 +-------------------------------+        +------------------------------------------+
  • archive_command copies each WAL file into a shared spool volume with standard tools (cp, sync, mv). It never overwrites a different file.
  • The agent pushes spooled files to your bucket in order, and deletes each only once stored.
  • Backups, Marks, Proof and Rewind copies run in the agent container, which reads the data volume read-only at the same path (unless you allow whole-database rewind).
  • Only archive_command runs in the PostgreSQL container. Rowsafe never restarts it unless you let it and someone clicks Restart.

The spool must be on durable disk

Until the agent pushes a WAL file, the spool holds its only copy. Use a named volume on local disk, never tmpfs or a network filesystem that ignores fsync. Don't delete spooled files by hand.

Set up

You need an enrollment token (rowsafe hosts enroll-token), a bucket and its keys, and an encryption passphrase saved in your secret manager first (steps 2 and 3 of Adopt an existing database).

Put the settings in rowsafe-agent.env next to your compose file. chmod 600 it and never commit it:

rowsafe-agent.env
ROWSAFE_ENROLL_TOKEN=rse_...        # first start only; delete it afterwards
ROWSAFE_REPO_S3_ENDPOINT=<account-id>.eu.r2.cloudflarestorage.com
ROWSAFE_REPO_S3_BUCKET=app-rowsafe
ROWSAFE_REPO_S3_KEY=...
ROWSAFE_REPO_S3_KEY_SECRET=...
ROWSAFE_REPO_CIPHER_PASS=...
# ROWSAFE_PG_USER=app               # if POSTGRES_USER is not postgres

Add the agent to your compose file. Your postgres service keeps everything else.

compose.yml
services:
  postgres:
    image: postgres:17
    volumes:
      - pgdata:/var/lib/postgresql/data
      - pgsocket:/var/run/postgresql        # added
      - rowsafe-spool:/rowsafe-spool        # added

  rowsafe-agent:
    image: ghcr.io/rowsafe/agent:pg17           # pg + your PostgreSQL major
    hostname: db-1                          # the host name Rowsafe shows
    user: "999:999"                         # the postgres user of the Debian images
    restart: unless-stopped
    depends_on: [postgres]
    env_file: rowsafe-agent.env
    shm_size: 256m                          # Proof starts a scratch PostgreSQL here
    volumes:
      - pgdata:/var/lib/postgresql/data:ro  # same volume, same path, read-only
      - pgsocket:/var/run/postgresql
      - rowsafe-spool:/rowsafe-spool
      - rowsafe-state:/var/lib/rowsafe

volumes:
  pgdata:
  pgsocket:
  rowsafe-spool:
  rowsafe-state:

The tag must match your PostgreSQL major (pg14 to pg18) and tracks the newest Rowsafe release. To pin one, use an exact tag like ghcr.io/rowsafe/agent:0.5.0-pg17 (releases).

Start it:

docker compose up -d
docker compose logs rowsafe-agent      # "enrolled"
rowsafe hosts list                     # db-1
  • Adding the two mounts recreates the postgres container: a few seconds of downtime. Do it in a maintenance window.
  • With a bind mount, mount the same host directory at the same path in both services.
  • Keep the rowsafe-state volume: it holds the host's identity and configuration. Lose it and you enroll again with a new token.

Adopt

In the dashboard: Databases, then Add database. Or with the CLI:

rowsafe adopt app --host db-1        # read-only plan
rowsafe apply app                    # config, spool directory, ALTER SYSTEM + reload
docker compose restart postgres      # you, in a maintenance window

Rowsafe notices the restart and proves WAL reaches the bucket. To check now, click Check now (or rowsafe verify app).

Settings go into postgresql.auto.conf in the data volume, so they survive container re-creation.

Back up an uploads volume

Back up your app's uploads volume with the database. Mount it into the agent under /rowsafe-files (read-only is enough), recreate the agent, and add /rowsafe-files/uploads in Rewind, then Files:

compose.yml
services:
  rowsafe-agent:
    volumes:
      - uploads:/rowsafe-files/uploads:ro

Restored files land in /var/lib/rowsafe/files-restored/<id> unless the volume is mounted read-write and writable by the agent. See Back up and restore files.

Rewind in Docker

  • Restore a copy, compare and bring back rows work as on a server (Restore a database). The copy lives in the rowsafe-state volume, which needs about 1.3 × the database size + 1 GB free.
  • Rewinding the whole database needs the container control service and a writable data volume (below). Without it, restore a copy and bring back rows, or restore by hand into a new volume.

Let Rowsafe restart the container

Optional. Without it, you restart PostgreSQL yourself and whole-database rewind isn't offered. Add the rowsafe-docker-control service to get:

  • Restart PostgreSQL: restarts the container and waits until it's healthy and PostgreSQL answers.
  • Rewind the whole database: stops the container, puts the data back to the moment you pick, starts it again. The old data is kept 7 days for Undo.

The control service holds the Docker socket, never the agent, and acts on one container only: your PostgreSQL service. Read the Threat model first.

The database's Settings page shows these lines with your service and volume names filled in.

Add the service:

compose.yml
services:
  rowsafe-docker-control:
    image: ghcr.io/rowsafe/docker-control:latest   # or pin a release, like :0.5.0
    restart: unless-stopped
    depends_on: [postgres]
    environment:
      ROWSAFE_CONTROL_SERVICE: postgres     # your PostgreSQL service's name
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - rowsafe-control:/run/rowsafe-control
    network_mode: none
    read_only: true
    cap_drop: [ALL]
    security_opt: ["no-new-privileges:true"]

Give the agent the control volume and, for whole-database rewind, write access to the data volume (remove :ro):

compose.yml
  rowsafe-agent:
    volumes:
      - pgdata:/var/lib/postgresql/data     # was :ro. PostgreSQL 18+: pgdata:/var/lib/postgresql
      - rowsafe-control:/run/rowsafe-control
      # ... the other volumes as before

volumes:
  rowsafe-control:

Keep :ro if you only want Restart.

Apply it. This recreates the agent; PostgreSQL keeps running.

docker compose up -d
docker compose logs rowsafe-docker-control   # "controlling container" myapp-postgres-1

To take it back, remove the service and the rowsafe-control volume.

How a rewind works in Docker

The data directory can't be renamed in Docker, so the agent moves what's inside it, within the same volume:

  1. Rowsafe saves a Mark; the agent checks the repository, free space and write access.
  2. The control service stops the container (clean shutdown).
  3. The data moves into .rowsafe-rewind/<name>.before-rewind-<time>/.
  4. The backup is restored into .rowsafe-rewind/restore-<id>/ and moved into place, keeping your configuration (postgresql.conf, pg_hba.conf, archive settings).
  5. Changes replay up to your moment, in the agent container, with no network.
  6. The control service starts the container; the agent waits for connections.

If a step fails, the original data goes back and the container starts again (also after an agent crash, on its next start). Owner and permissions are kept. .rowsafe-rewind stops the entrypoint from creating a new cluster meanwhile, and backups skip it.

It needs free space in the data volume for a second copy (size plus 10%) until you click Delete the old data now or it expires.

Threat model

Whoever can reach the Docker socket controls the host (a privileged container can mount /). Mounting it :ro changes nothing. That's why the agent never gets it, and why this is opt-in.

What the control service allows. A Unix socket on the rowsafe-control volume, mounted only by the agent. Requests carry an action and a log ID (plus, for an agent update, the version and signed image list); anything else is refused. Actions: inspect, stop, start, restart. The target is fixed by its own config: the compose service in ROWSAFE_CONTROL_SERVICE in its own project (or one container in ROWSAFE_CONTROL_CONTAINER), resolved at start and its labels rechecked before every action. Never itself; a service scaled to two containers is refused. Docker API calls: list by label, inspect, stop, start, restart. No exec, attach, logs, networks or volumes.

Agent updates (only with ROWSAFE_CONTROL_ALLOW_AGENT_UPDATE=1). Two more actions, update_agent and agent_ready, and the Docker calls they need: inspect an image, pull ghcr.io/rowsafe/agent (hard-coded) at a digest from a list signed with Rowsafe's release key, create, rename and remove agent containers. Only on the agent's compose service (ROWSAFE_CONTROL_AGENT_SERVICE, default rowsafe-agent) and only a container already running ghcr.io/rowsafe/agent. The control plane only relays the signed list, so a compromised control plane can withhold an update, not choose the image. At most four an hour, one at a time.

Who may ask. Only uid 999 or 70 (the postgres user of Debian and Alpine images; change with ROWSAFE_CONTROL_ALLOW_UIDS, e.g. 101 for the ClickHouse agent image), checked by kernel peer credentials. Requests must be small and arrive within 10 seconds.

Limits and log. At most one restart a minute and 20 stops and restarts an hour. Starting is never limited, so a rollback can always start PostgreSQL. Every request is logged (uid, action, container, outcome): docker compose logs rowsafe-docker-control.

Who triggers it. Only an owner or admin: in the dashboard, with rowsafe restart and a read-write API key, or through their AI agent (acting as them; a member's agent is refused). All in the audit log.

If the agent container is compromised, the attacker can stop or restart PostgreSQL (rate-limited), less than the agent's database access already allows. Nothing else on the host.

If the control service is compromised, the attacker is root on the host. It's built to make that hard: one static Go binary (about 2,300 lines, half for agent updates), an empty image, no network (network_mode: none), no capabilities, read-only filesystem, no-new-privileges. Signed with cosign, with SLSA provenance (Verifying releases). It runs as root in its container only to open the socket.

Why not a Docker socket proxy? Generic proxies like tecnativa/docker-socket-proxy filter by API section, not container: allowing restarts would allow any container to be stopped, created or removed.

If that trade isn't right for you, skip the service: restart the container yourself and restore copies instead of rewinding in place.

Upgrade

The agent never updates itself in Docker. Pull the newest image and recreate only the agent, ideally between backups. PostgreSQL keeps running:

docker compose pull rowsafe-agent && docker compose up -d rowsafe-agent

Name the service: a plain pull and up -d can also upgrade and restart PostgreSQL. With an exact tag (0.5.0-pg17), change the version first. The Agent updates card on the server's page says when one is out.

Update the agent from the dashboard

Optional, with the container control service. Add one line and pull its newest image:

compose.yml
  rowsafe-docker-control:
    image: ghcr.io/rowsafe/docker-control:latest
    environment:
      ROWSAFE_CONTROL_SERVICE: postgres
      ROWSAFE_CONTROL_ALLOW_AGENT_UPDATE: "1"      # added
      # ROWSAFE_CONTROL_AGENT_SERVICE: rowsafe-agent  # if your agent's service has another name
docker compose pull rowsafe-docker-control && docker compose up -d rowsafe-docker-control

Then the server's page shows Update now. When an owner or admin clicks and confirms, the control service:

  1. checks the release's image list against Rowsafe's signature (key built in) and picks your image's digest (pg17, pg17-alpine, clickhouse26.8...);
  2. pulls ghcr.io/rowsafe/agent at that digest;
  3. creates a new agent container with the old one's exact settings, only the image changed;
  4. swaps them and waits up to five minutes for the new agent to be healthy and report in;
  5. removes the old container. Volumes are never removed.

If the new one doesn't come up, the old one starts again and the dashboard says why. PostgreSQL keeps running; WAL waits in the spool for those seconds. An interrupted backup or Proof run reports as failed and runs again on schedule. Logged in the audit log and docker compose logs rowsafe-docker-control.

Limits:

  • It only replaces exactly one container of the agent's service, running ghcr.io/rowsafe/agent (not your own build).
  • The new container runs by digest. If your compose file pins an older exact tag, a later docker compose up -d --force-recreate goes back to it, so use the floating pg<major> tag (clickhouse26.8 for ClickHouse).
  • Works from the release after 0.5.0, for agents and control services from then on.

Rowsafe doesn't do PostgreSQL major upgrades in Docker. After one, switch to the new major's agent tag and adopt the database again (a new history).

Watch the spool

The agent reports the spool with every heartbeat. In Docker, the WAL status in rowsafe show app describes your bucket: "last archived" is the last file pushed, and failed pushes count as archiving failures.

  • If the oldest spooled file waits over 5 minutes (ROWSAFE_SPOOL_STALL_AFTER), you get the wal_archiving_failing alert and the database stops counting as protected.
  • The health check fails too, so docker ps shows unhealthy.
  • The API (GET /v1/databases/{ref}) shows the spool's file count, size and last push error.

Troubleshooting

The agent checks its environment at startup and names the fix:

MessageFix
refusing to run as rootSet user: "999:999" (or "70:70" for Alpine images).
PostgreSQL's socket directory /var/run/postgresql is owned by uid 70, but the agent runs as uid 999Alpine image: use the agent's -alpine tag and user: "70:70".
the agent runs as uid N, which has no user in this imageUse the agent tag that matches your postgres image flavour.
the WAL spool /rowsafe-spool is missingMount the spool volume in both services.
the WAL spool ... is owned by uid 0Another container created the volume first. Recreate it while empty, or chown 999:999 it.
PostgreSQL's data directory ... is not visible in the agent containerMount the data volume at the same path in both services (for 18+, at /var/lib/postgresql).
... belongs to another cluster (system identifier ...)The agent sees a different volume at that path.
this agent image has no PostgreSQL N server binariesUse the -pgN tag for your server's major version.
Rowsafe can't restart PostgreSQL running in Docker yetAdd the container control service (Let Rowsafe restart the container), or restart the container yourself.
found no container for the compose service "postgres"Set ROWSAFE_CONTROL_SERVICE to your PostgreSQL service's name, then docker compose up -d.
the PostgreSQL data volume is mounted read-only in the agent containerRemove :ro from the agent's data volume to allow Rewind the whole database.
uid N may not use this serviceThe agent runs as another user: set ROWSAFE_CONTROL_ALLOW_UIDS on the control service.
updating the agent's container isn't allowedSet ROWSAFE_CONTROL_ALLOW_AGENT_UPDATE: "1" on the control service (Update the agent from the dashboard), or update it yourself.
the agent container runs "...", not an image of ghcr.io/rowsafe/agentRowsafe only replaces its own published image: update a custom image yourself.
found no container for the agent's compose serviceSet ROWSAFE_CONTROL_AGENT_SERVICE to your agent's service name.

If WAL piles up in the spool, docker compose logs rowsafe-agent shows the error. It drains by itself once fixed.

Edit on GitHub