English · Français
📖 Prefer a visual guide? An online step-by-step tutorial mirrors this README with the screenshots (FR / EN, light/dark theme).
🎬 Prefer watching? A video walkthrough runs the whole install end to end — cloning the repository, then
make all(commentary in French).
Install and expose opencode (an open-source, command-line / web AI coding agent) on a small VPS, reachable only from your own devices through an SSH tunnel — never exposed to the public internet. The result: you code with opencode from your Mac or another computer, with the same projects and history, without installing anything server-side beyond ssh.
This repository contains a Makefile that automates the whole install on a fresh Ubuntu VPS, pausing at the steps that need a manual action (create an account, click a link in a browser…). It was written and tested on a Gandi VPS, but depends on nothing specific to that host: any Ubuntu 24.04 VPS with root/sudo access will do — for example Hetzner Cloud, DigitalOcean, Vultr, Scaleway, OVHcloud or Linode/Akamai. Clone it and adapt it to reproduce the same install elsewhere. Only the gandi-firewall step is named after Gandi: it merely reminds you to close the public ports in your host console's cloud firewall (every provider has one) — the real hardening happens on the VPS via ufw, independent of the host.
- No public exposure: no HTTP/HTTPS port open to the internet. Access is exclusively through an SSH tunnel (local port forwarding), with app-level authentication on top.
- Lightweight: a 1 vCPU / 2 GB RAM VPS is enough — opencode only makes outbound API calls to an LLM, it doesn't need to run any model locally (no GPU, no big machine). See the FAQ for a ballpark of the costs.
- Simple: a single access mechanism (SSH), no third-party daemon, no external account to manage.
- Reproducible: everything is scripted (
Makefile+ a verification script), no undocumented manual configuration. - Verifiable: a test suite (
make test) checks the server's actual state afterwards, including the security points that are easiest to misconfigure (see below).
Your device (Mac / another computer)
│ SSH tunnel (local port forwarding, `make tunnel`)
▼
Ubuntu 24.04 VPS
├─ ufw : only the SSH port (22 by default) is open publicly, everything else is closed
├─ fail2ban : bans IPs that brute-force SSH (the only public port)
└─ opencode web : listens locally (127.0.0.1) only, never exposed directly
The VPS itself has no application port open to the internet — only SSH (22 by default) is, for administration and for reaching opencode. All traffic to opencode goes through the SSH tunnel, encrypted end to end.
- An Ubuntu 24.04 VPS, any host will do, with your SSH key already installed (root/sudo access).
- Passwordless
sudofor that SSH user. Every target runs its commands over a non-interactivessh <command>, where asudopassword prompt cannot be answered (sudo: a terminal is required to read the password). Ubuntu cloud images set this up by default for theubuntuuser, so there is usually nothing to do — but a VPS installed by hand, with a user you created yourself, needs it (sudo visudo→yourname ALL=(ALL) NOPASSWD:ALL). This is also why reaching opencode means root on the VPS: see Security. make,ssh,scpandopensslavailable locally (already present on macOS/Linux). During the install,opensslruns on your machine: it generates theopencode webpassword, which is then sent to the VPS over the SSH connection — never asked from the VPS itself, never written to a file on your side. (The password-rotation recipe in the FAQ is the one exception: you are already logged in on the VPS there, soopensslruns on it.)
git clone https://github.com/StevenMorlier/opencode_vps_install.git
cd opencode_vps_install
make all🎬 These are exactly the commands the video walkthrough runs on a fresh VPS, if you'd rather watch the install than read it.
On the first run, make all asks for your VPS address, the SSH user (default ubuntu), the opencode web port (default 4096) and optionally a GitHub owner (used later by make github-deploy-key), then stores them in config.local.mk — so every later command is just make tunnel, make test… with nothing to retype. That file is git-ignored: it holds your real IP, and it never contains a password. To change the settings later, run make config. To skip the questions entirely, pass the values yourself — the command line always wins:
make all VPS_HOST=<vps-ip> VPS_USER=ubuntuThe pipeline pauses at every step that needs an action from you (open a link, tick a box in a web console) and prints exactly what to do before continuing. Budget 15 to 25 minutes, most of it in the security-updates step — see the FAQ if it looks stuck.
ℹ️ The
Makefileandmake testspeak English only — one language for the tooling, so the repository stays usable by everyone. A French translation of this README lives in README.fr.md.
Then, to connect:
make tunnelThis command stays in the foreground (Ctrl+C to close) and opens the tunnel. Once connected, open http://127.0.0.1:4096 in your browser — the username is opencode, and make all prints the password again in its closing recap, along with the URL to open. make password prints it back at any time.
| Step | What it does |
|---|---|
check-host |
Checks that SSH access to VPS_USER@VPS_HOST works before anything else runs |
gandi-firewall |
Reminder of the settings to make in your host's console (cloud firewall) |
harden |
ufw: default-deny incoming set explicitly, the SSH port you are connected through left open, everything else blocked + SSH password auth disabled (key only) |
fail2ban |
Installs fail2ban + an sshd jail that bans IPs brute-forcing SSH (Ubuntu doesn't ship it by default) |
swap |
Adds a 2 GB swap file (safety net on a low-RAM VPS) + a persistent vm.swappiness=10 |
security-updates |
Enables automatic security updates (already the Ubuntu default) + asks whether automatic reboot should be enabled (recommended, scheduled at 04:00; otherwise AUTO_REBOOT=yes|no to answer without a prompt), then runs a first upgrade pass so the VPS starts up to date (10–15 min on a fresh image) |
opencode-install |
Installs opencode via the official script |
opencode-web-service |
Deploys opencode web as a systemd service, bound to 127.0.0.1 only, generated password. Re-running it generates a new password and restarts the service so it takes effect — the one printed is always the one that works |
test |
End-to-end verification suite (see below) |
Each step can also be run on its own (make harden, etc.) — handy for adjusting one point without replaying everything. make help lists the main targets.
Two targets are deliberately not part of make all: make password prints the opencode web password back (read from the VPS — useful when you didn't write it down, see the FAQ), and make restart restarts the opencode-web service. You need the latter after editing /etc/opencode-web.env — e.g. changing the password — because a systemd EnvironmentFile= is only read at startup.
The SSH tunnel isn't limited to a Mac: any mobile SSH client that supports local port forwarding works too, for example Termius. In Termius: Vaults → Port Forwarding → +, then fill in a rule pointing at your VPS (SSH host already configured in the app), with Local Port / Destination port number = 4096 (or your OC_PORT value) and Destination address = 127.0.0.1:
Once the rule is active, open http://127.0.0.1:4096 in Safari, just like on a Mac.
This repository only covers the VPS side — the AI model's price is opencode's and your API provider's domain, not this setup, and you can point opencode at whatever plan you like. As a rough example only, the author's personal setup:
- Gandi VPS: ~€8/mo (1 vCPU / 2 GB RAM).
- opencode: an "opencode Go" subscription at ~€5/mo, with the GLM 5.2 model.
These are personal choices given as an example, not a recommendation nor a required minimum: any Ubuntu 24.04 VPS and any opencode-compatible model provider will do.
It almost certainly isn't. A cloud image is often several months old, so the first unattended-upgrade pass has 150+ security updates to install, one package at a time, on 1 vCPU. Count 10 to 15 minutes for that single step: a few silent minutes first (it is resolving dependencies at 100% CPU, printing nothing), then a long scroll of apt output. A ... still working (MM:SS elapsed) line every 30 seconds shows the step is alive through the quiet stretches.
Nothing in there is ever asking you a question. The last lines printed before the silence are usually Initial blacklist: and Initial whitelist (not strict): — two log lines ending in a colon with nothing after it, because no package filter is set (that empty pair is the Ubuntu default, and the right one: a blacklisted package would stop receiving security fixes). They look exactly like an unanswered prompt, but unattended-upgrade is non-interactive by design, and every target here runs over ssh <command> with no terminal attached — nothing could prompt you. Don't type anything and don't Ctrl+C: let it run.
Messages like tar: Exiting with failure status due to previous errors, Package cloud-init has conffile prompt and needs to be upgraded manually or Could not figure out development release are normal — unattended-upgrade skips the packages that would need an interactive answer, and the rest goes through. What matters is the make test at the end: no security update (-security) pending is the line that says the VPS is up to date.
To watch it from another terminal:
ssh ubuntu@<ip> 'sudo tail -f /var/log/unattended-upgrades/unattended-upgrades-dpkg.log'make tunnel must stay open in the foreground in a terminal for the whole session: if it closed (Mac asleep, Wi-Fi/5G network change, terminal closed), the page becomes unreachable. Just re-run make tunnel. The tunnel already sends keepalives (ServerAliveInterval=30); if it still drops, it's almost always machine sleep or a network switch, not the VPS.
Local port 4096 is already taken — often an old tunnel left open, or a tunnel to another VPS. Find the process with lsof -iTCP:4096 -sTCP:LISTEN then close it, or just use another local port:
make tunnel LOCAL_PORT=5000 # then open http://127.0.0.1:5000LOCAL_PORT only changes your end of the forward; the VPS keeps listening on OC_PORT. Two tunnels to two different VPSes can then run side by side.
They aren't on this VPS — nothing is copied between servers. That list is your browser's memory, not the server's: the opencode web UI stores its state (recent projects, theme, language) in localStorage, which the browser scopes to the origin — and http://127.0.0.1:4096 is the same origin for every VPS you tunnel to. A second VPS reached on the same local port inherits the first one's list. Cmd+Shift+R doesn't help: a hard reload clears the HTTP cache, never localStorage.
Clicking one of those stale entries makes the VPS you are connected to open a path it doesn't have, which is where the confusing empty sessions come from.
To clear it, in the page's DevTools console:
localStorage.clear(); location.reload()To avoid it, give each VPS its own local port — a different port is a different origin, so each keeps its own state:
make tunnel # VPS A on http://127.0.0.1:4096
make tunnel LOCAL_PORT=4097 # VPS B on http://127.0.0.1:4097Working from two checkouts (one per VPS)? Put LOCAL_PORT = 4097 in the second one's config.local.mk and make tunnel alone does the right thing.
Lost it? It isn't gone. Nothing is stored on your side, but the VPS still has it: the service reads it from /etc/opencode-web.env (600 root:root) at every start, so it's there in clear text. Print it back:
make passwordThe username to enter in the browser is opencode. This only reads — your current browser session keeps working.
Want to change it? That's a different operation: it invalidates the old password, so any open session has to log in again.
ssh ubuntu@<ip>
NEW=$(openssl rand -base64 18); echo "New password: $NEW"
printf 'OPENCODE_SERVER_PASSWORD=%s\n' "$NEW" | sudo install -m 600 /dev/stdin /etc/opencode-web.env
exit
make restart # a systemd EnvironmentFile= is only re-read on (re)startWrite down the password shown. (Re-running make opencode-web-service also generates a new one, along with redeploying the unit.)
Check the exact cause:
ssh ubuntu@<ip> 'systemctl status opencode-web.service; journalctl -u opencode-web.service -n 50 --no-pager'Common causes: opencode-install not run yet (missing binary), /home/ubuntu/projects folder missing (WorkingDirectory), or /etc/opencode-web.env missing. After fixing: make restart then make test.
Each line points to the precise problem and, when possible, the command to fix it. Re-run the relevant target (make harden, make security-updates, make opencode-web-service…) then re-run make test. A WARN is advisory (e.g. no swap, auto-reboot deliberately off); a FAIL makes the script exit non-zero.
harden disables password authentication: only your SSH key is accepted afterwards. Make sure your public key is in ~/.ssh/authorized_keys on the VPS before running harden, or you risk locking yourself out. If that already happened, reinstall your key via your host's rescue console (KVM / VNC), then re-run make harden.
| Risk | Mitigation |
|---|---|
| Scan/brute-force from the internet | No public application port — SSH only (key, no password); fail2ban bans IPs that hammer the SSH port |
| Compromised SSH (private-key theft) | SSH is now the only way in — harden disables password auth (key only) and then asserts the effective sshd -T value: its drop-in is ordered ahead of the one cloud images ship (sshd keeps the first value it reads), and the step fails loudly rather than reporting success if anything still re-enables it; put a passphrase on your private key |
| A third party with SSH access reaching the opencode interface | Dedicated app password (OPENCODE_SERVER_PASSWORD), independent of the tunnel |
| Server compromise → access to your GitHub account | Deploy keys per repo, never a key/token granting access to the whole account |
| Vulnerable system packages | Automatic security updates (always on); scheduled reboot if needed, your choice (make security-updates AUTO_REBOOT=yes|no) |
| Reaching opencode = root on the VPS | Accepted by design, not mitigated. opencode runs as VPS_USER (passwordless sudo on Ubuntu cloud images) and executes arbitrary shell commands — that is what the product is. Anyone past the app password, any local process on the VPS (127.0.0.1:4096 is reachable by every user of the machine), or a prompt injection from a cloned repo, effectively has root. Hence the absence of any public exposure: the app password is a second lock behind the tunnel, not a sandbox. Don't share the VPS with users you wouldn't give root to |
make github-deploy-key REPO=my-repo [OWNER=my-github-user]Generates an SSH key dedicated to REPO on the VPS (~/.ssh/github_deploy_<repo>) and prints the public key to add as a Deploy Key on that specific repo (Settings → Deploy keys → Add deploy key). GitHub refuses to let the same public key be a deploy key on several repos at once (error key already in use): that's intentional on GitHub's side, it limits each key's blast radius to a single repo. For a new repo, re-run the command with a different REPO= — no need to regenerate or replace the existing key. An SSH alias (github.com-<repo>) is added to ~/.ssh/config so that git clone git@github.com-<repo>:<owner>/<repo>.git automatically uses the right key. GitHub's own host key is checked against the ed25519 fingerprint GitHub publishes before it goes into the VPS's known_hosts — not trusted on first sight — so a machine-in-the-middle gets rejected instead of pinned; if it ever mismatches, the target stops and prints both fingerprints. The VPS then only has access to the repos where a key was explicitly added — never to a whole GitHub account. gh (the GitHub CLI) is not needed to clone/push; install it only if you want to manage PRs/issues from the command line.
This target is interactive and asks you up to two questions:
- If a key already exists for
REPO: keep it, or generate a new one. Generating a new one deletes the old key pair on the VPS — the Deploy Key already registered on GitHub then no longer matches and must be replaced by hand. Choose this only if that's what you want. - After printing the public key: clone
REPOonto the VPS right away (into~/projects/), or stop and get thegit clonecommand to run yourself later. Option 1 only works once you have actually added the key on GitHub, otherwise the clone fails withPermission denied. PassOWNER=<github-user-or-org>on the command line to skip the owner prompt.
make testDeploys and runs test-vps.sh on the VPS: firewall (only SSH open), SSH config, fail2ban (sshd jail), swap, auto-updates, opencode, systemd service (loopback only), permissions on /etc/opencode-web.env (600 root:root, since it holds the app password in clear text), app authentication. OK/FAIL/WARN output line by line, non-zero exit code if a FAIL is detected.
| File | Role |
|---|---|
Makefile |
Automates the whole install, target by target |
test-vps.sh |
Verification suite deployed and run on the VPS by make test |
config.local.mk |
Your VPS address, SSH user, port and optional GitHub owner, written by make config. Git-ignored, created on first run — never committed |
opencode-web.service.template |
systemd service template for opencode web — @VPS_USER@ and @OC_PORT@ are substituted at deploy time by make opencode-web-service |
docs/ |
Source of the online tutorial (index.html), self-contained, no external asset |
docs/images/ |
Screenshots — a single set, shared by the tutorial page and both READMEs |
.github/workflows/deploy-ovh.yml |
Publishes docs/ to the web host over SFTP on every push to main that touches it |
SECURITY.md |
How to report a vulnerability, and what is deliberately not protected |
CONTRIBUTING.md |
Conventions and the checks to run before opening a PR |
LICENSE |
MIT license |
💡 Tip: this repository is public — keep your own install log (IPs, names, real repos) outside this repo, e.g. in a local, unversioned file. These aren't secrets as such (no password or key is ever written in clear text in the code — see the Security section), but there's no point publishing personally identifiable information needlessly.
make config stores VPS_HOST, VPS_USER, OC_PORT and OWNER in config.local.mk, which the Makefile includes automatically. Precedence is command line > config.local.mk > defaults, so you can always override a stored value for one command:
make test VPS_HOST=1.2.3.4 VPS_USER=debian # this run only, nothing is rewrittenThe full list:
| Variable | Default | Role |
|---|---|---|
VPS_HOST |
(asked on first run) | VPS IP or hostname |
VPS_USER |
ubuntu |
SSH user |
OC_PORT |
4096 |
Port opencode web listens on, on the VPS (loopback only) |
LOCAL_PORT |
(same as OC_PORT) |
Port on your machine that make tunnel forwards from. Give a second VPS its own value (make tunnel LOCAL_PORT=4097, or a LOCAL_PORT = 4097 line in config.local.mk) — see the FAQ for why that matters, and it lets two tunnels run at once |
SSH_OPTS |
-o ConnectTimeout=8 |
Extra options passed to every ssh/scp call and to make tunnel — a non-standard port, a specific key, a jump host. Use the -o Name=value form (-p and -P mean different things to ssh and scp), e.g. SSH_OPTS='-o Port=2222 -o IdentityFile=~/.ssh/vps'. A non-standard port is safe: harden opens the port your session actually came in on, not a hardcoded 22. For anything permanent, a Host entry in ~/.ssh/config is usually cleaner — and it is picked up the same way |
AUTO_REBOOT |
(interactive if absent) | yes/no — automatic reboot after a security update that requires it (e.g. make all VPS_HOST=1.2.3.4 AUTO_REBOOT=no). Any other value is rejected rather than silently treated as "yes" |
REPO |
(required by github-deploy-key) |
Name of the GitHub repo the deploy key is dedicated to |
OWNER |
(interactive if absent) | GitHub user or org owning REPO, used by github-deploy-key to clone (e.g. make github-deploy-key VPS_HOST=1.2.3.4 REPO=my-repo OWNER=my-github-user) |
- The SSH tunnel must stay open during use (
make tunnelstays in the foreground), no always-on background access. On macOS, putting the Mac to sleep drops the connection; you have to re-runmake tunnel. - Mobile access: see Access from iPhone (Termius) above — it works, but with more frequent drops than on a computer.
- No automatic VPS backup — remember to take snapshots on your host's side if your projects aren't consistently pushed to GitHub.
- Tested on Ubuntu 24.04 at Gandi; should work as-is on any Ubuntu 24.04 VPS with root/sudo access.
Issues and PRs are welcome if you adapt this setup to another host or hit a problem — see CONTRIBUTING.md for the conventions and the checks to run before opening one. For anything security-sensitive, follow SECURITY.md rather than opening a public issue.



