--- title: "ðŸ“Ķ Templates and workspaces" description: "Template strategy for the docker starter, the pushed docker template, the two non-interactive CLI traps that had to be solved, the first workspace build, how to reach workspace apps without a wildcard access URL, and the docker socket risk." source: "verified deployment on the loca operator host, 2026-08-19; template push and first workspace verified 2026-08-20" reviewed: 2026-08-20 --- # ðŸ“Ķ Templates and workspaces ## 🧭 Template strategy - start from the upstream docker starter template rather than hand-rolling Terraform: - `coder templates init --id docker` writes the starter into a directory, then edit it in place - the starter is Terraform using the `coder/coder` provider plus the `kreuzwerker/docker` provider - what the upstream docker starter provisions: - `coder_agent.main` with a `startup_script` that seeds `/etc/skel` on first start, plus CPU, RAM, disk and host load metadata blocks - `docker_volume` for the workspace home, labelled with the owner, owner id, workspace id and creation-time name - `docker_container` from image `codercom/example-base:ubuntu`, named `coder--`, with `CODER_AGENT_TOKEN` in its environment and a `host.docker.internal` host entry mapped to `host-gateway` - registry modules for `code-server` and `jetbrains` as optional IDE entry points - local rules for this deployment: - pin module versions rather than floating; the starter's `~> 1.0` style constraint is fine for a trial but a production template should name the version it was tested with - never set `privileged`, never add extra host bind mounts, and never mount `/var/run/docker.sock` into a workspace container - keep the workspace image explicit; do not use `latest` ## 🔗 coder_app must stay path-based - every `coder_app` in a template here keeps `subdomain = false`, which is also the provider default - upstream is explicit: if wildcards have not been set up by the administrator, apps with `subdomain = true` are not accessible - no wildcard access URL is configured on this deployment, and the blocker is TLS issuance rather than DNS; the detail lives in [[reference/limits]] - example shape for a path-based app: ```tf resource "coder_app" "web" { agent_id = coder_agent.main.id slug = "web" display_name = "Web app" url = "http://localhost:3000" subdomain = false share = "owner" healthcheck { url = "http://localhost:3000/healthz" interval = 5 threshold = 10 } } ``` - `share = "owner"` is the right default here because this is a single-admin deployment; `authenticated` and `public` widen exposure through the same public vhost - frameworks that insist on serving from an absolute root path (Vite, React dev server, Next.js dev server, JupyterLab, RStudio per upstream) are the ones that suffer under path-based access; run them behind their own base-path setting, or reach them with `coder port-forward` instead ## ðŸ“Ī Pushing a template The `docker` template is pushed and its version is active. Verified 2026-08-20: the push ran against the deployed provisioner, which planned 4 resources and created the template version. A pre-validated docker template ships with this deployment at `/home/loca/dev/coder/templates/docker`, so a re-push from an operator shell is one command once a CLI token exists: ```bash coder login https://code.loca.zone # browser session flow, stores a CLI token coder templates push docker -d /home/loca/dev/coder/templates/docker \ -m "initial docker template" ``` The first push was done with the CLI that already lives in the server container, staging the directory at `/tmp/tmpl` inside it, which is where the first trap below comes from: ```bash /opt/coder templates push docker -d /tmp/tmpl -y ``` - what ships in that directory: - `main.tf` — one `coder_app` (code-server) pinned to `subdomain = false`, a persistent `/home/coder` volume keyed to the workspace id, and no docker socket in the workspace container - `.terraform.lock.hcl` — provider hashes generated by the provisioner's own terraform (linux_amd64), so the push is warning-free and reproducible - `README.md` — the operator runbook, including the upstream sources each construct came from - providers are pinned: `coder/coder ~> 2.18` and `kreuzwerker/docker ~> 4.5`, resolved to 2.18.0 and 4.5.0 - validation performed against the deployed provisioner before the push: `terraform fmt -check`, `init`, `validate` and `plan` all clean (`Plan: 6 to add`); the first real `apply` came with workspace `ws1` below, which was also the first exercise of the code-server download path - scaffolding a fresh template instead is `coder templates init --id docker `, then edit and push the same way - `coder templates push` creates or updates a template from the chosen directory, marking the new version active by default (`--activate` defaults to true) - `-d`/`--directory` selects the source directory, `-m`/`--message` records the change description, `--name` names the version - the Template Admin role and above can create templates; in this deployment that means the single Owner - `coder templates pull`, edit, `coder templates push` is the round-trip for later edits; the dashboard can also edit template files directly - never put credentials in a template: upstream warns that template versions are persisted indefinitely in postgres and pushing a new version does not expunge secrets from earlier ones ## ðŸŠĪ Two non-interactive CLI traps, both hit for real | Trap | Symptom | Recipe | | --- | --- | --- | | `docker cp` lands files in the container owned by root, while the container process runs as `coder` | the in-container CLI dies with `open /home/coder/.config/coderv2/session: permission denied` | `chown` the staged session file and the copied template directory to `coder` before invoking the CLI, then delete both afterwards; the staged session file is a credential and must not survive the operation | | `coder create ... -y` does not auto-answer `coder_parameter` prompts | the CLI dies with `error creating cancelreader` instead of prompting, because there is no TTY to prompt on | give every parameter explicitly on the command line: `--parameter cpu=2 --parameter memory=2` | ## ðŸ§ą First workspace, as built ```bash /opt/coder create ws1 --template docker --parameter cpu=2 --parameter memory=2 -y ``` - resource gate re-measured immediately before the build: swap had drained to 0 of 16383 MB and roughly 31 GB of RAM was available, so the eight-gigabyte floor in [[reference/limits]] passed rather than being assumed - build result: `Apply complete` with 6 resources added; image `codercom/enterprise-base:ubuntu` pulled in about 1m30s; total start about 95s - runtime state after the build: workspace `ws1` running with health true, agent `main` connected with lifecycle `ready`, app `code-server` health `healthy`, `subdomain` false - ceilings actually applied: `cpu=2` and `memory=2` GB, with container swap pinned equal to memory, so a workspace cannot push the host back into swap - live proof that path-based apps work with no wildcard access URL and no extra certificate: https://code.loca.zone/@loca/ws1.main/apps/code-server/ loads a full code-server workbench in a real browser and redirects to `?folder=/home/coder` ## 🚊 Reaching a workspace - workspace app, path-based: - `https://code.loca.zone/@/./apps//` - the agent segment is optional when the workspace has a single agent, giving `/@//apps//` - the trailing slash matters; the server issues a redirect when it is missing, so the dashboard always links with it - web terminal: - `https://code.loca.zone/@/./terminal` - it is a websocket-backed xterm.js session, which is exactly why `proxy_read_timeout 86400` and `proxy_buffering off` are set in the vhost - arbitrary ports, replacing the dashboard port-forward UI that a wildcard URL would provide: - `coder port-forward --tcp 8000:8080` forwards remote 8080 to local 8000 - condensed syntax works too: `coder port-forward --tcp 8080,9000:3000,9990-9999` - `--udp` exists for UDP services, and `coder port-forward . --tcp ...` targets a specific agent - SSH: - `coder ssh ` for an interactive shell, `coder ssh -- ` for one-shot commands - `coder config-ssh` writes host entries so `ssh coder.` and any SSH-based tooling works, including `-L`/`-R` forwarding - `coder ssh -R` provides reverse forwarding - IDE remotes (VS Code extension, JetBrains Gateway, Coder Desktop) all ride the same CLI-established connections and need no wildcard URL ## ⚠ïļ Docker socket risk and containment The risk, stated plainly: `/var/run/docker.sock` is bind-mounted into the `coder` container, so the Coder server is root-equivalent on this host. Docker documents that access to the socket grants root-level privileges, and a template author can schedule a privileged container, mount host paths, or run arbitrary `local-exec` code during a build. - why it is accepted: this is a single-admin OSS deployment where only the Owner authors templates, and the built-in provisioner is the only way to build docker workspaces without standing up an external provisioner - containment actually in place: - the server image is pinned to `ghcr.io/coder/coder:v2.35.3`, not `latest` - no open signup, so no untrusted account can reach the template APIs; see [[operations/auth-model]] - the socket is not passed into workspace containers, so a workspace shell cannot reach the daemon - the socket group id is read at write time with `getent group docker | cut -d: -f3` and pinned as `group_add: "105"` rather than guessed - `mem_limit` bounds both containers, so a runaway build cannot swallow the host; see [[reference/limits]] - treat Template Admin as equivalent to host root and keep it to the single operator account ## ðŸĪ– Driving workspaces from the omp harness (MCP) Coder ships its own MCP server, and the `omp` harness is an MCP client, so the two compose natively. This is the deliberate direction of the integration: the agent runs on the host where its credentials already live and reaches into the workspace, rather than installing an agent inside the container and shipping secrets to it. Wiring: - server: `coder exp mcp server`, run inside the `coder` container, speaking MCP over stdio. Confirmed `serverInfo {name: "Coder Agent", version: "v2.35.3+65e2bfb"}` - wrapper: `scripts/coder-mcp.sh`, which supplies credentials and the tool allow-list - registration: `/home/loca/dev/coder/.omp/mcp.json`, project-scoped on purpose. The server exposes 26 tools; loading those into every unrelated omp session would waste context, so it only mounts when the working directory is this project ```json { "mcpServers": { "coder": { "command": "/home/loca/dev/coder/scripts/coder-mcp.sh", "args": [], "disabled": false } } } ``` Credentials: - a dedicated Coder token named `omp-mcp`, not the operator session token, so it can be revoked alone. `.mcp-token`, mode 600, gitignored - this deployment caps token lifetime at 168h (7d); `--lifetime 30d` is rejected outright. Rotate weekly with `scripts/coder-token-rotate.sh`, which mints the replacement, verifies it with a real API call, and only then revokes the previous ones - the token is handed to the container through `docker exec --env-file` with a mode-600 temp file that an EXIT trap shreds. It is never an argument, so it never appears in `ps` or in an agent transcript Tool allow-list, and why: - 26 of 32 tools are exposed via `--allowed-tools` - excluded: `coder_create_template`, `coder_create_template_version`, `coder_update_template_active_version`, `coder_delete_template`, `coder_delete_task`, `coder_upload_tar_file` - rationale: pushing a template is the one real path from a Coder principal to the host docker socket, as described in the section above. Template mutation stays a human action; the agent gets read-only template inspection plus full workspace access. Verified: a `tools/list` through the wrapper returns 26 names and none of the excluded ones - kept: `coder_workspace_bash`, `coder_workspace_read_file` / `write_file` / `edit_file` / `edit_files` / `ls`, `coder_workspace_list_apps`, `coder_workspace_port_forward`, agent and build logs, workspace create plus `coder_create_workspace_build` for start/stop/delete, the task tools, and `search` / `fetch` Wrapper trap worth knowing: the `docker exec` must run in the foreground. Backgrounding it with `&` plus `wait` looks harmless but breaks the server, because with job control disabled bash redirects an async command's stdin from `/dev/null`; the MCP stdio transport then sees EOF immediately and the client logs `MCP tool load failed ... Transport closed` with no tools registered. Verified end to end 2026-08-20: a fresh `omp` session started in `/home/loca/dev/coder` mounted the tools and answered from them — `coder_get_authenticated_user` returned `loca`, `coder_list_workspaces` plus `coder_get_workspace` returned `ws1` `running`. ## 📚 Sources - https://github.com/coder/coder/blob/main/examples/templates/docker/main.tf - https://coder.com/docs/admin/templates/managing-templates - https://coder.com/docs/reference/cli/templates_init - https://coder.com/docs/reference/cli/templates_push - https://coder.com/docs/reference/cli/port-forward - https://coder.com/docs/reference/cli/ssh - https://coder.com/docs/user-guides/workspace-access - https://coder.com/docs/admin/networking/wildcard-access-url - https://coder.com/docs/tutorials/best-practices/security-best-practices - https://registry.terraform.io/providers/coder/coder/latest/docs/resources/app - https://github.com/coder/coder/blob/main/coderd/workspaceapps/appurl/appurl.go - https://github.com/coder/coder/blob/main/site/src/modules/apps/apps.ts - https://docs.docker.com/engine/install/linux-postinstall/