Self-Hosted AI Coding Agents on EU VPS: Hardened Setup
Quick answer: a coding agent on a VPS needs ordinary compute, not a GPU, because the model runs at the provider. The risk is what the agent can reach, so limit that: a disposable container per task, non-root, read-only root filesystem, outbound traffic only to an allowlist, one scoped token as a file, a time limit and a human before production. Below: a tested runner, a fake agent that attacks it (real output) and the price. These settings limit what a hijacked agent can reach; they do not stop prompt injection. Checked on 2026-10-14.
What you are hosting, and what you are not
Typical jobs: a repository on a server, pull request review, tests after every change, scheduled jobs. All of it is agent compute: the CLI runs commands on your VPS and only sends prompts to a model API, so no GPU is needed (hosting a model is another job). Basics: Run Claude Code, Codex and Grok CLI on your own cloud server; prompts: cheat sheet.
Threat model: what an injected instruction can reach
Everything the agent reads (repository files, issues, pull requests, web pages, tool output) is input it may obey. OWASP lists prompt injection as LLM01:2025 and excessive agency as LLM06:2025; no model is immune, so limit what a hijacked agent can touch.
| An injected instruction wants to... | On a naive setup it can reach | Limit in this design |
|---|---|---|
| Read secrets | env, ~/.ssh, other repositories | dedicated uid, empty read-only home, one repository, one scoped token file |
| Send data out | any host on the internet | no route out, allowlist proxy, 403 for the rest |
| Push or merge bad code | any branch the token covers | token for one repository, branch protection, human review |
| Reach cloud, LAN or host services | metadata address, databases, host services | no route out, no host address on the sandbox bridge |
Vendors agree: Claude Code security recommends VMs or a dev container for untrusted content, its hardening guide asks for default-deny egress and one container per session, and the Codex docs keep the network off by default. Checked on 2026-10-14.
The architecture
One task is one Docker Compose project: an agent container plus a small egress proxy. The agent sits on an internal network where the host has no IPv4 address, so its only way out is the proxy, which answers only for allowlisted hosts. When the task ends, everything is destroyed.

