Current version: 0.11.2
OpenCode Multiuser turns OpenCode into a centrally managed, multi-user developer platform for enterprise and internal engineering environments. It provides authenticated access to isolated, disposable OpenCode workspaces while keeping project files and OpenCode state persistent across container restarts.
Instead of giving every developer a permanently running VM or asking users to manage OpenCode locally, the platform allocates an isolated rootless Podman container only when a workspace is needed. A central FastAPI control plane handles authentication, authorization, workspace lifecycle, routing, capacity limits, idle cleanup, and administrative controls. Traefik exposes a single portal URL, while OpenCode itself remains largely unmodified upstream software.
OpenCode is powerful because it can inspect repositories, edit files, run commands, and invoke development tools. Those same capabilities make a shared deployment difficult to operate safely without isolation, identity, lifecycle management, and governance. OpenCode Multiuser adds that missing enterprise layer.
The platform is useful when an organization wants to provide AI-assisted development centrally while retaining control over infrastructure, source code, authentication, capacity, and runtime isolation. Typical benefits include:
- Centralized access — users sign in through one internal portal instead of installing and configuring OpenCode individually.
- Strong workspace isolation — every active workspace runs in its own rootless Podman container with CPU, memory, PID, capability, and filesystem restrictions.
- Efficient infrastructure use — containers are created on demand and destroyed when users stop, log out, or remain idle, so capacity is not permanently reserved per user.
- Persistent developer state — repositories, OpenCode data, state, and configuration survive container recreation while the runtime itself remains disposable.
- Enterprise identity integration — generic OpenID Connect supports Entra ID, Keycloak, Authentik, Okta, and other standards-compliant providers, while local accounts remain available as an optional fallback.
- Role-based administration — administrators can manage users, roles, account status, password resets, and active workspaces from the portal.
- Single ingress point — users access one portal hostname; workspace containers are not directly exposed to the network.
- Controlled resource usage — a configurable slot pool limits the number of simultaneously running workspaces.
- Operational clarity — readiness checks, loading states, stale-runtime reconciliation, idle reclamation, health endpoints, and systemd integration make the platform suitable for long-running internal operation.
- On-premises friendly — the system is designed for Linux, rootless Podman, systemd/Quadlet, PostgreSQL, and internal networks without requiring Kubernetes.
The result is closer to an internal developer platform than a simple OpenCode wrapper: users get an easy browser experience, while infrastructure teams retain control of runtime boundaries and capacity.
- Single-host web portal for login, registration, dashboard, administration, and OpenCode access
- Generic OIDC authentication with PKCE and optional local username/password fallback
- First-account administrator bootstrap and
admin/developerroles - Admin user-management console
- Admin system console with controlled OpenCode engine updates
- Rootless Podman workspace containers
- systemd/Quadlet-managed PostgreSQL and Traefik services
- One isolated runtime per active workspace
- Persistent per-user/per-project workspace and OpenCode state
- Configurable maximum concurrent workspace slots
- Automatic stop-on-logout
- Automatic idle-workspace reclamation
- OpenCode readiness checks before opening a workspace
- Stale-runtime reconciliation after crashes or host restarts
- Server-side injection of OpenCode Basic Auth credentials
- HTTP and WebSocket gateway proxying
- Management widget injected into OpenCode with Dashboard, Stop, and Logout actions
- Responsive enterprise portal UI with loading, error, toast, and confirmation states
- Semantic Versioning with reproducible ZIP release packaging
| Login panel | System administration |
![]() |
![]() |
| User management | User workspaces |
![]() |
![]() |
Enterprise user
|
| HTTP/HTTPS
v
+----------------------+
| Traefik |
| single public host |
+----------+-----------+
|
v
+----------------------------------+
| FastAPI control plane / gateway |
| |
| - authentication |
| - authorization |
| - user/admin management |
| - workspace scheduler |
| - readiness / idle cleanup |
| - HTTP + WebSocket proxy |
+-------------+--------------------+
|
| loopback-only backend ports
+---------------+------------------+
| | |
v v v
127.0.0.1:41001 127.0.0.1:41002 127.0.0.1:41003
| | |
v v v
+-----------+ +-----------+ +-----------+
| OpenCode | | OpenCode | | OpenCode |
| workspace | | workspace | | workspace |
| container | | container | | container |
+-----------+ +-----------+ +-----------+
PostgreSQL stores users, slots,
workspace leases and metadata.
The browser never connects directly to a workspace container. The gateway validates the user's selected workspace, checks ownership, injects the container's private OpenCode Basic Auth credential, and proxies HTTP/WebSocket traffic to the slot's loopback-only backend port.
User starts project
|
v
Allocate free slot
|
v
Create fresh rootless Podman container
|
v
Mount persistent user/project directories
|
v
Wait for authenticated OpenCode /global/health
|
v
Mark workspace RUNNING
|
v
Proxy browser traffic through gateway
|
+------ user stops / logs out / becomes idle ------+
|
v
Destroy runtime container
|
v
Release slot
|
v
Keep persistent project state
MAX_SLOTS=10 means up to ten simultaneous workspace containers. It does not prestart ten containers.
Stopping a workspace preserves its project and OpenCode state for a later start. The dashboard's Remove action is different: after an explicit confirmation, it stops any active runtime, deletes the workspace record, and permanently removes that workspace's project files and OpenCode data.
The project is designed around disposable runtimes and explicit trust boundaries:
- Workspace containers run rootless under the service account.
- Workspace root filesystems are read-only except for explicit persistent mounts and tmpfs paths.
- Linux capabilities are dropped and
no-new-privilegesis enabled. - CPU, memory, and PID limits are enforced through cgroup v2.
- User namespaces isolate container identities from the host.
- Workspace backend ports bind only to
127.0.0.1. - OpenCode Basic Auth passwords are generated per runtime and never exposed to the browser.
- The gateway validates workspace ownership before proxying traffic.
- Client authentication headers and portal cookies are stripped before requests are forwarded to OpenCode.
- Traefik does not receive the Podman socket.
- The control plane is the only component allowed to create and destroy workspace runtimes.
- OIDC access and refresh tokens are not persisted by the portal.
- Administrative actions are protected by backend role checks, not only hidden UI elements.
For production use, enable HTTPS at Traefik and restrict direct access to the control-plane port with the host firewall.
- Linux with cgroup v2
- Podman 5.x+
- systemd user services / Quadlet
- Python 3.11+
- PostgreSQL container support through Podman
opensslrsync- delegated
cpu,memory, andpidscgroup controllers for the rootless service user - one DNS A/AAAA record for the portal hostname
Run the supplied host preflight before installation:
./scripts/check-host.shAs root:
loginctl enable-linger llm_apps
UID_LLM=$(id -u llm_apps)
systemctl start user-runtime-dir@${UID_LLM}.service
systemctl start user@${UID_LLM}.serviceIf the required cgroup controllers are not delegated, a typical systemd override is:
# /etc/systemd/system/[email protected]/delegate.conf
[Service]
Delegate=cpu cpuset io memory pidsThen:
systemctl daemon-reload
systemctl restart user@$(id -u llm_apps).serviceLog in again as the rootless service user afterward.
unzip opencode-multiuser-0.11.2.zip
cd opencode-multiuser-0.11.2
export BASE_DOMAIN=code-test.example.org
PYTHON_BIN=python3.11 ./scripts/install.shThe installer configures:
~/.config/containers/systemd/
opencode.network
postgres.container
postgres-data.volume
traefik.container
~/.config/systemd/user/
opencode-control-plane.service
~/.config/opencode-multiuser/
control-plane.env
postgres.env
traefik.yml
~/.local/share/opencode-multiuser/
users/
traefik-dynamic/
It bootstraps uv inside the application virtual environment and installs the
production dependency set from the committed uv.lock. Installation fails if
the release archive omits that lockfile; it never resolves a newer dependency
set during deployment.
Default listeners are:
Traefik public HTTP: :8080 and :8443
Control plane / gateway: :8010
Workspace loopback base: 41000
Slot 1 OpenCode backend: 127.0.0.1:41001
Slot 2 OpenCode backend: 127.0.0.1:41002
...
Port 8443 is plain HTTP until TLS is explicitly configured.
The gateway normally binds CONTROL_PLANE_BIND=0.0.0.0 so the Traefik container can reach host.containers.internal:<CONTROL_PLANE_PORT>. Restrict that port with the host firewall; Traefik should be the intended public ingress.
The primary runtime configuration is:
~/.config/opencode-multiuser/control-plane.env
Common settings:
CONTROL_PLANE_BIND=0.0.0.0
CONTROL_PLANE_PORT=8010
CONTROL_PLANE_URL=http://code-test.example.org:8443
MAX_SLOTS=10
WORKSPACE_HOST_PORT_BASE=41000
WORKSPACE_READY_TIMEOUT_SECONDS=45
WORKSPACE_READY_POLL_INTERVAL_SECONDS=0.5
WORKSPACE_IDLE_TIMEOUT_MINUTES=30
WORKSPACE_REAPER_INTERVAL_SECONDS=60
STOP_WORKSPACES_ON_LOGOUT=true
WORKSPACE_IMAGE=localhost/opencode-workspace:latest
OPENCODE_VERSION=1.18.30
OPENCODE_REGISTRY_URL=https://registry.npmjs.org/opencode-ai/latest
OPENCODE_UPDATE_TIMEOUT_SECONDS=900
DATA_ROOT=/srv/opencode-multiuser/data
TRAEFIK_DYNAMIC_DIR=/srv/opencode-multiuser/traefik-dynamic
# Central OpenCode provider policy (comma-separated provider IDs).
# These values are enforced for every workspace.
OPENCODE_ENABLED_PROVIDERS=
OPENCODE_DISABLED_PROVIDERS=
SESSION_COOKIE_NAME=oc_session
WORKSPACE_COOKIE_NAME=oc_workspace
COOKIE_SECURE=false
LOCAL_AUTH_ENABLED=true
ALLOW_REGISTRATION=true
OIDC_ENABLED=false
OIDC_ISSUER=
OIDC_DISCOVERY_URL=
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
OIDC_SCOPES=openid profile email
OIDC_DISPLAY_NAME=Corporate SSO
OIDC_REDIRECT_URI=
OIDC_USE_PKCE=true
OIDC_AUTO_PROVISION=true
OIDC_DEFAULT_ROLE=developerWhen serving the portal over plain HTTP, keep COOKIE_SECURE=false. Set it to true only after the public endpoint is genuinely HTTPS.
For a fresh installation, override the default home-directory locations by passing absolute paths to the installer:
DATA_ROOT=/srv/opencode-multiuser/data \
TRAEFIK_DYNAMIC_DIR=/srv/opencode-multiuser/traefik-dynamic \
PYTHON_BIN=python3.11 ./scripts/install.shThe installer persists both values in control-plane.env, creates the needed
directories, and configures the Traefik container mounts accordingly. On later
upgrades it retains the persisted paths unless explicitly overridden again.
Set OPENCODE_ENABLED_PROVIDERS to an allowlist, or
OPENCODE_DISABLED_PROVIDERS to deny specific providers, in
control-plane.env. Values are comma-separated OpenCode provider IDs; for
example:
OPENCODE_ENABLED_PROVIDERS=anthropic,openai
OPENCODE_DISABLED_PROVIDERS=geminiThey may also be supplied during installation or an upgrade:
OPENCODE_ENABLED_PROVIDERS=anthropic,openai ./scripts/install.shThe control plane generates a managed OpenCode configuration and mounts it
read-only into each workspace at /etc/opencode/opencode.json. It is applied
after user and project configuration, so users cannot override it. If a
provider appears in both lists, OpenCode disables it. Restart an existing
workspace after changing the policy; the policy is applied when a workspace is
started.
Local authentication is enabled by default.
Registration:
http://code-test.example.org:8443/register
Login:
http://code-test.example.org:8443/login
The first account provisioned in an empty database receives the Admin role. Later users default to Developer.
For an internal deployment, create the intended bootstrap administrator first and then consider disabling open registration:
ALLOW_REGISTRATION=falseThe portal supports standards-compliant OpenID Connect providers such as Microsoft Entra ID, Keycloak, Authentik, Okta, and others.
Register the portal as an OIDC web client and configure a callback similar to:
http://code-test.example.org:8443/auth/oidc/callback
Then configure:
OIDC_ENABLED=true
OIDC_ISSUER=https://id.example.org/realms/developers
OIDC_CLIENT_ID=opencode-portal
OIDC_CLIENT_SECRET=replace-me
OIDC_SCOPES=openid profile email
OIDC_DISPLAY_NAME=Company SSO
OIDC_USE_PKCE=true
OIDC_AUTO_PROVISION=true
OIDC_DEFAULT_ROLE=developerOIDC_DISCOVERY_URL normally stays empty because the portal derives the provider metadata URL from OIDC_ISSUER. OIDC_REDIRECT_URI also normally stays empty and is derived from CONTROL_PLANE_URL.
After configuration:
systemctl --user restart opencode-control-plane.serviceOnce OIDC has been verified from a separate browser session, an SSO-only deployment can use:
LOCAL_AUTH_ENABLED=false
ALLOW_REGISTRATION=falseDo not disable local authentication before confirming that OIDC works; otherwise a configuration error could lock out administrators.
OIDC identities use (issuer, sub) as the immutable identity key. Email, display name, and preferred username are profile attributes rather than identity keys. OIDC access and refresh tokens are not stored by the portal.
The portal currently defines two roles:
- Admin — user administration plus normal workspace access
- Developer — normal workspace access
Admins can open:
/admin/users
/admin/system
/admin/users provides user and role management. /admin/system provides portal/OpenCode version visibility and the controlled OpenCode image update workflow described below.
The user-management console supports:
- searching and filtering users
- viewing Local versus OIDC authentication source
- viewing role, account status, created time, last login, and active workspaces
- promoting developers to admin
- demoting admins with safeguards
- enabling and disabling accounts
- stopping another user's active workspaces
- resetting local-account passwords
- deleting eligible local accounts
Safety rules prevent an administrator from disabling, demoting, or deleting themselves, and prevent removal of the last enabled administrator.
After login, users are sent to:
/dashboard
The dashboard shows project/workspace cards, runtime status, slot availability, and available actions.
Typical statuses include:
STARTING
RUNNING
STOPPING
STOPPED
ERROR
Starting, stopping, and logout operations provide immediate loading/progress feedback so users can see that the action was accepted.
When a workspace is open, the gateway injects a small Portal control into the OpenCode page with:
- Dashboard
- Stop workspace
- Logout
Upstream OpenCode itself is not modified.
A container reporting Up does not guarantee OpenCode is ready to accept requests. The control plane therefore polls the authenticated OpenCode health endpoint before marking a workspace RUNNING.
Defaults:
WORKSPACE_READY_TIMEOUT_SECONDS=45
WORKSPACE_READY_POLL_INTERVAL_SECONDS=0.5If OpenCode never becomes healthy, the new runtime is removed and the slot is returned rather than exposing a transient broken workspace.
The control plane also reconciles workspace records against actual Podman runtimes at startup and during housekeeping. Missing containers or runtimes without their expected loopback mapping are marked stale, cleaned up, and their slots released.
Capacity is reclaimed in two ways.
With:
STOP_WORKSPACES_ON_LOGOUT=truelogging out stops the user's active workspace containers and returns their slots immediately. Persistent project/OpenCode data is retained.
The gateway records workspace activity during HTTP requests, streamed responses, WebSocket activity, and workspace open/start operations. A background reaper stops workspaces that have remained inactive longer than:
WORKSPACE_IDLE_TIMEOUT_MINUTES=30The reaper runs every:
WORKSPACE_REAPER_INTERVAL_SECONDS=60Set the timeout to 0 to disable idle cleanup.
After an idle timeout, browser navigation returns the user to the dashboard with an explanation that the runtime was reclaimed. API traffic receives a machine-readable conflict response, and stale WebSockets are closed cleanly.
Each user/project has persistent storage outside the disposable runtime container:
~/.local/share/opencode-multiuser/users/<user-id>/<project>/
├── workspace/
├── opencode-data/
├── opencode-state/
├── opencode-config/
├── runtime-secret
├── last-activity
└── stop-reason
The OpenCode cache remains ephemeral in tmpfs.
Stopping or recreating a container does not delete the project workspace or OpenCode state.
Local-auth API clients can obtain a bearer token:
CP=http://127.0.0.1:8010
TOKEN=$(
curl -sS -X POST "$CP/auth/login" \
-H 'content-type: application/json' \
-d '{"username":"cahya","password":"replace-with-a-long-password"}' |
jq -r .access_token
)Start a workspace:
curl -sS -X POST "$CP/workspaces/start" \
-H "Authorization: Bearer $TOKEN" \
-H 'content-type: application/json' \
-d '{"project_slug":"rag-project"}' | jqThe response includes a URL such as:
http://code-test.example.org:8443/open/<workspace-id>
API clients can route through the single-host gateway with:
curl -sS \
-H "Authorization: Bearer $TOKEN" \
-H "X-OpenCode-Workspace: <workspace-id-or-container-name>" \
http://code-test.example.org:8443/global/health | jqThe gateway validates that the selected workspace is running and belongs to the authenticated user. The header is never treated as an unrestricted container destination.
Useful checks:
systemctl --user status \
postgres.service \
traefik.service \
opencode-control-plane.service
podman ps
curl http://127.0.0.1:8010/healthz
curl http://127.0.0.1:8010/version
ss -ltn | grep -E ':(8010|8080|8443|4100[0-9])\\b'A healthy response includes the application version and gateway mode.
Application logs:
journalctl --user -u opencode-control-plane.service -n 200 --no-pagerWorkspace containers:
podman ps --filter 'name=oc-'
podman logs <workspace-container>The portal version and the OpenCode engine version are independent. Upgrading OpenCode Multiuser does not require every OpenCode engine update to be a new portal release.
The configured OpenCode engine version is persisted in:
~/.config/opencode-multiuser/control-plane.env
using:
OPENCODE_VERSION=1.18.30Administrators can open:
/admin/system
The System page shows:
- OpenCode Multiuser portal version
- configured OpenCode version
- version currently installed in
localhost/opencode-workspace:latest - upstream OpenCode version from the npm registry
- current image update/build state and log output
Use Check latest to query the configured registry and Build & activate to update to a chosen semantic version.
The update is deliberately transactional:
request version
|
v
build localhost/opencode-workspace:<version>
|
v
run `opencode --version` smoke test
|
+---- failure ----> keep current `latest` image unchanged
|
v
retag tested candidate as localhost/opencode-workspace:latest
|
v
persist OPENCODE_VERSION in control-plane.env
The build runs in the background so the admin page remains responsive and can poll progress. Only administrators can start or inspect the update workflow.
Running workspaces are not interrupted. A container that is already running continues using the image it started with. Stop and start that workspace when you want it to use the newly activated OpenCode image.
The same safe workflow can be performed manually. Example for 1.18.31:
cd ~/opt/opencode-multiuser
TARGET_VERSION=1.18.31
podman build \
--build-arg "OPENCODE_VERSION=${TARGET_VERSION}" \
-t "localhost/opencode-workspace:${TARGET_VERSION}" \
-f Containerfile.workspace .
podman run --rm \
"localhost/opencode-workspace:${TARGET_VERSION}" \
--version
podman tag \
"localhost/opencode-workspace:${TARGET_VERSION}" \
localhost/opencode-workspace:latest
sed -i \
"s/^OPENCODE_VERSION=.*/OPENCODE_VERSION=${TARGET_VERSION}/" \
~/.config/opencode-multiuser/control-plane.env
systemctl --user restart opencode-control-plane.serviceAlternatively, the installer honors an explicit version:
OPENCODE_VERSION=1.18.31 \
PYTHON_BIN=python3.11 \
./scripts/install.shOn later upgrades, install.sh reuses the persisted OPENCODE_VERSION unless OPENCODE_VERSION is explicitly supplied in the shell.
Dependencies are resolved in the committed uv.lock. Install uv using its
supported distribution for your development machine, then create the locked
development environment and run tests with:
uv sync --locked --extra dev
uv run pytest -qVerify that dependency declarations and the lockfile agree before committing or cutting a release:
uv lock --checkWhen intentionally changing a dependency declaration, run uv lock and commit
both pyproject.toml and uv.lock. To intentionally refresh resolved versions
without changing declarations, use uv lock --upgrade and review the lockfile
diff before committing it. The production installer uses uv sync --frozen --no-dev, so it installs exactly the locked production dependency set.
OpenCode Multiuser follows Semantic Versioning (MAJOR.MINOR.PATCH). The single source of truth is:
app/version.py
The Python package metadata, FastAPI/OpenAPI metadata, /healthz, /version, portal UI, and installer all read from this version.
Show or change the version:
python3 scripts/version.py show
python3 scripts/version.py bump patch # 0.11.2 -> 0.11.3
python3 scripts/version.py bump minor # 0.11.2 -> 0.12.0
python3 scripts/version.py bump major # 0.11.2 -> 1.0.0
python3 scripts/version.py set 0.12.0Build a release archive and checksum:
./scripts/package-release.shwhich creates:
dist/opencode-multiuser-<version>.zip
dist/opencode-multiuser-<version>.zip.sha256
A typical release flow is:
python3 scripts/version.py bump patch
# update CHANGELOG.md
uv lock --check
uv run pytest -q
./scripts/package-release.shSee CHANGELOG.md for the complete release history and detailed evolution of the project.
Before exposing the portal beyond a controlled test network:
- enable HTTPS at Traefik
- set
COOKIE_SECURE=true - integrate with corporate OIDC/SSO
- disable open registration after bootstrap
- disable local authentication if organizational policy requires SSO-only access
- restrict direct access to the control-plane port
- review outbound network access for workspace containers
- use short-lived Git credentials or a credential broker rather than mounting host SSH keys
- monitor slot usage, memory, CPU, and workspace lifecycle events
- back up PostgreSQL and persistent workspace storage
Detailed feature-by-feature changes have intentionally been moved out of this README. See CHANGELOG.md for the full history from the earliest prototype through version 0.9.0.



