All projects Platform

How I Sandbox LLM Agents That Read Production Databases

Scheduled agents read production databases every night and report what they find. Each run happens inside a throwaway network holding only the one database it is allowed to see, using a login with no ability to write.

Role
Sole architect and operator
Running since
February 2026
Stack
Docker · Postgres · Python · Claude · Prometheus
Code
github.com/zzofrea/workflow-agent

Problem

An LLM agent with database access is the most useful thing I run and the one I trust least. It reads a warehouse every morning and writes up whatever moved. It is also a probabilistic system holding a shell and a network, where the failure mode is not a crash you can see but a quiet data leak you learn about later.

Objective

I run a fleet of these on my own hardware against real client data, so the design target was not what an agent is permitted to do but what it is structurally incapable of doing.

47
containers running
6 reachable publicly
1,049
archived agent runs
90% completed clean
0
ports forwarded from the internet
public traffic arrives by tunnel
0
write grants for auditors
21 SELECT, nothing else

Decision: Separate What It Can Reach From What It Does

Every agent is two files. A policy says what it can reach: which databases attach to the run, which tools are allowed. A role says what it should do: model, turn limit, timeout, task. A role cannot grant itself anything, only name a policy, so adding an agent is a behavior change rather than a security change. The default is deny: a policy naming nothing produces an agent with nothing. My briefing agents run that way and have never had database access.

What one agent run can touch

An agent run receives a policy file defining what it can reach and a role file defining what it should do. The run executes in a throwaway Docker network containing only the agent container and the single database its policy names. No route exists from that network to dokploy-network, where the other 45 containers run. The network is destroyed when the run exits. policy.yaml what it can reach databases · tools role.yaml what it should do model · turns · spec throwaway network · created per run agent container --cap-drop ALL · no docker socket --allowedTools · no persistence the one database its policy names 21 SELECT grants / 0 write grants reads destroyed on exit, timeout or signal report.json archived per run → Loki · Prometheus no route exists the run is never joined to this network dokploy-network every other service on the host + 35 more

scroll to see the full diagram →

Each run gets its own Docker network, created for it and destroyed when it exits, holding only the databases its policy names. Every other container sits on a network the run never joins.

The easier option was to put agent runs on the shared network with everything else and rely on credentials to keep them in their lane. Credentials get edited. A network route that was never created cannot be edited by mistake.

The sandbox limits where a run can go and Postgres limits what it can do. I did not want either one to be the only thing standing.

Database access is a real Postgres role, not a convention. The auditor login carries 21 SELECT grants and zero INSERT, UPDATE or DELETE, enforced a layer below anything the agent or I can reach while it runs.

The Weak Point, Named

These runs are unattended, on a schedule, at night. Nobody is there to approve a tool call, so permission prompting is off. That makes the tool allowlist the control between the agent and the shell rather than one of several, and my own spec says so in those words. I would rather write that down than find it during an incident.

The five layers behind it:

  • No added capabilities on the container
  • No Docker socket, so a run cannot start other containers
  • Input mounted read-only
  • Credentials resolved only from host environment variables, and rejected at parse time if a password is ever written in plaintext
  • No session persistence, so nothing a run learns survives it

Exposure, Counted

Where 47 containers actually sit

Of 47 running containers, 6 are reachable from the public internet through a tunnel, 14 bind a port on the LAN or host, and 27 are internal to Docker networks with no binding at all. 6 Public internet 14 LAN or host only 27 Internal only BY WHAT CAN REACH THEM

scroll to see the full diagram →

Counted from container labels and port bindings on 20 September 2026, not read off a diagram. The six public services reach the internet through a Cloudflare tunnel. The fourteen with a host binding listen on the LAN only, and nothing is port-forwarded from the internet, so none of them is reachable from outside the house.

The number that matters is how many things are actually listening, and it drifts from the diagram the moment you stop looking. When I counted for this write-up, my own inventory notes were stale by eleven containers.

Every Run Leaves Evidence

A run writes report.json to its own directory, and that path is the index: a log shipper parses service, role and run ID straight out of it into Loki, while four metrics per run go to Prometheus. There are 1,049 of these archived, unbroken since February, which means the platform can be judged on its own record instead of on my description of it.

Every run since February, and what happened to it

