The problem: you want an AI coding assistant that’s available from anywhere. Not locked to one laptop. Not tied to a VS Code extension that breaks every other update. Something you can pull up on your phone, your tablet, even a browser on a friend’s machine when you need to debug something fast.
But AI coding tools are powerful. They can read your files, run shell commands, push code. Exposing one to the internet without authentication is — let’s be direct — insane. And the usual “just put a password on it” approach falls apart as soon as you have more than one user or want proper session management.
Here’s the fix: OpenCode’s built-in web server, behind Authentik SSO, proxied through Nginx Proxy Manager, with the backend tucked safely on a private Tailscale network. Every request hits Authentik before it touches OpenCode. No shared credentials. No open ports. Full TLS. And the whole thing took about 30 minutes to deploy.
This article walks through the exact setup I use — the same infrastructure that already runs a dozen other apps on TekOnline’s homelab. If you’ve already got Authentik and NPM running, adding OpenCode is a 10-minute job.
Architecture Overview
The request flow:
Internet
│ DNS: opencode.yourdomain.com → Oracle public IP
▼
┌─────────────────────────────────┐
│ ORACLE CLOUD │
│ Nginx Proxy Manager (:443) │ ← TLS termination, Let's Encrypt
│ │ auth_request → Authentik │ ← Validates session cookie
│ │ Returns 302 if no session │
│ ▼ │
│ Authentik (embedded outpost) │ ← SSO provider, forward-auth
│ │ User logs in once │
│ │ Session cookie set │
│ ▼ │
│ NPM proxies to backend │
└────────────┬────────────────────┘
│
Tailscale mesh VPN
│
┌────────────┴────────────────────┐
│ UBUNTU SERVER │
│ Docker: opencode container │ ← smanx/opencode, port 4096
│ smanx/opencode image │
│ /workspace → /home/jon/repos │
└─────────────────────────────────┘
The key insight: OpenCode never touches the public internet. It runs on an on-prem Ubuntu box, reachable only via Tailscale’s private mesh. Nginx Proxy Manager on Oracle is the single entry point. Authentik validates every request before NPM proxies it through. If Authentik says no, the request never reaches OpenCode.
This is the same pattern I use for Jellyfin, Nextcloud, Immich, and every other self-hosted app in my homelab. One SSO provider, one reverse proxy, any number of backends — cloud or on-prem, it all looks the same from Authentik’s perspective.
What Is OpenCode?
OpenCode is an AI coding agent that runs as a CLI tool — but it also ships a built-in web server. Run opencode web and you get a full browser-based IDE with session management, terminal access, and the same AI capabilities as the CLI version. No VS Code required. No desktop app. Just a browser.
The web mode uses SSE (Server-Sent Events) for real-time updates — not WebSockets. This matters for reverse proxying: no upgrade headers, no sticky sessions, just long-running HTTP connections. Nginx handles it fine once you disable buffering.
| Feature | Details |
|---|---|
| Built-in web server | opencode web --port 4096 --hostname 0.0.0.0 |
| Real-time updates | SSE (Server-Sent Events) — no WebSocket config needed |
| Health check | GET /global/health → {"healthy":true,"version":"1.18.2"} |
| Native auth | Basic auth via OPENCODE_SERVER_PASSWORD (optional — we let Authentik handle it) |
| Docker image | smanx/opencode — Ubuntu-based, 113 MB, 100K+ pulls |
| Persistence | ~/.config/opencode/ + ~/.local/share/opencode/ |
Step 1: Deploy OpenCode on Docker
The Docker setup is straightforward — just a compose file, two volumes for persistence, and a health check. I run this on an Ubuntu Server VM, but any Docker host works.
version: "3.8"
services:
opencode:
image: smanx/opencode:latest
container_name: opencode
restart: unless-stopped
ports:
- "4096:4096"
environment:
- OPENCODE_HOSTNAME=0.0.0.0
- OPENCODE_PORT=4096
# No OPENCODE_SERVER_PASSWORD — Authentik handles auth
volumes:
- opencode-config:/root/.config/opencode
- opencode-data:/root/.local/share/opencode
- /home/jon/repos:/workspace:rw
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:4096/global/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
volumes:
opencode-config:
opencode-data:
Note: no password set. Authentik is the sole auth layer. If someone reaches this container directly (e.g., via Tailscale IP), they’d get in unauthenticated — but that requires being on the Tailscale mesh, which is a trusted network. For extra paranoia, set OPENCODE_SERVER_PASSWORD as a second layer.
docker compose up -d
curl -s http://localhost:4096/global/health
# {"healthy":true,"version":"1.18.2"}
Step 2: Create the Authentik Provider
Authentik uses a Proxy Provider for forward-auth. This is the same pattern used by every app in the homelab — Jellyfin, Nextcloud, Immich, all of them. The provider defines two things: the external URL users visit, and the internal URL Authentik should protect.
In the Authentik admin panel:
- Applications → Providers → Create → Proxy Provider
- Name:
OpenCode - Authentication flow:
default-authentication-flow - Authorization flow:
default-provider-authorization-implicit-consent - Mode: Forward Auth (Single Application)
- External Host:
https://opencode.yourdomain.com - Internal Host:
http://100.89.58.49:4096(Tailscale IP of the Docker host)
Critical: both External Host and Internal Host must be filled, or the outpost won’t pick up the provider.
Then create an Application that links to this provider, and assign it to the authentik Embedded Outpost. Three clicks, done. The provider is now active — any request to opencode.yourdomain.com will be intercepted and validated.
Step 3: Wire Up Nginx Proxy Manager
NPM handles TLS termination (Let’s Encrypt), domain routing, and the Authentik forward-auth integration. The config lives in the Advanced tab of the proxy host:
# Proxy host settings:
# Domain: opencode.yourdomain.com
# Forward: 100.89.58.49:4096 (Ubuntu via Tailscale)
# SSL: Let's Encrypt, force HTTPS, HTTP/2
location / {
# Preserve the original URL for the auth subrequest
set $ak_orig_url https://$http_host$request_uri;
# Proxy to OpenCode on Ubuntu via Tailscale
proxy_pass $forward_scheme://$server:$port;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $forward_scheme;
proxy_set_header X-Forwarded-Host $host;
# Required for SSE (Server-Sent Events) — OpenCode uses /event
proxy_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_http_version 1.1;
chunked_transfer_encoding on;
# Forward-auth via Authentik
auth_request /__ak_auth;
error_page 401 = @goauthentik_proxy_signin;
auth_request_set $auth_cookie $upstream_http_set_cookie;
add_header Set-Cookie $auth_cookie;
# Pass Authentik headers to backend
auth_request_set $authentik_username $upstream_http_x_authentik_username;
auth_request_set $authentik_email $upstream_http_x_authentik_email;
proxy_set_header X-authentik-username $authentik_username;
proxy_set_header X-authentik-email $authentik_email;
}
The auth subrequest location — this is an internal nginx location that forwards the auth check to Authentik without exposing the outpost URL to the client:
# Internal auth subrequest — avoids NPM encoding ?rd= into %3F
location = /__ak_auth {
internal;
proxy_pass http://authentik-server:9000/outpost.goauthentik.io/auth/nginx;
proxy_set_header Host $http_host;
proxy_set_header X-Original-URL $ak_orig_url;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
}
# Outpost endpoints — must be accessible without authentication
location /outpost.goauthentik.io {
proxy_pass http://authentik-server:9000/outpost.goauthentik.io;
proxy_set_header Host $http_host;
proxy_set_header X-Original-URL https://$http_host$request_uri;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
}
# Redirect to Authentik login on auth failure
location @goauthentik_proxy_signin {
internal;
return 302 /outpost.goauthentik.io/start?rd=https://$http_host$request_uri;
}
Save, and NPM regenerates the nginx config. Test it:
curl -sk https://opencode.yourdomain.com/ -o /dev/null -w '%{http_code}'
# 302 → redirect to Authentik login. Working.
# 500 → Authentik provider not configured yet (404 on auth subrequest)
The 302 is the correct response — unauthenticated users get bounced to the Authentik login page. After login, Authentik sets a session cookie and redirects back to OpenCode. From then on, the auth subrequest returns 200 and NPM proxies through to the backend.
SSE: The One Thing That Trips People Up
OpenCode uses Server-Sent Events (/event and /global/event endpoints) for real-time communication between the browser and the AI agent. SSE is simpler than WebSockets — it’s just a long-running HTTP response with text/event-stream content type. But reverse proxies will buffer it by default, and buffered SSE breaks everything.
The fix is three lines in the NPM advanced config:
proxy_buffering off; # Don't buffer the event stream
proxy_read_timeout 3600s; # Keep the connection alive for long sessions
chunked_transfer_encoding on; # Stream chunks as they arrive
Without these, the OpenCode UI loads but sits at a spinner forever — the SSE connection buffers the initial events and never flushes them to the browser. With buffering off, events stream in real time. The AI’s responses appear character by character, just like in the CLI.
Security: What This Setup Does and Doesn’t Protect
What’s protected
| Threat | Mitigation |
|---|---|
| Unauthenticated internet access | Authentik forward-auth blocks every request. No valid session = redirect to login |
| TLS interception | Let’s Encrypt via NPM, forced HTTPS, HTTP/2 |
| Direct backend access | OpenCode only reachable via Tailscale IP (private mesh, no public route) |
| Credential sharing | No shared passwords. Each user has their own Authentik account. Revoke access centrally |
| Brute force | Authentik handles rate limiting and account lockout. OpenCode never sees login attempts |
| Container escape | OpenCode runs as an unprivileged Docker container with only workspace + config volumes mounted |
What’s not protected
- Tailscale network access: anyone on your Tailscale mesh can reach OpenCode directly at
100.x.x.x:4096without Authentik. Tailscale membership is the trust boundary — keep it tight - AI provider API keys: stored in OpenCode’s config. Anyone with access to the container can extract them. Use API keys with usage limits and rotate them periodically
- Workspace files: OpenCode can read and write everything in
/workspace. Mount only what it needs - Container breakout via AI: OpenCode can run shell commands. An unpatched container runtime or a misconfigured Docker socket mount could allow host access. This container has no Docker socket mount — it can only affect files in its volumes
Why Not Just Use VS Code with GitHub Copilot?
VS Code extensions work great — on one machine. But they don’t give you:
- Session persistence across devices: start a refactor on your desktop, continue on your laptop, check progress from your phone
- Shared context across teams: multiple people connecting to the same OpenCode instance, seeing the same project state
- Centralised configuration: API keys, provider settings, and tool permissions managed once, not per device
- No IDE lock-in: works in any browser. Firefox, Chrome, Safari, even a tablet. The AI follows you, not your editor
OpenCode’s web mode turns an AI coding agent from a personal tool into a shared service. Authentik turns it from a security liability into a properly governed application. The two together make AI-assisted development something you can deploy to a team, not just run on your laptop.
Lessons Learned
1. SSE Is Not WebSockets — But Proxies Don’t Know That
Every reverse proxy I tested buffered the SSE stream by default. NPM, raw nginx, Caddy — all of them. The fix is always the same three lines (proxy_buffering off, long timeout, chunked encoding), but you have to know to add them. The OpenCode docs mention this clearly — read them before deploying.
2. Forward-Auth Is the Right Pattern for Browser Apps
Authentik supports several auth modes: OIDC (full redirect flow), LDAP (directory binding), SAML, and forward-auth (subrequest-based). For browser-based apps that don’t have native OIDC support — which is most self-hosted tools — forward-auth is the cleanest. The app never knows about authentication. It just trusts that every request arriving has already been validated.
3. NPM’s Advanced Config Can Bite You
Nginx Proxy Manager is great for 90% of cases, but the “Advanced” tab is where you’ll spend your debugging time. The key insight: NPM generates a location / { proxy_pass ... } block automatically based on your forward host/port settings. Anything you paste in the Advanced tab gets injected inside that location block. If you add another proxy_pass directive, nginx will fail to start. If you add nested location blocks, they work — but only because nginx supports nested locations (which is surprising and poorly documented). Test with docker exec nginx-app-1 nginx -t after every change.
4. Authentik Providers Need Both Hosts Set
I spent 15 minutes wondering why a freshly created provider wasn’t appearing in the outpost’s application list. The answer: both External Host and Internal Host must be set on the proxy provider before the outpost will register it. The Internal Host can be a Tailscale IP — Authentik doesn’t validate it, it just needs a non-empty value. Set both, save, and the provider appears instantly.
5. The Homelab Pattern Scales
This is now the 14th application running behind the same Authentik + NPM combo. The pattern is consistent: Docker container on whatever host makes sense (cloud for public apps, on-prem for storage-heavy ones), Tailscale mesh linking them together, NPM as the single internet-facing edge, Authentik handling auth for all of them. Adding a new app takes 10 minutes because 90% of the config is copy-paste from the previous one. Templates for infrastructure are worth their weight in time saved.
Resources
- OpenCode Web Docs — official documentation for the web server mode
- OpenCode on GitHub — source code, releases, issues
- smanx/opencode Docker Image — community-maintained Docker image, 100K+ pulls
- Authentik Proxy Provider Docs — forward-auth configuration guide
- Nginx Proxy Manager — simple reverse proxy with Let’s Encrypt
- Tailscale — free mesh VPN, up to 100 devices
This setup runs on the same infrastructure documented in the zero-budget homelab guide. If you’re starting from scratch, read that first — it covers the hypervisor, networking, and Tailscale foundation everything else depends on.
Leave a Reply