Self-Hosting Cloud Dev Environments With Coder
The team wants cloud workspaces, but the code must stay inside the company network, workspaces must reach internal services that are not on the internet, and the security team will not approve a SaaS provider holding source code. Codespaces is out; the question is how to get the same experience on infrastructure the organisation owns. Coder is an open-source control plane that provisions workspaces from Terraform templates on Docker hosts, Kubernetes or cloud VMs, and connects IDEs to them over an encrypted tunnel. This page stands up a Coder deployment with a template that builds a project's dev container, as part of cloud development environments for onboarding.
A first symptom teams hit on a quick trial is a workspace stuck at Waiting for agent to connect...; the diagnosis below covers that too, because it is almost always networking between the workspace and the Coder server.
Diagnostic
Check the Coder server's reachability from where workspaces run, and the state of a workspace's agent:
#!/usr/bin/env bash
set -euo pipefail
coder version
coder list --output json | jq -r '.[] | "\(.name)\t\(.latest_build.status)\t\(.latest_build.resources[0].agents[0].status // "no agent")"'
docker run --rm curlimages/curl:8.9.1 -fsS -o /dev/null -w 'from container: %{http_code}\n' "$CODER_ACCESS_URL/healthz" || true
docker logs "$(docker ps -qf label=coder.workspace_name=onboarding)" 2>&1 | tail -3 || true
Expected bad output on a first trial run with CODER_ACCESS_URL=http://localhost:3000:
Coder v2.15.0
onboarding running connecting
curl: (7) Failed to connect to localhost port 3000
from container: 000
agent: dial tcp 127.0.0.1:3000: connect: connection refused
The workspace container started, but its agent tries to reach the Coder server at localhost, which inside the container is the container itself.
Root cause
Coder's architecture is pull-based: the server provisions a workspace by running a Terraform template, and a small agent inside the workspace connects back to the server's access URL to register, receive its configuration and carry IDE traffic. That access URL must be reachable from inside every workspace. localhost works for the browser on the machine running Coder but not for containers or VMs, which is why trials on a laptop get stuck at "connecting". In a real deployment the access URL is an internal DNS name with TLS (https://coder.corp.example) that both developers and workspaces can reach. The other thing trials skip is the template: Coder does not know how to build your project's environment until a template describes it, and a template that reuses the repository's devcontainer.json avoids maintaining a second definition.
Resolution
- Run the Coder server with a reachable access URL. For a single Docker host, Compose is enough; production deployments typically use the Helm chart on Kubernetes with an external Postgres:
services:
coder:
image: ghcr.io/coder/coder:v2.15.0
ports:
- "3000:3000"
environment:
CODER_ACCESS_URL: https://coder.corp.example
CODER_HTTP_ADDRESS: 0.0.0.0:3000
CODER_PG_CONNECTION_URL: postgresql://coder:${CODER_DB_PASSWORD:?set it}@db/coder?sslmode=disable
group_add:
- "${DOCKER_GID:-999}"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
depends_on:
db:
condition: service_healthy
db:
image: postgres:16.4
environment:
POSTGRES_USER: coder
POSTGRES_PASSWORD: ${CODER_DB_PASSWORD:?set it}
POSTGRES_DB: coder
volumes:
- coder-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD", "pg_isready", "-U", "coder"]
interval: 5s
volumes:
coder-db:
Put a TLS-terminating proxy in front of port 3000 at coder.corp.example, and make sure that name resolves from the Docker host's containers.
- Write a template that builds the repository's dev container. Coder's
envbuilderimage clones the repository and builds from.devcontainer/devcontainer.jsoninside the workspace, so the same definition drives local, Codespaces and Coder workspaces:
terraform {
required_providers {
coder = { source = "coder/coder", version = "~> 1.0" }
docker = { source = "kreuzwerker/docker", version = "~> 3.0" }
}
}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
resource "coder_agent" "main" {
os = "linux"
arch = "amd64"
}
resource "docker_volume" "workspaces" {
name = "coder-${data.coder_workspace.me.id}-workspaces"
}
resource "docker_container" "workspace" {
count = data.coder_workspace.me.start_count
image = "ghcr.io/coder/envbuilder:1.0.3"
name = "coder-${data.coder_workspace_owner.me.name}-${data.coder_workspace.me.name}"
env = [
"CODER_AGENT_TOKEN=${coder_agent.main.token}",
"CODER_AGENT_URL=${data.coder_workspace.me.access_url}",
"ENVBUILDER_GIT_URL=https://git.corp.example/acme/shop.git",
"ENVBUILDER_INIT_SCRIPT=${replace(coder_agent.main.init_script, "/localhost|127\\.0\\.0\\.1/", "coder.corp.example")}",
"ENVBUILDER_FALLBACK_IMAGE=codercom/enterprise-base:ubuntu",
]
volumes {
container_path = "/workspaces"
volume_name = docker_volume.workspaces.name
}
}
- Push the template and create a workspace:
#!/usr/bin/env bash
set -euo pipefail
coder login https://coder.corp.example
coder templates push shop --directory ./coder/templates/shop --yes
coder create onboarding --template shop --yes
coder ssh onboarding -- 'cd /workspaces/shop && make doctor'
- Set auto-stop and TTL so idle workspaces release their resources. In the template settings (or
coder templates edit shop --default-ttl 8h --activity-bump 1h), stop workspaces after eight hours without activity and delete dormant ones after thirty days.
Expected output
$ coder list
WORKSPACE TEMPLATE STATUS HEALTHY LAST BUILT OUTDATED
dev/onboarding shop Running true 2m false
$ coder ssh onboarding -- 'cd /workspaces/shop && make doctor'
docker ............. ok
node 20.17.0 ....... ok
db reachable ....... ok
internal api ....... ok (https://api.internal.corp.example)
The agent is connected, the workspace was built from the repository's dev container, and it reaches an internal API that a public CDE could not.
The last line is the reason most organisations self-host: workspaces live on the same network as internal package mirrors, staging databases and service registries, with no VPN client or tunnel on the developer's side. Access control follows the organisation's existing SSO through Coder's OIDC integration, so revoking a leaver's identity revokes workspace access at the same moment.
Prevention
Version templates in git and push them from CI. A template edited through the UI drifts from what anyone can review.
Monitor the agent connection rate. Coder exposes Prometheus metrics; alert when workspaces fail to reach
Connectedwithin a few minutes, which usually means DNS or TLS changed for the access URL.Keep images and templates close to the definition in the repository. If envbuilder falls back to the fallback image, the dev container failed to build — surface that in the workspace startup logs and fix the definition rather than the template.
Platform caveats
Apple Silicon (ARM64) developers: workspaces run on the organisation's hosts, typically amd64. Developers connect from any laptop through VS Code Remote, JetBrains Gateway or the browser; set
arch = "arm64"in the agent only if the hosts are ARM.
Kubernetes deployments: replace the Docker provider with the Kubernetes provider and a persistent volume claim per workspace. Workspaces that need Docker inside should use a rootless option such as Sysbox rather than privileged pods.
Corporate proxies: workspaces behind an outbound proxy need
HTTP_PROXY,HTTPS_PROXYandNO_PROXYincluding the Coder access URL, or the agent's connection goes through the proxy and fails; see resolving corporate proxy and TLS interception failures.
Rollback
Workspaces are Terraform-managed, so deleting them removes containers and volumes cleanly; the server itself is removed with Compose:
#!/usr/bin/env bash
set -euo pipefail
coder list --output json | jq -r '.[].name' | xargs -r -n1 coder delete --yes
docker compose -f coder/compose.yaml down
Frequently Asked Questions
Why is my Coder workspace stuck at "Waiting for agent to connect"?
The agent inside the workspace cannot reach the Coder server's access URL. Use a hostname reachable from inside workspaces, not localhost, and check DNS and TLS from a container on the same host.
Do we need to rewrite our dev container as a Coder template?
No. With envbuilder, the template clones the repository and builds its existing devcontainer.json. The template only describes where and how big the workspace is.
Is Coder free?
The open-source edition is free and covers templates, workspaces, auto-stop and IDE connections. A paid edition adds features such as high availability, audit logging and advanced quotas.
Can developers still work locally?
Yes, and they should be able to. The same devcontainer.json works with the VS Code Dev Containers extension locally, which also makes debugging template problems much faster.