A self-hosted OpenCode workspace with persistent projects and sessions, Docker-in-Docker application previews, browser automation, optional Claude Code subscription delegation, GitHub HTTPS authentication, and wildcard Cloudflare Tunnel routing.
Each project can be built inside the isolated DinD daemon and published at a stable hostname such as shop.example.com. OpenCode receives explicit deployment and teardown procedures that verify real browser behavior and preserve application data.
Browser
-> Cloudflare Access and Tunnel
-> OpenCode web UI
Project preview hostname
-> wildcard Cloudflare Tunnel route
-> Caddy preview router
-> port published by an application inside DinD
Screenshot hostname
-> wildcard Cloudflare Tunnel route
-> Caddy preview router
-> authenticated read-only screenshot viewer
The host does not publish OpenCode, preview, browser, or Docker daemon ports. Cloudflared reaches services over the private Compose network.
- OpenCode web UI running as a non-root user
- Persistent projects, configuration, sessions, and preview registry
- Docker 29 DinD daemon with Docker Compose and Buildx in OpenCode
- Caddy wildcard preview router with dynamic, persistent routes
- Browserless Chromium and Playwright MCP
- Basic-authenticated screenshot gallery at
screenshots.<PREVIEW_BASE_DOMAIN> - Context7 and GitHub MCP templates
- GitHub CLI HTTPS credential helper
- Salesforce CLI for workspace-isolated authentication used by the official Salesforce MCP server
- Official Claude Code CLI bridge with selectable
default,sonnet,opus, andhaikumodels - Guarded
/claudedelegation command with read-only and file-edit modes /preview-deployand/preview-destroycommands/reload-workspacecommand for reloading project config and MCP servers without restarting the container- Enforced pre-handoff browser verification and
/test-like-a-humanacceptance command - Context-aware PR follow-through with inline feedback resolution and
/pr-follow-through - Proportional validation rules that keep simple tasks fast, reserve expensive checks for matching risks, and keep manually created worktrees inside each repository
- Portable agents, commands, instructions, plugins, and pinned third-party skill bundles
- Hardened preview instructions covering production builds, Compose overrides, bootstrap, hydration checks, data preservation, and orphan cleanup
- Common development tools including Git, Git LFS, GitHub CLI, Node 22, Python 3,
nano,rg,fd, and a compiler toolchain
- Linux host with Docker Engine and Docker Compose, or Docker Desktop
- A model provider supported by OpenCode
- A Cloudflare account, domain, and named tunnel for remote access
- At least 12 GB of available memory with the default limits
- GitHub token if private repository access or GitHub MCP is required
DinD requires privileged: true. Read the security section before exposing this stack.
-
Clone and bootstrap the repository:
git clone <repository-url> opencode_remote cd opencode_remote ./scripts/bootstrap.sh
-
Edit
.env:nano .env
-
Set at least:
OPENCODE_SERVER_PASSWORD=a-long-random-password OPENCODE_MODEL=anthropic/claude-sonnet-4-6 ANTHROPIC_API_KEY=your-provider-key PREVIEW_BASE_DOMAIN=example.com CLOUDFLARE_TUNNEL_TOKEN=your-named-tunnel-token
-
On Linux, set
OPENCODE_UIDandOPENCODE_GIDto the values printed bybootstrap.sh. This keeps bind-mounted files writable. -
Build and start the stack:
docker compose up -d --build docker compose ps
-
Confirm the local service health:
docker compose exec opencode sh -c \ 'curl -fsS -u "$OPENCODE_SERVER_USERNAME:$OPENCODE_SERVER_PASSWORD" http://127.0.0.1:4096/global/health'
The variables in the final command are container environment variables. Run it exactly through docker compose exec as shown.
Create or reuse a remotely managed named tunnel. Add two public hostnames to the same tunnel:
| Public hostname | Service |
|---|---|
code.your-domain.com |
http://opencode:4096 |
*.your-preview-domain.com |
http://preview-router:80 |
Set PREVIEW_BASE_DOMAIN=your-preview-domain.com in .env.
Cloudflare Universal SSL normally covers the apex and one wildcard label. With PREVIEW_BASE_DOMAIN=example.com, project.example.com is covered. A hostname such as project.preview.example.com may require an additional certificate because it is two labels below the apex.
If the tunnel dashboard says No DNS records changed, add the wildcard DNS record manually in the preview domain's zone:
Type: CNAME
Name: *
Target: <tunnel-uuid>.cfargotunnel.com
Proxy: Proxied
TTL: Auto
Verify it with a random hostname:
curl -I https://dns-probe.your-preview-domain.comAn unregistered hostname should return 404 with:
No preview is registered for this hostname.
The template defaults to Anthropic. Change OPENCODE_MODEL and add the corresponding provider credential to compose.yaml and .env when using another provider. OpenCode model IDs include the provider prefix.
The live OpenCode configuration is initialized at:
./data/config/opencode.jsonc
The versioned template is at:
./config-template/opencode.jsonc
./scripts/bootstrap.sh initializes the pinned Git submodules and installs the complete public configuration on first run. This includes the local subagents, commands, instruction policies, Ponytail plugin, adapted Addy skills, Cloudflare skills, and Payload skills. Plugin runtime dependencies are locked in config-template/package-lock.json and installed inside the OpenCode container.
Machine-specific provider endpoints, Git identity, tokens, credentials, personal domains, runtime state, and node_modules are intentionally excluded. Configure those through .env, the generic template, or ignored files under data/.
Reload the affected workspace after changing project configuration, commands, agents, skills, or MCP credentials:
/reload-workspace
This disposes and recreates only the current workspace instance. Recreate the OpenCode container when changing its image or process-level environment variables.
Browser-facing changes must pass an isolated Playwright acceptance check after the final implementation change and before OpenCode hands the work back or recommends production deployment. Later code changes, rebuilds, restarts, or preview updates invalidate earlier browser evidence.
Run a specific acceptance journey from OpenCode Web with:
/test-like-a-human complete checkout as a first-time customer
The command requires normal user navigation, completed interactions, final-state assertions, console and relevant network inspection, applicable desktop and mobile checks, screenshots, and a PASS, FAIL, or BLOCKED evidence block. Production checks are optional follow-up smoke tests, not substitutes for pre-handoff preview verification.
When OpenCode creates or updates a pull request, it detects whether that repository has active CI, automated review, or security agents. If automation exists, OpenCode waits for the latest revision, fixes actionable feedback, validates and pushes a new commit, replies on each originating inline thread with the fix and evidence, resolves that thread, and repeats until the PR is clean. It does not wait for human approval. OpenCode never merges pull requests; merging is always reserved for the human user.
Repositories without applicable automation incur only a short registration check and are not blocked indefinitely. Resume the loop explicitly from OpenCode Web with:
/pr-follow-through
OpenCode classifies task risk before selecting validation. Documentation and metadata changes avoid builds and browser work; isolated logic changes use focused tests; browser-facing work gets one conclusive pre-handoff journey; production packaging and high-risk behavior receive the heavier checks they warrant. Validation proceeds from cheapest to most expensive, repeated full builds and preview updates are bounded, static package operations do not start infrastructure, and simple tasks do not launch subagents.
Code-changing requests made from a primary checkout create a dedicated stream with stream create <name>. Manual Git worktrees live under the primary checkout at .worktrees/<name>, with .worktrees/ added to the repository's local .git/info/exclude. OpenCode then scopes source access, commands, tests, and previews to that worktree. Open the returned directory as a new OpenCode project for complete watcher and LSP isolation; an existing session cannot change its underlying project directory.
Useful low-level commands are:
docker compose exec opencode stream create sale-prices /workspace/my-project origin/main
docker compose exec opencode stream list /workspace/my-project
docker compose exec opencode stream remove sale-prices /workspace/my-projectRemoval is explicit and refuses dirty worktrees. It removes the worktree while preserving its Git branch. OpenCode's built-in worktree feature is separate: its location is managed under OpenCode's persistent data directory and automatically registered as a project sandbox.
OpenCode can delegate a bounded task to the official Claude Code CLI using your own Claude Pro, Max, Team, or Enterprise subscription. This does not make the subscription a native OpenCode provider: OpenCode still uses OPENCODE_MODEL for orchestration, while Claude Code handles only delegated requests.
After building the stack, generate a token through Anthropic's official flow:
docker compose exec opencode claude setup-tokenPut the generated value directly in the ignored .env file, never in chat or source control:
CLAUDE_CODE_OAUTH_TOKEN=your-generated-tokenApply and verify it:
chmod 600 .env
docker compose up -d --no-deps --force-recreate opencode
docker compose exec opencode claude auth status --textDelegate from OpenCode with an optional model alias:
/claude opus review this architecture
/claude sonnet implement the requested change
/claude haiku summarize this module
Every delegation requires permission. Analyze mode is read-only; edit mode can modify files but cannot run shell, web, browser, or MCP tools. OpenCode performs tests and final verification. See Claude Code Subscription Bridge for installation updates, authentication, security boundaries, renewal, and troubleshooting.
Put a GitHub token in .env:
GITHUB_TOKEN=github-tokenA classic token needs repo for private repositories. Add read:org when organization or team API queries are required. Prefer a fine-grained token restricted to the repositories this environment needs.
Recreate OpenCode after changing .env:
docker compose up -d --no-deps --force-recreate opencodeVerify authentication without printing the token:
docker compose exec opencode gh api user --jq .loginConfigure commit attribution in the persistent Git config:
docker compose exec opencode git config --global user.name "Your Name"
docker compose exec opencode git config --global user.email "your-github-email"Use an account-specific GitHub noreply address if the account has no public email.
Clone or create projects under /workspace in OpenCode. On the host they are stored under:
./data/workspace
The same absolute /workspace path is mounted into OpenCode and DinD. This is important because bind-mount source paths are resolved by the DinD daemon, not the Docker client.
OpenCode session history persists under ./data/state. After the OpenCode container restarts, reopen the existing session and ask it to inspect the current workspace and continue.
Processes running inside the OpenCode container, including shell commands and Claude Code delegations, stop with the container and are not replayed automatically. Automatic replay is unsafe because an interrupted tool may already have partially changed files or an external service.
Run long-lived project applications through Compose in DinD with an appropriate restart policy. Those containers are independent of the OpenCode service and can continue running or restart under the DinD daemon. Design long-running batch work to persist checkpoints and restart idempotently rather than relying on an OpenCode terminal process.
From an OpenCode session opened in a project, run:
/preview-deploy my-project
The deployment procedure:
- Prefers the project's production image over a development server.
- Reserves a port from
31000-31999. - Generates or updates a preview Compose override.
- Removes unrelated host port publishing with Compose
!resetand!overridetags. - Builds, migrates, seeds, and starts the application in DinD.
- Publishes
<slug>.<PREVIEW_BASE_DOMAIN>through Caddy. - Uses Playwright to prove that the application hydrated and a meaningful interaction works, then posts an authenticated screenshot link.
HTTP 200 and server-rendered HTML are deliberately not considered sufficient proof of success.
To stop a preview while preserving data and build artifacts:
/preview-destroy my-project
Destroy removes project containers, its Compose network, the Caddy route, and the port reservation. It preserves named volumes, bind-mounted data, source files, images, build cache, generated environment files, and the preview override. The procedure records volumes before teardown and verifies they still exist afterward.
Each preview uses a persistent environment file at /workspace/.preview-env/<slug>.env. Initialize it from inside the OpenCode container with preview env <slug>; the helper requests directory mode 700 and file mode 600, removes all group/other access, and prints only its path. NFSv4 ACL datasets may preserve an owner execute bit and display an owner-only file as 700.
docker compose exec opencode preview env my-project
# /workspace/.preview-env/my-project.envStore one KEY=VALUE per line and edit secret values directly in that file. Do not put secrets in chat, shell arguments, project files, or committed examples. If a project also has a base .env, pass both files to every Compose lifecycle command in this order so the preview-specific values take precedence:
docker compose --env-file .env \
--env-file /workspace/.preview-env/my-project.env \
-p preview-my-project -f compose.yaml -f .opencode/preview.compose.yml \
config --quietThe same ordered --env-file arguments must be reused for validation, build, startup, bootstrap, redeploy, and teardown. Omit .env when it does not exist. Compose env files provide interpolation values; they do not automatically expose every variable to containers.
- Explicitly allowlist required runtime variables under the service's
environment:section. - Use build arguments only for non-secret values that must be embedded in frontend assets, such as public browser URLs.
- Use BuildKit or Compose secrets for sensitive build inputs. Build arguments can remain visible in image metadata or layers.
- Validate with
docker compose ... config --quiet. Never print rawdocker compose configoutput because resolved configuration can contain secrets. /preview-destroyretains/workspace/.preview-env/<slug>.envwith owner-only access for safe, fast redeployment unless the user explicitly requests its removal.
Visual verification applies to all meaningful browser-facing work, not only /preview-deploy. The global instruction asks OpenCode to run or reuse a reachable application while building, verify a real interaction, and post an authenticated link to a desktop screenshot of the resulting state. It also requests a mobile screenshot when responsive behavior is relevant.
Screenshots supplement behavioral, console, network, and automated checks rather than replacing them. The instruction prohibits capturing secrets, credentials, private environment values, or sensitive user data.
Retained files are stored in the shared /workspace/screenshots directory, outside project checkouts. Bootstrap adds /screenshots/ to /workspace/.gitignore as an additional safeguard. Playwright limits its output to 500 MiB and evicts older artifacts as needed.
Open the gallery at:
https://screenshots.<PREVIEW_BASE_DOMAIN>
The viewer uses the same Basic Auth username and password as OpenCode, serves only flat PNG, JPEG, and WebP files from the screenshot directory, and mounts that directory read-only. OpenCode Web does not currently render tool-result images inline, so agents return clickable gallery links instead.
The screenshots preview slug is reserved for this viewer. Remove or rename any existing project preview using that slug before upgrading.
To clear retained image files manually while preserving the screenshot directory, run:
/clear-screenshots
Useful low-level commands:
docker compose exec opencode preview list
docker compose exec opencode preview url my-projectBack up these host paths:
./data/workspace Project repositories and bind-mounted application data
./data/config OpenCode configuration, skills, commands, and agents
./data/state OpenCode sessions and state
./data/previews Preview registry
./data/gitconfig Git identity and credential-helper configuration
The Compose volume ending in _dind-data contains DinD images, build cache, and application named volumes. Back it up if named-volume application data must survive total stack loss. Do not assume backing up ./data includes it.
The Caddy configuration volumes can be recreated from the preview registry by republishing previews.
Update outer images and rebuild OpenCode:
docker compose pull
docker compose build --pull opencode
docker compose up -dPin OPENCODE_VERSION and image tags for reproducible or production-oriented installations. Review release notes before major Docker, Caddy, Browserless, or OpenCode upgrades.
Bootstrap does not overwrite an existing data/config. When an update changes config-template or pinned vendor configuration, back up data/config, selectively copy or merge the new configuration, commands, skills, and instructions, then restart OpenCode.
When adding the screenshot viewer to an existing installation, rerun ./scripts/bootstrap.sh to create /workspace/screenshots and its ignore rule, merge the updated configuration template, then recreate the affected services and reload the preserved preview registry:
docker compose up -d --force-recreate screenshot-viewer preview-router opencode --wait
docker compose exec opencode preview reloadOlder installations may retain files under data/workspace/.opencode-artifacts/screenshots. They are not migrated or managed by the updated /clear-screenshots command; inspect them and remove them manually after preserving anything needed.
- Put Cloudflare Access with MFA in front of both OpenCode and wildcard preview hostnames.
- Use a long OpenCode server password as defense in depth.
- The screenshot viewer reuses OpenCode Basic Auth and must remain behind the wildcard tunnel and Cloudflare Access; do not publish its port directly.
- Never publish or tunnel DinD port
2375; it is an unauthenticated privileged Docker API on the private Compose network. - DinD itself is privileged. Anyone who can execute unrestricted commands in OpenCode can control its nested containers and data.
- Use repository-scoped GitHub credentials and rotate tokens after accidental disclosure.
- Keep
.envanddata/out of Git. They are ignored by this repository. - Do not put production secrets in project Compose files or preview overrides.
- Retain resource limits. A build can otherwise consume the entire host even when
nprocreports more CPUs than DinD was allocated. - Protect backups because they may contain source code, sessions, credentials, and application data.
Confirm the wildcard CNAME exists and is proxied. The tunnel ingress rule and DNS record are separate configuration objects.
Check that the wildcard tunnel service is exactly http://preview-router:80, not the OpenCode service or a DinD port.
Use the production image, inspect JavaScript and chunk failures, and verify hydration with an observable interaction. Development HMR failures can leave server-rendered HTML visible while the client runtime is inert.
Compose appends lists. Use ports: !override on the previewed service and ports: !reset [] on every other service. Some YAML language servers flag these tags incorrectly; docker compose config is authoritative.
The runtime container UID needs read and directory-traverse permission on the host path as seen by DinD. Prefer copying immutable initialization files into an image or using a correctly owned volume instead of making repository contents world-writable.
Verify gh api user, inspect git config --global --show-origin --list, and use an HTTPS clone URL. Ensure the token can access the repository and organization.
Limit framework build workers rather than relying on nproc, increase DIND_MEMORY_LIMIT only when the host has capacity, and inspect DinD disk/cache usage before retrying.
If Docker reports that address pools are exhausted or the configured subnet overlaps another network, choose an unused private /24 in .env:
OPENCODE_NETWORK_SUBNET=10.77.78.0/24Inspect current allocations with docker network inspect $(docker network ls -q) before selecting a replacement.
Stop the stack, back up data/config, and copy the desired files from config-template. Bootstrap intentionally does not overwrite an existing live configuration.
compose.yaml Outer service stack
Dockerfile OpenCode development image
config-template/ Initial OpenCode configuration and instructions
vendor/ Pinned third-party skills and plugin sources
preview/Caddyfile Initial wildcard router configuration
preview/preview Preview registry and Caddy lifecycle helper
scripts/bootstrap.sh Safe first-run data initialization
data/ Runtime state; generated and ignored
Licensed under the MIT License.