binding
OpenCode binds to 127.0.0.1 only. Caddy is the sole public-facing entry point, and the installer actively verifies the backend is not bound to a public address.
platform installer — systemd · caddy · let's encrypt
One command installs OpenCode 2 behind HTTPS, wires the project manager in as an MCP server, and makes the agent in the chat the person who ships.
01
OpenCode's web UI is the dashboard. The pm_*
tools are the power behind it — and pmd is not just a
daemon, it is Caddy's TLS authorization gate.
layer 01
Web UI on 127.0.0.1 only. You log in, you type, you ship.
layer 02
Registered inside opencode.json. Nine tools, verified as connected — not just well-formed.
layer 03
Health, routing, state.json, and the on-demand TLS gate.
unprivileged user pm, bound to loopback
one certificate per subdomain, issued lazily
because pm is the whole gate, a stranger cannot burn your Let's Encrypt quota — and the installer tells pm to authorize the OpenCode site too, or its own certificate would stop issuing.
02
Each line below is a decision the installer makes before it touches your filesystem — and refuses to guess.
--domain is the one host serving OpenCode. pm's parent is a domain with a wildcard record, often somewhere else entirely. Resolution is --pm-parent → PM_PARENT → an existing *. block → you. Never guessed, because a guess becomes an install that fails on a record that cannot exist.
When the records are absent the installer prints exactly what to add, how to re-run, and continues with exit 0. OpenCode 2 succeeded; pm is optional. Exit status only lies about the thing that must not be broken.
With on-demand TLS, Caddy issues lazily — seconds after install there is legitimately no certificate. The installer nudges the HTTP-01 challenge, then retries for up to ~120 seconds and reports what it is still waiting for.
Your existing /etc/caddy/Caddyfile is backed up to a timestamped .bak and merged into — never overwritten — validated before reload, rolled back on failure. Pre-existing sites are probed afterwards to prove they were not broken.
Mirroring an existing on-demand wildcard policy silently can produce a config where the gate would never issue. The installer probes the ask endpoint and refuses to continue on a policy conflict, explaining both remedies.
sudo -n "$@" || sudo "$@" cannot tell "needs a password" from "exited non-zero", so every failing root command used to run a second time — including destructive ones. The retry now happens only on genuine authentication failure.
Both units are enabled, linger is enabled, Restart=always, RestartSec=5, and StartLimitIntervalSec=0 so systemd can never rate-limit a crash loop into a permanently dead unit.
--uninstall --with-pm stops and deletes pmd.service and every project unit, drops the pm entry while leaving other MCP servers intact, and restores the on_demand_tls gate pm had taken over.
03
This is the whole surface the agent gets. Copy any line and paste it into the chat.
pm_create({ name, subdomain? })scaffold, start, route, wait for HTTPSpm_list()every project with status, port, subdomainpm_status({ name })one project's statepm_start({ name })start a projectpm_stop({ name })stop a project, keep its filespm_restart({ name })restart its systemd unitpm_delete({ name, purge? })remove unit + route; purge deletes filespm_logs({ name, lines? })recent journal outputthen, in the chat — nothing else to learn:
04
Password handling is the part most installers get wrong. This one has a script for it, a rollback, and a grammar that counts characters instead of bytes.
OpenCode binds to 127.0.0.1 only. Caddy is the sole public-facing entry point, and the installer actively verifies the backend is not bound to a public address.
The password lives in ~/.config/opencode/env, mode 0600 inside a 0700 directory, and reaches the unit through EnvironmentFile — so it never appears in systemctl show output.
Interactive entry uses read -s: not echoed, never in your shell history. A password you typed is never printed back. Only an auto-generated one is shown, and only once.
./change-password.sh stops the service, rewrites the single OPENCODE_PASSWORD= line — every other line preserved — and restarts. If it does not come back, the old password is restored automatically. A failed change can never lock you out.
Length is counted in characters, not bytes. One Arabic character is 2 bytes, so a byte count would report a strong password as "shorter than 12" — the bug that made the warning unreadable.
systemd's EnvironmentFile treats a backslash as an escape and trims edge whitespace — so those would be stored but never received as typed. Both scripts warn.
rejected outright, on every path — including --password
awaiting input
05
Not root. The OpenCode service must read your own config and credentials.
$ ./install-opencode.sh --domain example.com
the flags worth knowing
it ends with a tally
Only fatal failures abort the install. Fatal: the unit active, the backend answering on loopback, Caddy active, both units enabled at boot, linger enabled, and the backend not publicly bound. Certificate timing is a warning, never a failure.
Fatal checks pass on the backend's loopback health, never on HTTPS — so a slow ACME round-trip cannot misreport a healthy install.
06
Every entry below was a bug reported from someone's own machine, fixed in a released version.
/srv/pm was chowned before it existed — useradd --home-dir does not create the directory, so mkdir -p now precedes the chown.
The parent prompt used to echo --domain into itself, so pressing Enter accepted a parent you never chose. The prompt is now empty and says plainly that the parent is independent.
Pressing Enter could no longer skip pm. An empty answer now prints Skipping pm install. and continues — because an empty answer is a decision, not a typo.
--domain oc2d.example.com produced parent example.com — a domain nobody configured — and the install then failed a wildcard check for a record that could not exist. Resolution is now explicit, in a fixed order, and never guessed.
grep -q "^[[:space:]]*\*.$P" matched nothing: the unescaped . and * made it a regex that never hit, so pm appended a second wildcard and Caddy rejected the whole file with ambiguous site definition. Matching is now -F.
pm_create, pm_list, pm_logs and the rest arrived as tools the agent calls; install-pm.sh grew --allow-domain for the unified path.
After writing the config the installer restarts OpenCode and polls /api/mcp until it reports pm as connected. A well-formed config file no longer counts as proof.
pm-test/ ships fake systemctl, journalctl and services, plus a fake DNS + TLS pair, so the whole suite runs locally without touching a real host or burning a single certificate.
٠٧ — بالعربي
oc2d يثبّت لك OpenCode 2 خلف HTTPS مع Caddy، ويوصّل مدير المشاريع (pm) جوّه OpenCode كـ MCP server. يعني بدل ما تفتح لوحة تحكم وتعمل إعدادات، تكتب بال-chat العادي:
«أنشئ مشروع اسمه portfolio على portfolio.example.com»
فيقوم pm يشغّل الخدمة، يعطيه بورت، يربطه على subdomain، ويجيب له شهادة SSL صالحة — كله تلقائياً.
النقطة الأهم: pmd نفسه هو بوابة تفويض شهادات Caddy، فحدا ما يقدر يستهلك حصة Let's Encrypt بتاعتك. وكل قرار الم installer بياخده بيكون صريح — ما بيخمّن، وبيفحص قبل ما يمس أي ملف.