Deploying agent-orchestrator on a VPS
Complete deployment guide, from a bare server to the first real pipeline test.
Requirements
- A GitLab account (gitlab.com or self-hosted) with a project you want to point the orchestrator at
- An OpenAI API key
- A VPS running Ubuntu 24.04 or newer, with root access
- A domain name you control (for the webhook endpoint)
Prerequisites
DNS — Add two A records pointing to your VPS's IP:
your-domain.com(orchestrator webhook)preview.your-domain.com(optional, manualnpm run devpreview)
Verify propagation before continuing:
dig +short your-domain.com
Connecting to the server:
ssh-keygen -R <server-ip> # clears any stale host key locally, useful if this IP served a different host before
ssh root@<server-ip>
Step 1 — Base packages + firewall
sudo apt update
sudo apt install -y git curl gnupg
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw enable
sudo ufw status verbose
⚠️
allow OpenSSHmust come beforeenable, or you lose SSH access.
Step 2 — Install Caddy
Not in Ubuntu's default repositories — it needs its own APT repo:
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo chmod o+r /usr/share/keyrings/caddy-stable-archive-keyring.gpg
sudo chmod o+r /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install -y caddy
caddy version
Step 3 — Dedicated system user
sudo useradd -r -m -d /home/agent-orchestrator -s /usr/sbin/nologin agent-orchestrator
-r (system account), -m (creates the home directory uv/OpenCode install into), -d (fixes the path the rest of this guide depends on), -s /usr/sbin/nologin (blocks interactive login — sudo -u agent-orchestrator <command> still works, since it executes directly without going through a login shell).
This isolates the blast radius of a compromised token or a misbehaving agent — a real account, usable via sudo -u, but with no exploitable shell session. One side effect worth knowing upfront: this same nologin setting prevents ~/.bashrc from ever being sourced, by systemd or by sudo -u — anything that activates via a .bashrc line (like nvm, below) needs an explicit workaround.
Step 4 — Install uv and OpenCode for this user
sudo -H -u agent-orchestrator bash -c 'curl -LsSf https://astral.sh/uv/install.sh | sh'
sudo -H -u agent-orchestrator bash -c 'curl -fsSL https://opencode.ai/install | bash'
# Symlink OpenCode to /usr/local/bin so systemd can find it.
# The installer places the binary in ~/.opencode/bin, which is only
# on PATH for interactive shell sessions (via .bashrc). systemd never
# reads .bashrc — without this symlink, subprocess calls to "opencode"
# would fail in production even though a manual terminal test works fine.
sudo ln -s /home/agent-orchestrator/.opencode/bin/opencode /usr/local/bin/opencode
opencode --version
Step 5 — Install Node.js for this user
Needed for OpenCode (and REVIEW_COMMANDS — lint/build) to work on a Next.js/TypeScript target project. Same trap as OpenCode above, worse: nvm activates entirely through a line added to ~/.bashrc, never sourced here. Unlike OpenCode's simple symlink, there's no shortcut — you have to explicitly inject the resolved PATH into the systemd service, in Step 11.
1. Install nvm for the agent-orchestrator user:
sudo -H -u agent-orchestrator bash -c 'curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash'
2. Install Node and set it as default:
sudo -H -u agent-orchestrator bash -c 'source ~/.nvm/nvm.sh && nvm install 24 && nvm alias default 24'
3. Get the exact binary path — keep this for Step 11:
sudo -H -u agent-orchestrator bash -c 'source ~/.nvm/nvm.sh && which node'
Expected output, something like:
/home/agent-orchestrator/.nvm/versions/node/v24.19.0/bin/node
Note the directory (.../bin), without the trailing /node.
Step 6 — Install review tools: semgrep and gitleaks
Two tools REVIEW_COMMANDS can invoke as a deterministic gate before any LLM judgment — same category as uv/OpenCode/Node: tooling for the orchestrator itself, never installed into the target project.
Semgrep, via uv tool install:
sudo -H -u agent-orchestrator bash -c '/home/agent-orchestrator/.local/bin/uv tool install semgrep'
sudo ln -s /home/agent-orchestrator/.local/bin/semgrep /usr/local/bin/semgrep
sudo -H -u agent-orchestrator semgrep --version
Gitleaks, official binary with checksum verification:
VERSION="8.30.1"
BASE="https://github.com/gitleaks/gitleaks/releases/download/v${VERSION}"
curl -fsSLO "${BASE}/gitleaks_${VERSION}_linux_x64.tar.gz"
curl -fsSLO "${BASE}/gitleaks_${VERSION}_checksums.txt"
sha256sum --check --ignore-missing gitleaks_${VERSION}_checksums.txt
tar xzf gitleaks_${VERSION}_linux_x64.tar.gz gitleaks
sudo install -m 0755 gitleaks /usr/local/bin/gitleaks
rm -f gitleaks "gitleaks_${VERSION}_linux_x64.tar.gz" "gitleaks_${VERSION}_checksums.txt"
gitleaks version
Both land in /usr/local/bin — already on systemd's default PATH, no extra config needed.
Step 7 — Clone and install
Generate a GitLab fine-grained access token first, with these permissions:
| Resource | Permissions |
|---|---|
| Code | Push, Read |
| Commit | Create |
| Merge Request | Create, Read, Update |
| Work Item | Read, Update |
| Note | Create |
sudo -H -u agent-orchestrator git clone https://gitlab.com/thibault-chausson/agent-orchestrator.git /home/agent-orchestrator/agent-orchestrator
cd /home/agent-orchestrator/agent-orchestrator
sudo -H -u agent-orchestrator /home/agent-orchestrator/.local/bin/uv sync
Git identity for automated commits (required, once):
sudo -H -u agent-orchestrator git config --global user.email "agent-orchestrator@your-domain.com"
sudo -H -u agent-orchestrator git config --global user.name "Agent Orchestrator Bot"
Step 8 — Langfuse account
Create a Langfuse account and get your credentials for .env.
Wire Langfuse into OpenCode:
sudo -H -u agent-orchestrator mkdir -p /home/agent-orchestrator/.config/opencode
sudo -H -u agent-orchestrator bash -c 'cat > /home/agent-orchestrator/.config/opencode/opencode.json << "EOF"
{
"$schema": "https://opencode.ai/config.json",
"experimental": {
"openTelemetry": true
},
"plugin": ["@langfuse/opencode-observability-plugin@latest"]
}
EOF'
Step 9 — Configure .env
sudo -H -u agent-orchestrator cp .env.example .env
sudo -u agent-orchestrator nano .env
sudo chmod 600 .env
Fill in everything except:
GITLAB_URL— leave blank (defaults togitlab.com)REVIEW_COMMANDS— leave blank for the first test
Step 10 — Authenticate OpenCode
Choose OpenAI as the provider during the interactive login:
sudo -H -u agent-orchestrator opencode auth login
Step 11 — Manual test before systemd
sudo -H -u agent-orchestrator /home/agent-orchestrator/agent-orchestrator/.venv/bin/uvicorn webhook.app:app --app-dir /home/agent-orchestrator/agent-orchestrator --host 127.0.0.1 --port 8000
In another terminal: curl http://127.0.0.1:8000/health → should return {"status":"ok"}. Then Ctrl+C.
Step 12 — Create the systemd service
The PATH below includes the Node directory from Step 5 — replace it with your own value.
/etc/systemd/system/agent-orchestrator.service:
[Unit]
Description=Agent Orchestrator (GitLab issue -> agents -> MR pipeline)
After=network.target
[Service]
Type=simple
User=agent-orchestrator
Environment=PATH=/home/agent-orchestrator/.nvm/versions/node/v24.19.0/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
WorkingDirectory=/home/agent-orchestrator/agent-orchestrator
EnvironmentFile=/home/agent-orchestrator/agent-orchestrator/.env
ExecStart=/home/agent-orchestrator/agent-orchestrator/.venv/bin/uvicorn webhook.app:app --host 127.0.0.1 --port 8000
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now agent-orchestrator
sudo systemctl status agent-orchestrator
journalctl -u agent-orchestrator -f
Step 13 — Expose via Caddy
/etc/caddy/Caddyfile:
your-domain.com {
reverse_proxy /webhook/* 127.0.0.1:8000
reverse_proxy /health 127.0.0.1:8000
}
# Optional — manual `npm run dev` preview of the target repo
preview.your-domain.com {
reverse_proxy 127.0.0.1:5173
}
sudo systemctl reload caddy
curl https://your-domain.com/health
Step 14 — Configure the GitLab webhook
On your target repo → Settings → Webhooks:
- URL:
https://your-domain.com/webhook/gitlab - Secret token: same value as
GITLAB_WEBHOOK_SECRETin.env - Triggers: Work item events + Merge request events + Comments
Step 15 — First real test
Open an issue on your target repo using the user_story.md template with the type::feature label. Watch the logs:
prepare_node → plan_node → develop_node → review_node → supervise_node → git_node
until a merge request appears on GitLab.
Step 16 — Running the tests
sudo -H -u agent-orchestrator /home/agent-orchestrator/.local/bin/uv run pytest tests/ -v