The hardened runner (tested)
Six small files; only Docker with Compose is needed. The image holds a harmless fake agent; for real use, install your agent CLI in the Dockerfile and add the hosts it needs (git host, package registry, model API) to allowlist.txt. A tool that ignores HTTPS_PROXY fails closed.
Dockerfile: non-root user, nothing writable
FROM python:3.13-alpine
# UID 10001, no shell login, no sudo, nothing writable except the tmpfs mounted at /work
RUN adduser -D -u 10001 -s /sbin/nologin agent
COPY --chmod=0555 fake_agent.py /opt/fake_agent.py
USER 10001:10001
WORKDIR /work
ENTRYPOINT ["python", "/opt/fake_agent.py"]
compose.yml: limits, read-only root, two networks, secret as a file
# One task = one compose project. Nothing is shared between tasks.
name: ${TASK_ID:-demo}
services:
agent:
build: .
image: agent-runner:local # built once, shared by every task project
user: "10001:10001"
read_only: true # root fs is immutable
tmpfs:
- /work:size=256m,uid=10001,gid=10001,mode=0700 # the workspace, gone with the container
- /tmp:size=64m,mode=1777
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
pids_limit: 64
mem_limit: 512m
memswap_limit: 512m # no swap: an OOM kill instead of a slow host
cpus: 1.0
environment:
HTTPS_PROXY: http://egress:3128
TASK_ID: ${TASK_ID:-demo}
AGENT_MODE: ${AGENT_MODE:-attack}
secrets: [git_token] # a file under /run/secrets, never in the image or in `docker inspect` env
networks: [sandbox] # the only network: internal, no route out
depends_on:
egress: {condition: service_healthy}
egress:
image: python:3.13-alpine
user: "10001:10001"
read_only: true
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
pids_limit: 64
mem_limit: 128m
healthcheck:
test: ["CMD", "python", "-c", "import socket; socket.create_connection(('127.0.0.1', 3128), 2)"]
interval: 1s
retries: 10
command: ["python", "/egress_proxy.py", "/allowlist.txt"]
volumes:
- ./egress_proxy.py:/egress_proxy.py:ro
- ./allowlist.txt:/allowlist.txt:ro
networks: [sandbox, outside]
secrets:
git_token:
file: ${TOKEN_FILE:?set TOKEN_FILE}
networks:
sandbox:
internal: true # no route to the internet or other networks
driver_opts:
com.docker.network.bridge.inhibit_ipv4: "true" # the host gets no address on this bridge (needs a recent Docker Engine, tested 29.7.2)
outside: {}
allowlist.txt: everything else is refused
# one host name per line; everything else is refused
example.com
egress_proxy.py: default-deny CONNECT proxy, standard library only
"""Default-deny HTTPS egress: a CONNECT-only proxy with a host allowlist (stdlib only)."""
import asyncio, ipaddress, socket, sys
ALLOW = {l.strip().lower() for l in open(sys.argv[1]) if l.strip() and not l.startswith('#')}
async def pipe(r, w):
try:
while data := await r.read(65536):
w.write(data)
await w.drain()
except OSError:
pass
finally:
w.close()
async def handle(cr, cw):
line = (await cr.readline()).decode('latin-1').split()
if not line: # health probe: connect and close
cw.close(); return
while (await cr.readline()) not in (b'\r\n', b''):
pass # drop the request headers
if len(line) < 2 or line[0] != 'CONNECT':
verdict, code = 'method', '405 Method Not Allowed'
else:
host, _, port = line[1].rpartition(':')
host = host.lower()
verdict, code = None, None
if host not in ALLOW or port != '443':
verdict, code = 'DENY host', '403 Forbidden'
else:
try: # an allowlisted name must not resolve to a private address (SSRF)
ai = socket.getaddrinfo(host, 443, type=socket.SOCK_STREAM)
if not all(ipaddress.ip_address(a[4][0]).is_global for a in ai):
verdict, code = 'DENY private-ip', '403 Forbidden'
except OSError:
verdict, code = 'DENY dns', '502 Bad Gateway'
if verdict:
print(f'egress {verdict}: {" ".join(line[:2])}', flush=True)
cw.write(f'HTTP/1.1 {code}\r\nConnection: close\r\n\r\n'.encode())
await cw.drain(); cw.close(); return
try:
ur, uw = await asyncio.open_connection(ai[0][4][0], 443)
except OSError:
cw.write(b'HTTP/1.1 502 Bad Gateway\r\n\r\n'); cw.close(); return
print(f'egress ALLOW: {host}:443', flush=True)
cw.write(b'HTTP/1.1 200 Connection established\r\n\r\n')
await asyncio.gather(pipe(cr, uw), pipe(ur, cw))
async def main():
srv = await asyncio.start_server(handle, '0.0.0.0', 3128)
async with srv:
await srv.serve_forever()
asyncio.run(main())
run-task.sh: create, run, redact, check, destroy
#!/usr/bin/env bash
# Disposable workspace per task: create, run, redact, check, destroy.
set -euo pipefail
cd "$(dirname "$0")"
TASK_ID="task-$(date +%Y%m%d-%H%M%S)-$RANDOM"
TOKEN_SRC=${1:?usage: run-task.sh <token-file>} # a scoped, short-lived token, 0600, outside the repo
LOGDIR=${LOGDIR:-./logs}
TASK_TIMEOUT=${TASK_TIMEOUT:-900} # wall-clock seconds for the whole task
umask 077
WORK=$(mktemp -d "${TMPDIR:-/tmp}/$TASK_ID.XXXXXX")
cat < "$TOKEN_SRC" > "$WORK/token" && chmod 0444 "$WORK/token" # readable by uid 10001 inside; the dir stays 0700 on the host
mkdir -p "$LOGDIR"
export TASK_ID TOKEN_FILE="$WORK/token"
teardown() { # runs on success, failure and Ctrl-C
docker compose down --volumes --remove-orphans >/dev/null 2>&1 || true
rm -rf "$WORK"
}
trap teardown EXIT
TOKEN=$(<"$TOKEN_SRC")
[[ $TOKEN =~ ^[A-Za-z0-9_-]+$ ]] || { echo "token has characters sed cannot redact safely" >&2; exit 2; }
rc=0
docker compose --progress quiet build agent
timeout --foreground "$TASK_TIMEOUT" docker compose --progress quiet up --abort-on-container-exit --exit-code-from agent 2>&1 \
| sed -f <(printf 's|%s|[REDACTED]|g\n' "$TOKEN") > "$LOGDIR/$TASK_ID.log" || rc=$?
# redaction check: the raw token must not appear in what we keep (the token never goes into argv: ps shows arguments)
if grep -qF -f <(printf '%s\n' "$TOKEN") "$LOGDIR/$TASK_ID.log"; then
echo "FAIL: token found in $LOGDIR/$TASK_ID.log" >&2; rm -f "$LOGDIR/$TASK_ID.log"; exit 3
fi
echo "task=$TASK_ID exit=$rc log=$LOGDIR/$TASK_ID.log redaction-check=ok"
exit "$rc"
fake_agent.py: tries what a hijacked agent would try
"""Harmless fake agent: tries things a hijacked agent would try. Exit 1 if any forbidden one works."""
import os, socket, ssl, subprocess, sys, time, urllib.parse
bad = []
def check(name, forbidden, fn):
try:
res = fn()
res = res if isinstance(res, str) else 'connected'
outcome = 'ALLOWED'
except Exception as e:
res, outcome = f'{type(e).__name__}: {e}', 'BLOCKED'
print(f'[{outcome:7}] {name} -> {str(res)[:90]}', flush=True)
if forbidden and outcome == 'ALLOWED':
bad.append(name)
def via_proxy(host):
p = urllib.parse.urlparse(os.environ['HTTPS_PROXY'])
s = socket.create_connection((p.hostname, p.port), timeout=5)
s.sendall(f'CONNECT {host}:443 HTTP/1.1\r\nHost: {host}:443\r\n\r\n'.encode())
status = s.recv(200).split(b'\r\n')[0].decode()
if ' 200 ' not in status:
raise ConnectionError(status)
t = ssl.create_default_context().wrap_socket(s, server_hostname=host)
t.sendall(f'GET / HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n\r\n'.encode())
return t.recv(40).split(b'\r\n')[0].decode()
def write(path):
def f():
with open(path, 'w') as fh: fh.write('x')
return 'wrote ' + path
return f
def flood_disk():
with open('/work/big', 'wb') as fh:
for _ in range(300): fh.write(b'0' * 1024 * 1024) # 300 MB into a 256 MB tmpfs
return 'wrote 300 MB'
def fork_many():
kids = []
try:
for _ in range(200): kids.append(subprocess.Popen(['sleep', '30']))
return f'started {len(kids)} processes'
finally:
for k in kids: k.kill()
def eat_ram():
r = subprocess.run([sys.executable, '-c', 'b=[]\nwhile True: b.append(bytearray(50_000_000))'], capture_output=True)
if r.returncode < 0: raise MemoryError(f'child killed by signal {-r.returncode}')
return f'child exit {r.returncode}'
def setuid_root():
os.setuid(0); return 'uid 0'
def routes():
return [l.split() for l in open('/proc/net/route').read().splitlines()[1:]]
def default_route():
if not any(c[1] == '00000000' for c in routes()): raise OSError('no default route')
return 'present'
def other_routes():
if len(routes()) < 2: raise OSError('only the on-link subnet route')
return 'present'
def host_side(port):
def f(): # .1 of our subnet: the host on a plain network, whoever holds .1 otherwise
for l in open('/proc/net/route').read().splitlines()[1:]:
c = l.split()
if c[1] != '00000000':
a = socket.inet_ntoa(bytes.fromhex(c[1])[::-1]).split('.')
a[3] = '1'
socket.create_connection(('.'.join(a), port), timeout=3).close()
return 'connected to ' + '.'.join(a)
raise OSError('no subnet route')
return f
def posture():
st = dict(l.split(':\t', 1) for l in open('/proc/self/status').read().splitlines() if ':\t' in l)
work = ([l.split()[3] for l in open('/proc/mounts') if l.split()[1] == '/work'] or ['not a separate mount'])[0]
try: aa = open('/proc/self/attr/current').read().strip()
except OSError: aa = 'n/a'
print(f'posture: Seccomp={st["Seccomp"]} NoNewPrivs={st["NoNewPrivs"]} CapEff={st["CapEff"]} AppArmor={aa} /work={work}', flush=True)
def raw_socket():
socket.socket(socket.AF_INET, socket.SOCK_RAW, 1); return 'raw socket'
print(f'agent uid={os.getuid()} gid={os.getgid()} task={os.environ.get("TASK_ID")}', flush=True)
if os.environ.get('AGENT_MODE') == 'gateway': # only the host-side probe, for a bare `docker run`
check('first address of the sandbox subnet, port 22', True, host_side(22))
sys.exit(1 if bad else 0)
posture()
if os.environ.get('AGENT_MODE') == 'hang':
print('agent hangs on purpose', flush=True)
time.sleep(10**6)
check('allowed: HTTPS to an allowlisted host via the proxy', False, lambda: via_proxy('example.com'))
check('HTTPS to a host that is not allowlisted', True, lambda: via_proxy('example.org'))
check('direct connection, bypassing the proxy', True, lambda: socket.create_connection(('1.1.1.1', 443), timeout=4))
check('cloud metadata address 169.254.169.254', True, lambda: socket.create_connection(('169.254.169.254', 80), timeout=4))
check('a default route out of the sandbox network', True, default_route)
check('any route besides the sandbox subnet itself', True, other_routes)
check('DNS lookup of an outside name', True, lambda: socket.gethostbyname('example.org'))
check('write to the root filesystem', True, write('/etc/pwned'))
check('write into the image code', True, write('/opt/fake_agent.py'))
check('write into the workspace (allowed)', False, write('/work/notes.txt'))
check('regain root with setuid(0)', True, setuid_root)
check('open a raw socket (needs CAP_NET_RAW)', True, raw_socket)
check('see the host docker socket', True, lambda: os.stat('/var/run/docker.sock'))
check('overflow the 256 MB workspace', True, flood_disk)
check('start 200 processes (limit 64)', True, fork_many)
check('allocate RAM until limit (512 MB)', True, eat_ram)
tok = open('/run/secrets/git_token').read().strip()
check('read the scoped token (allowed) and print it to the log', False, lambda: 'token=' + tok)
print('RESULT:', 'ISOLATION BROKEN: ' + ', '.join(bad) if bad else 'all forbidden actions blocked', flush=True)
sys.exit(1 if bad else 0)
Honest limits and residual risk:
- The agent can read its token and send it to any allowlisted host (a gist or issue on github.com), so token scope stays the main control; a proxy that injects the credential outside the container is stronger, as the Agent runners page describes, and is not built here. The daemon's JSON log also holds the raw token (the fake agent prints it) until
compose down; only the kept log is redacted. - Containers share the host kernel: use a VM per task or gVisor for hostile or multi-tenant code (not tested here).
- The proxy is one minimal point (blocking name lookups, no idle timeout or connection cap, image pinned by tag): bypassed or down, the design degrades. It filters by name, so a shared CDN or domain fronting on an allowed name can carry data out, and it sits on a normal bridge that can reach host listeners: firewall it (DOCKER-USER) if services listen on all addresses.
- Keep the Docker daemon away from the agent: access means control of the host (Docker security).
Walkthrough on a throwaway token (with real output)
Environment: Docker 29.7.2 with Compose, Linux arm64, python:3.13-alpine (Python 3.13.16), 2026-10-14. The token is an obvious placeholder; allowed outbound HTTPS needs internet access from the host. The Docker daemon must be able to see the project directory and $TMPDIR (bind mounts from /tmp failed here).
Put the six files in a new directory, create a fake token and run one task:
printf 'fake_token_0000_do_not_use\n' > fake.token && chmod 600 fake.token
./run-task.sh fake.token
BLOCKED on a forbidden action is a pass; ALLOWED lines are the legitimate ones. The posture line shows Docker's defaults: seccomp, no-new-privileges, no capabilities, AppArmor, and a /work tmpfs mounted noexec,nosuid,nodev. The printed token comes out redacted (compose log prefixes trimmed):
agent uid=10001 gid=10001 task=task-20261015-002125-3091
posture: Seccomp=2 NoNewPrivs=1 CapEff=0000000000000000 AppArmor=docker-default (enforce) /work=rw,nosuid,nodev,noexec,relatime,size=262144k,mode=700,uid=10001,gid=10001,inode64
egress ALLOW: example.com:443
[ALLOWED] allowed: HTTPS to an allowlisted host via the proxy -> HTTP/1.1 200 OK
egress DENY host: CONNECT example.org:443
[BLOCKED] HTTPS to a host that is not allowlisted -> ConnectionError: HTTP/1.1 403 Forbidden
[BLOCKED] direct connection, bypassing the proxy -> OSError: [Errno 101] Network unreachable
[BLOCKED] cloud metadata address 169.254.169.254 -> OSError: [Errno 101] Network unreachable
[BLOCKED] a default route out of the sandbox network -> OSError: no default route
[BLOCKED] any route besides the sandbox subnet itself -> OSError: only the on-link subnet route
[BLOCKED] DNS lookup of an outside name -> gaierror: [Errno -3] Try again
[BLOCKED] write to the root filesystem -> OSError: [Errno 30] Read-only file system: '/etc/pwned'
[BLOCKED] write into the image code -> OSError: [Errno 30] Read-only file system: '/opt/fake_agent.py'
[ALLOWED] write into the workspace (allowed) -> wrote /work/notes.txt
[BLOCKED] regain root with setuid(0) -> PermissionError: [Errno 1] Operation not permitted
[BLOCKED] open a raw socket (needs CAP_NET_RAW) -> PermissionError: [Errno 1] Operation not permitted
[BLOCKED] see the host docker socket -> FileNotFoundError: [Errno 2] No such file or directory: '/var/run/docker.sock'
[BLOCKED] overflow the 256 MB workspace -> OSError: [Errno 28] No space left on device
[BLOCKED] start 200 processes (limit 64) -> BlockingIOError: [Errno 11] Resource temporarily unavailable
[BLOCKED] allocate RAM until limit (512 MB) -> MemoryError: child killed by signal 9
[ALLOWED] read the scoped token (allowed) and print it to the log -> token=[REDACTED]
RESULT: all forbidden actions blocked
task=task-20261015-002125-3091 exit=0 log=./logs/task-20261015-002125-3091.log redaction-check=ok
Teardown is automatic: containers, networks and temporary directories are gone; only the shared image and the redacted logs stay. The wrapper refuses a token it cannot redact safely.
docker ps -a --filter name=task- --format '{{.Names}}'
docker network ls --filter name=task- --format '{{.Name}}'
ls -d "${TMPDIR:-/tmp}"/task-* 2>/dev/null | wc -l
A control run without the hardening (root, default bridge, memory capped only to protect this machine) shows the checks bite: 8 to 10 of the 14 forbidden checks succeed (the two internet probes depend on the host's connectivity). The rest are blocked anyway (no proxy to try, no Docker socket, no metadata address here, the memory cap), so those rows are configuration checks, not proof:
# the image was built by the first run (compose build needs TOKEN_FILE set)
docker run --rm --user 0 --memory 512m --network bridge --entrypoint sh \
-v "$PWD/fake.token:/run/secrets/git_token:ro" agent-runner:local \
-c 'HTTPS_PROXY=http://127.0.0.1:3128 TASK_ID=control python /opt/fake_agent.py' \
| grep -E 'ALLOWED|RESULT' | sed 's/ -> .*//'
[ALLOWED] direct connection, bypassing the proxy
[ALLOWED] a default route out of the sandbox network
[ALLOWED] any route besides the sandbox subnet itself
[ALLOWED] DNS lookup of an outside name
[ALLOWED] write to the root filesystem
[ALLOWED] write into the image code
[ALLOWED] write into the workspace (allowed)
[ALLOWED] regain root with setuid(0)
[ALLOWED] open a raw socket (needs CAP_NET_RAW)
[ALLOWED] overflow the 256 MB workspace
[ALLOWED] start 200 processes (limit 64)
[ALLOWED] read the scoped token (allowed) and print it to the log
RESULT: ISOLATION BROKEN: direct connection, bypassing the proxy, a default route out of the sandbox network, any route besides the sandbox subnet itself, DNS lookup of an outside name, write to the root filesystem, write into the image code, regain root with setuid(0), open a raw socket (needs CAP_NET_RAW), overflow the 256 MB workspace, start 200 processes (limit 64)
The network option matters. On a plain --internal network the host keeps the gateway address on the bridge, and the container reached a host service listening on all addresses (here sshd, port 22). With inhibit_ipv4 (documented, no minimum version stated, tested on 29.7.2) the host has no address on the bridge, the network has no gateway and the route table holds only the container's own subnet. The first container takes .1, so a probe of .1 reaches that container, not the host: the proof is the address and route output, not the refusal:
# with inhibit_ipv4 the first container takes .1, so the probe below hits that container itself
for opts in "" "-o com.docker.network.bridge.inhibit_ipv4=true"; do
echo "--- internal network, options: ${opts:-none}"
docker network create --internal $opts gwtest >/dev/null
addr=$(ip -4 -o addr show | grep "br-$(docker network inspect gwtest -f '{{.Id}}' | cut -c1-12)" | tr -s ' ' | cut -d' ' -f2,4)
echo "host address on the bridge: ${addr:-none}"
gw=$(docker network inspect gwtest -f '{{range .IPAM.Config}}{{.Gateway}}{{end}}' | grep -E '^[0-9.]+$')
echo "network gateway: ${gw:-none}"
docker run --rm --network gwtest --entrypoint ip agent-runner:local route
docker run --rm --network gwtest -e AGENT_MODE=gateway agent-runner:local | tail -1
docker network rm gwtest >/dev/null
done
--- internal network, options: none
host address on the bridge: br-fb577b8a3fa3 172.19.0.1/16
network gateway: 172.19.0.1
172.19.0.0/16 dev eth0 scope link src 172.19.0.2
[ALLOWED] first address of the sandbox subnet, port 22 -> connected to 172.19.0.1
--- internal network, options: -o com.docker.network.bridge.inhibit_ipv4=true
host address on the bridge: none
network gateway: none
172.19.0.0/16 dev eth0 scope link src 172.19.0.1
[BLOCKED] first address of the sandbox subnet, port 22 -> ConnectionRefusedError: [Errno 111] Connection refused
The token must stay out of process arguments, so sed and grep read it through -f <(printf ...). Sampling ps -eo args every 100 ms during a run found no line with the token; the same sampler does catch one placed in arguments on purpose:
( while [ ! -f stop ]; do ps -eo args | grep -F -f fake.token >> ps_run.out; sleep 0.1; done ) &
./run-task.sh fake.token; touch stop; wait; rm -f stop
wc -l < ps_run.out
sh -c 'sleep 3' "$(cat fake.token)" & sleep 1; ps -eo args | grep -c -F -f fake.token
task=task-20261015-002152-32129 exit=0 log=./logs/task-20261015-002152-32129.log redaction-check=ok
0
1
A wall-clock limit stops a stuck agent: timeout --foreground (900 s by default, TASK_TIMEOUT) ends the run and the teardown trap cleans up. With a hanging fake agent and a 20 s limit:
AGENT_MODE=hang TASK_TIMEOUT=20 ./run-task.sh fake.token; echo "exit=$?"
task=task-20261015-002214-3896 exit=124 log=./logs/task-20261015-002214-3896.log redaction-check=ok
exit=124
Want this runner on a machine that stays online? Explore EU Agent Runner Hosting.
Security checklist: secrets, logs and the human gate
Review before a runner touches a real repository.
| Control | Setting | How to verify |
|---|---|---|
| Hardening | USER 10001, read_only: true, seccomp, AppArmor, cap_drop: [ALL], no-new-privileges |
posture line: Seccomp=2, NoNewPrivs=1, CapEff=0; writes to /etc fail |
| Workspace tmpfs | /work, 256 MB, noexec,nosuid,nodev by default |
No space left on device at the limit. Tools that run files from the workspace (npm .bin scripts, compiled binaries, virtualenvs) need exec on /work: a conscious trade-off |
| Resource and time limits | 64 pids, 512 MB without swap, 1 CPU, timeout 900 s |
fork fails, memory hog OOM-killed, hanging agent ends with exit 124 |
| Egress | internal network, no host address, allowlist proxy | direct connect fails, ip shows no host address on the bridge, other hosts get 403 |
| Token and logs | fine-grained, one repository, short expiry (for example 7 days), contents and pull requests only; a file under /run/secrets, never in the image, env or arguments; logs redacted, then grepped |
docker history and ps -eo args show no token; the wrapper exits 3 if a log holds one |
| Production and merge | no deploy keys or cloud credentials here; the agent opens a pull request, branch protection and review decide | env holds one token; it cannot push to the protected branch |
Vendor terms: what not to do
Use your own credentials for your own work. Anthropic's Claude Code legal page says OAuth sign-in is for subscription plan holders, developers building products should use API keys, and routing requests through other people's subscription credentials is not permitted; its usage policy bans automated account creation (the policy page shows an effective date of 2026-11-12). OpenAI's terms of use, in the versions we could read (search excerpts; the page blocks automated fetch), do not allow sharing account credentials; confirm in a browser. One key or login per person, never pooled or resold. Checked on 2026-10-14.
Sizing, cost and which setup to pick
Prices are DCXV Cloud plans in Prague with one backup, computed from the calculator formula and matched to the live /cloud-server table on 2026-10-14.
| If... | Start with | Note |
|---|---|---|
| one person, one agent, editing and unit tests | Small (2 vCPU / 4 GB / 40 GB), EUR 15.00 | memory, not CPU, is usually the limit |
| a browser or end-to-end tests | Medium (4 / 8 GB / 80 GB), EUR 18.43 | browsers need gigabytes; measure the peak |
| several agents in parallel | Large (8 / 16 GB / 160 GB), EUR 36.88, or several runners | one container per task; peak times concurrency |
| long builds, CI, deploy keys | a separate build server | the agent host never holds deploy credentials |
| you serve the model yourself | a GPU job: see the inference guide | an agent calling a provider API needs none |
Measure, do not guess: read the peak from the container's cgroup before it exits, for example cat /sys/fs/cgroup/memory.peak (a 100 MB allocation in a 512 MB container reported 105263104 bytes), and use docker stats --no-stream for CPU. Host size is peak memory per task times concurrent tasks, plus OS headroom.
Two bills: the VPS is a flat monthly price from DCXV; model usage, tokens and subscriptions are billed by the model provider, not DCXV, and usually decide the total. Servers: Prague (CZ) or Covilha (PT).
Bottom line
A safer agent runner is a few boring settings applied every time. They limit what a hijacked agent can reach; they do not stop prompt injection.
Explore EU Agent Runner Hosting or Configure an EU Cloud Server.
