I wanted to try an open-source AI agent app on my VPS. It is a web app that starts coding agents, and those agents are allowed to run shell commands. The same VPS runs my automation stack and my file storage.
That combination is the problem. An agent with a shell does whatever its instructions and its inputs lead it to do. I did not want a bad prompt, a bad dependency or a runaway render to touch the other services or starve them of memory.
The result is one container that is treated as the blast radius. It has its own network, two mounted folders, hard CPU and memory caps, and a login in front of it. Everything is in one setup script that is safe to re-run. In the snippets below I have renamed the app to agent-app and the domain to agent.example.com.
The threat model, in plain terms
I am the only user, so this is not about hostile tenants. It is about limiting damage from the agent itself:
- It should not reach my other containers over the Docker network.
- It should not control Docker.
- It should not read or write anything on the host except its own data.
- It should not be able to use all the RAM or CPU.
- Nobody on the internet should reach it without a password. The app has no login of its own.
The run command
# Recreated every run so a new image or edited env file takes effect.
docker rm -f agent-app >/dev/null 2>&1 || true
docker run -d --name agent-app --restart unless-stopped --init \
--memory 2560m --memory-swap 2560m --cpus 1.5 --pids-limit 2048 --shm-size 512m \
-p 127.0.0.1:$PORT:4318 --env-file $ENVF \
-v $DATA/home:/home/node -v $DATA/projects:/app/projects \
agent-app:local >/dev/null
Each part answers one line of the threat model.
Network: the default bridge, on purpose
There is no --network flag. That is the point. My other services are attached to their own user-defined Docker networks, where containers find each other by name. A container on the default bridge is not on those networks, so the agent cannot open a connection to them. It still has normal outbound internet access, which the agents need.
No Docker socket
/var/run/docker.sock is not mounted. A container that can talk to the Docker daemon can start a new container with the host’s disk mounted, which is root on the host in practice. Some dashboards ask for the socket. An agent app does not get it.
Two mounts and nothing else
mkdir -p $DATA/home $DATA/projects && chown -R 1000:1000 $DATA
$DATA/projectsmaps to/app/projects: the files for each project the agents work on.$DATA/homemaps to/home/node: the user’s home directory, which holds session state, optional API keys the app stores, and tool caches.
Both are owned by UID 1000, which is the node user in the image. Everything else the container sees is its own image filesystem, which is thrown away when the container is recreated.
A non-root user
The image ends with:
# uid 1000 = the image's `node` user. Agents write anywhere in the workspace (projects/, skill caches).
RUN mkdir -p projects && chown -R node:node /app
USER node
ENV HOME=/home/node HOST=0.0.0.0 PORT=4318
The agents run as node, not root. They can write inside /app and their home directory, and that is all. The agent CLI is installed globally as root at build time, so the running user cannot modify it. Its auto-updater is switched off with DISABLE_AUTOUPDATER=1. Updates happen by rebuilding the image, never in place.
Resource caps sized for a small box
The VPS has 2 vCPUs, no swap, and about 2.5 GB free when the other services are running.
--memory 2560m --memory-swap 2560m: a hard limit. Setting both to the same value means the container gets no swap on top. If it goes over, the kernel kills a process inside this container and not one of my other services.--cpus 1.5: leaves half a core for everything else, even during a render.--pids-limit 2048: a ceiling on process count, so a loop that keeps spawning processes stops there.--shm-size 512m: the app renders frames with headless Chromium, and Chromium uses/dev/shmheavily. Docker’s default is too small for that.--init: runs a tiny init process as PID 1 that reaps finished child processes. Agents start Chromium and ffmpeg over and over, and without an init those can pile up as zombies.
Caps on the container are half the story. The app itself was built for a desktop and defaults to 6 parallel builders and 12 agents. I turned both down in the env file:
# Parallel shot builders per video, and agents at once across all projects
BUILDERS=1
MAX_AGENTS=2
A memory limit protects the neighbours. Lower concurrency is what keeps the app itself from being killed mid-job.
Secrets in a root-only env file
Settings and keys go in a file under /etc, created with umask 077 and then chmod 600, and passed with --env-file. They are not baked into the image and not in the repo.
Gotcha: the app only listened on 127.0.0.1
The app was written as a desktop tool, so its server binds to 127.0.0.1. Inside a container, that is the container’s own loopback. Docker’s published port forwards to the container’s network interface, not its loopback, so every request from the host was refused even though the app was running.
Upstream hard-codes the address, so I patch it at build time to read an environment variable:
# Upstream binds 127.0.0.1 only (a desktop app); inside a container it must listen on all interfaces.
# Docker still publishes it on the host's 127.0.0.1 only, behind nginx + basic auth.
RUN sed -i "s/app.listen(PORT, '127.0.0.1'/app.listen(PORT, process.env.HOST || '127.0.0.1'/" app/server/index.ts \
&& grep -q "process.env.HOST" app/server/index.ts
The grep -q after the sed is the part worth copying. sed exits successfully even when it matches nothing. If upstream changes that line, the patch would silently stop applying and I would get an image that builds fine and does not work. With the grep, the build fails instead.
Listening on 0.0.0.0 inside the container does not expose the app. The publish flag is -p 127.0.0.1:$PORT:4318, so on the host it is reachable only from the host itself.
Pin the source
The Dockerfile clones the app at one commit, not a branch:
RUN git clone "$REPO" . && git checkout --detach "$REF" && rm -rf .git
REF is a build argument holding a full commit hash. An update is a deliberate REF=<commit> bash setup.sh, after reading what changed. For software that hands a shell to an agent, I do not want whatever was pushed to the main branch this morning.
Login and HTTPS in nginx
The app has no accounts, so nginx adds basic auth for the whole site:
server {
listen 80;
listen [::]:80;
server_name agent.example.com;
# reference files and user assets are uploaded through the app
client_max_body_size 2g;
auth_basic "Agent App";
auth_basic_user_file /etc/nginx/agent-app.htpasswd;
location / {
proxy_pass http://127.0.0.1:8800;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# live progress is Server-Sent Events: no buffering, long-lived connections
proxy_buffering off;
proxy_cache off;
proxy_request_buffering off;
proxy_read_timeout 1d;
proxy_send_timeout 1d;
}
}
The password file is made with openssl passwd -apr1, owned by root:www-data with mode 640, so nginx can read it and other users cannot.
Two details:
- The app streams progress with Server-Sent Events. With nginx’s default buffering, updates arrive in bursts or not at all.
proxy_buffering offand long timeouts fix that.proxy_request_buffering offstops nginx from spooling large uploads to disk before passing them on. - The script writes this file only once.
certbot --nginxthen adds the HTTPS listener and redirect to it. Rewriting it on each run would remove the certificate lines.
Basic auth sends the password with every request, so it is only acceptable over HTTPS. The script checks that the domain resolves to the server before asking certbot for a certificate, and skips that step with a clear message if DNS is not ready.
Checks at the end of the script
The script finishes by testing its own work:
echo "== container: $(docker ps --filter name=^agent-app$ --format '{{.Status}} | {{.Ports}}')"
echo "== app local: health HTTP $(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:$PORT/api/health)"
echo "== nginx vhost without login: HTTP $(curl -s -o /dev/null -w '%{http_code}' -H "Host: $DOMAIN" http://127.0.0.1/) (expect 401, or 301 once HTTPS is on)"
echo "== mem: $(docker stats --no-stream --format '{{.MemUsage}}' agent-app)"
The line I care about most is the third. If the site ever answers without a login, I see it right away.
What this does not protect against
It is a container, not a virtual machine. Some honest limits:
- The agents have full outbound internet access.
- I did not drop Linux capabilities or make the root filesystem read-only here, because the agents write across their workspace. On the same VPS I run a small read-only API bridge with
--read-only --cap-drop ALL --security-opt no-new-privilegesand a 256 MB limit. That level of lockdown fits a service that writes nothing. It does not fit this one without more work. - Chromium runs with
--no-sandboxinside the container, which is how the app launches it on Linux. - Anything in the two mounted folders is fully in the agents’ hands. I keep nothing else there.
For a single-user tool on a personal server, that is a level of risk I accept. The container can be deleted and rebuilt from the script in one command, and the other services never see it.
Checklist
- Default bridge network, not the network your other services share.
- No Docker socket.
- Mount only the app’s own data folders.
- Run as a non-root user. Install tools as root at build time.
- Set
--memoryand--memory-swapto the same value, plus--cpusand--pids-limit. - Add
--initand a larger--shm-sizeif the app drives a headless browser. - Publish on
127.0.0.1and put nginx with a login and HTTPS in front. - Pin the upstream commit, and make build-time patches fail loudly.