Skip to content

Repository files navigation

opencode on a VPS — self-hosted, private, reachable anywhere

English · Français

License: MIT Platform: Ubuntu 24.04 Access: SSH tunnel Visual tutorial Video walkthrough

📖 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.

Why this setup rather than another?

  • 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).

Architecture

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.

Requirements

  • An Ubuntu 24.04 VPS, any host will do, with your SSH key already installed (root/sudo access).
  • Passwordless sudo for that SSH user. Every target runs its commands over a non-interactive ssh <command>, where a sudo password prompt cannot be answered (sudo: a terminal is required to read the password). Ubuntu cloud images set this up by default for the ubuntu user, so there is usually nothing to do — but a VPS installed by hand, with a user you created yourself, needs it (sudo visudoyourname ALL=(ALL) NOPASSWD:ALL). This is also why reaching opencode means root on the VPS: see Security.
  • make, ssh, scp and openssl available locally (already present on macOS/Linux). During the install, openssl runs on your machine: it generates the opencode web password, 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, so openssl runs on it.)

Quick start

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=ubuntu

The 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 Makefile and make test speak 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 tunnel

This 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.

opencode web, reached through the SSH tunnel on 127.0.0.1:4096

Steps run by make all

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.

Access from iPhone (Termius)

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: VaultsPort 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:

Termius — the vault's Port Forwarding section Termius — editing the port forwarding rule Termius — port forwarding rule active

Once the rule is active, open http://127.0.0.1:4096 in Safari, just like on a Mac.

⚠️ Frequent drops: on iPhone the tunnel is noticeably less stable than on a computer — app sleep, a Wi-Fi/5G switch or simply backgrounding the app cuts the port-forwarding rule. You then have to restart it manually in Termius; it's not a reliable always-on access, more of an occasional workaround.

FAQ / Troubleshooting

How much does it cost? (example)

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.

make all looks stuck on security-updates

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 normalunattended-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'

The tunnel drops, or "connection refused" on http://127.0.0.1:4096

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.

"bind: Address already in use" when starting make tunnel

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:5000

LOCAL_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.

The opencode UI shows projects from another VPS

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:4097

Working 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.

I lost (or want to change) the opencode-web password

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 password

The 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)start

Write down the password shown. (Re-running make opencode-web-service also generates a new one, along with redeploying the unit.)

The opencode-web service won't start

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.

make test shows FAIL / WARN

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.

SSH: "Permission denied (publickey)"

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.

Security — threat model and choices made

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

Sync your code (GitHub)

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:

  1. 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.
  2. After printing the public key: clone REPO onto the VPS right away (into ~/projects/), or stop and get the git clone command to run yourself later. Option 1 only works once you have actually added the key on GitHub, otherwise the clone fails with Permission denied. Pass OWNER=<github-user-or-org> on the command line to skip the owner prompt.

Verify the install

make test

Deploys 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.

Files in this repository

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.

Customize

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 rewritten

The 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)

Known limitations / not covered

  • The SSH tunnel must stay open during use (make tunnel stays in the foreground), no always-on background access. On macOS, putting the Mac to sleep drops the connection; you have to re-run make 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.

Contributing

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.

License

MIT

About

Install opencode on an Ubuntu VPS, reachable only through an SSH tunnel — never exposed publicly.

Topics

Resources

Contributing

Security policy

Stars

Watchers

Forks

Releases

Packages

Used by

Contributors

Languages