Of 1,049 archived agent runs between 25 February and 19 September 2026, 942 completed as expected, 62 exited non-zero and 45 crashed or timed out, a 90 percent completion rate. Of the 107 that did not complete, 58 were pipeline stages exiting non-zero, 32 were audit agents hitting the 300 second timeout, 13 were briefing agents erroring on upstream APIs, and 4 were audits correctly returning a fail verdict. Run duration across the 405 runs that emit a structured report was 51 seconds at the median, 105 seconds at p90 and 268 seconds at p95. 1,049 RUNS · 25 FEB TO 19 SEP 2026 942 Completed as expected, 90% of all runs 62 exited non-zero 45 crashed or timed out WHAT THE 107 NON-COMPLETIONS WERE 58 Pipeline stage exited non-zero a broken test argument accounts for most 32 Audit agent hit its timeout 300s cap, report kept and marked incomplete 13 Briefing agent errored upstream API refusals 4 Audit returned a fail verdict working as intended RUN DURATION, 405 STRUCTURED REPORTS 51s p50 105s p90 268s p95 The 300s cap is the only reason p95 is not longer.

scroll to see the full diagram →

Read straight out of the run archive on 19 September 2026. Duration covers the 405 runs that emit a structured report; the rest are pipeline stages, which log an exit code and their output rather than a report. Runs exit non-zero on failure, so a later stage can depend on an earlier one.

Those 58 non-zero exits are two unrelated stories, and only grouping them separates the two. Half a month of them were a real client-side problem, which is what led me to split the drift check into its own stage. The next twelve nights were that split backfiring: the new stage was the first command in the pipeline to contain a quoted argument, and the runner was building its arguments by splitting on spaces, so the test stage collected nothing and exited before it checked anything. Both look identical to a monitor that only watches whether a stage finished.

Guards, Not Elegance

Twelve minutes after midnight

At 00:00 UTC the orchestrator prunes every Docker image. At 00:05, 00:10 and 00:12 three scheduled guards rebuild the agent image, recreate the reverse proxy if it is missing, and recover a stalled media service. 00:00 Dokploy prunes all images docker image prune 00:05 Agent image rebuilt workflow-agent build 00:10 Traefik recreated ensure-traefik.sh 00:12 Media service recovered ensure-service.sh

scroll to see the full diagram →

The orchestration platform prunes unused images nightly, which periodically deletes live infrastructure. Rather than fight it, three guards run in the minutes after and put everything back.

Not elegant. Something upstream does something inconvenient on a schedule, so you measure it and put a guard in front of it. Dokploy exposes no exclusion list for the prune that I could find, so the alternative was forking the orchestrator and owning that fork forever. 41 of 47 containers currently hold three weeks of continuous uptime, and that floor is the night the guards were still being written.

What It Runs

The main workload is a client ETL pipeline: eight stages that a small dependency engine resolves into tiers and runs in parallel where it can, pulling five external APIs through a staged warehouse into the client's reports. Only one of those stages is allowed to stop a delivery.

Eight stages, and which failures are allowed to stop a report

The client ETL runs eight stages. One ingest stage, etl-pipeline, pulls five external APIs and is the only stage that can stop anything. Four checks run in parallel from it and block nothing: data-health, ops-drift, catalog-spine and amazon-report-validation. Three client reports, weekly, monthly and new-product, run when the ingest stage has succeeded, so a standing backlog of client-side findings, or a failing check, cannot hold up a delivery. INGEST etl-pipeline 5 APIs, bronze to gold CHECKS, IN PARALLEL, BLOCKING NOTHING data-health did we break the pipeline ops-drift client process drift catalog-spine cross-system disagreement amazon-report-validation feed prices vs report prices CLIENT REPORTS, GATED ON INGEST SUCCESS weekly-report Mondays monthly-report 1st of the month new-product-report daily Teal is the only path that can stop a client report.

scroll to see the full diagram →

Only the ingest stage can stop a delivery. The four checks run after it, report what they find and gate nothing, which is deliberate: three of them surface drift in the client's own process, and a standing backlog of that cannot be allowed to hold up a report.

That shape came from a mistake. One data-health check was originally doing two jobs: catching bugs in my pipeline, and surfacing drift in the client's process. The client's backlog kept it permanently red, which trains everyone to ignore it. Splitting the client-side findings into their own stages made red mean we broke something again.

Alongside it, a security scanner sweeps images, secrets and host hardening on its own schedule, and two backup jobs dump their databases weekly, then restore them into throwaway containers and match row counts, so the backup is verified rather than assumed.