Open a browser tab, get a fully-loaded dev environment. Close it, it’s gone. Open it tomorrow, it’s identical — same tools, same shell history, same checkout on the same branch.
That’s the experience this homelab actually delivers. Coder on a single Docker daemon, every workspace a sibling container, every workspace reachable on its own subdomain.
What Coder is#
Coder treats development environments as code. A template defines the workspace — base image, tools, env vars, the startup script. Coder provisions a workspace from a template on demand, gives it a URL, and tears it down when you stop it. State is reproducible because it’s declared, not hand-installed.
The payoff shows up in the parts of dev work nobody enjoys: a new contributor productive in the time it takes to click create workspace, a broken laptop that costs zero setup time to replace, a “works on my machine” bug that reproduces from the same template. The browser-based IDE is part of it; the real value is that the environment is code, version-controlled and rebuildable.
The Docker provider — workspaces as sibling containers#
Coder is provider-agnostic; the provider decides where workspaces actually land. In this homelab that’s the Docker provider, and the mechanic is worth understanding plainly:
flowchart TB
USER[Browser] -->|"HTTPS
workspace-name.example.com"| TR
subgraph HOST["Docker host (single daemon)"]
CODER["coder container
mounts /var/run/docker.sock
mounts /opt/coder-data:/home/coder"]
PG[("coder-db · postgres
users · workspaces · builds")]
CODER ---|"CODER_PG_CONNECTION_URL
container-to-container"| PG
CODER -->|"on 'create workspace'
docker run from template image"| WS1["workspace container
(sibling, not nested)"]
CODER -->|"docker run"| WS2["workspace container
(sibling, not nested)"]
TR["Traefik
routes workspace subdomains"]
end
TR -->|routes| CODER
CODER -->|"proxies to the workspace"| WS1
CODER --> WS2
Because the Coder container mounts the host’s Docker socket, “create a workspace” is effectively docker run with the right image — the new workspace is just another container on the same daemon, sitting next to Coder itself. No nested Docker-in-Docker, no virtual machine, no separate runtime. The workspace sees the host’s images, the host’s networks, the host’s volumes, because it is a host container.
This is the simplest possible arrangement, and it’s the one I run. It’s also the one with the most permissive trust boundary, which I’ll come back to.
The compose, annotated#
The real docker-compose.yml, with only the Coder-relevant services (a couple of unrelated co-located apps live in the same file and aren’t part of this setup):
services:
coder:
image: ghcr.io/coder/coder
container_name: coder
ports:
- "80:80"
- "2112:2112"
group_add:
- "${DOCKER_GID}"
environment:
CODER_ACCESS_URL: ${CODER_ACCESS_URL}
CODER_WILDCARD_ACCESS_URL: ${CODER_WILDCARD_ACCESS_URL}
CODER_HTTP_ADDRESS: "0.0.0.0:80"
CODER_PG_CONNECTION_URL: postgres://coder:${CODER_DB_PASSWORD}@coder-db:5432/coder?sslmode=disable
CODER_PROMETHEUS_ENABLE: "true"
CODER_PROMETHEUS_ADDRESS: "0.0.0.0:2112"
CODER_PROMETHEUS_COLLECT_AGENT_STATS: "true"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /opt/coder-data:/home/coder
depends_on:
coder-db:
condition: service_healthy
restart: unless-stopped
coder-db:
image: postgres
container_name: coder-db
shm_size: 256mb
environment:
POSTGRES_USER: coder
POSTGRES_PASSWORD: ${CODER_DB_PASSWORD}
POSTGRES_DB: coder
command: >
postgres
-c shared_buffers=256MB
-c effective_cache_size=2GB
-c work_mem=16MB
-c maintenance_work_mem=128MB
-c wal_buffers=16MB
-c max_parallel_workers_per_gather=1
-c random_page_cost=1.1
volumes:
- coder_pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U coder"]
interval: 5s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
coder_pgdata:
A few things worth pointing at.
- Two ports.
:80is the Coder UI and API.:2112is Prometheus metrics, enabled withCODER_PROMETHEUS_ENABLE=trueandCODER_PROMETHEUS_ADDRESS=0.0.0.0:2112. The central Prometheus scrapes:2112, and I get workspace-build rates and durations with no extra wiring. - The Docker socket mount.
/var/run/docker.sock:/var/run/docker.sockis the provider. This single line is what lets Coder spawn sibling containers on the host daemon — without it, Coder can’t provision anything. Thegroup_add: ["${DOCKER_GID}"]line gives the in-container user the host group membership needed to actually talk to that socket. - Postgres holds everything. Users, workspaces, builds, template versions — all in
coder-db.CODER_PG_CONNECTION_URLpoints Coder at it;sslmode=disablebecause the connection is container-to-container on the same compose network. Thehealthcheckoncoder-dband thedepends_on: { condition: service_healthy }oncodermean Coder will not start until Postgres is ready to accept connections. - The tuning lines are deliberate.
shared_buffers=256MB,effective_cache_size=2GB,work_mem=16MB,maintenance_work_mem=128MB,random_page_cost=1.1— these are not defaults. For a small database on a host with a few GB of RAM and SSD storage, the defaults over-provision parallelism and under-provision cache.effective_cache_size=2GBtells the planner the OS page cache is real and large, which changes query plans for the better;random_page_cost=1.1tells it random reads are roughly as cheap as sequential ones, which is true on SSD. Invisible until you have a slow query and wonder why.
Every workspace gets its own subdomain#
Every workspace container is reachable at its own subdomain — no per-workspace DNS entry, no per-workspace routing rule, nothing to remember. CODER_WILDCARD_ACCESS_URL on the Coder side plus wildcard DNS at the edge is all it takes; Coder handles the mapping.
One gotcha, plainly stated: workspace names must not collide with existing subdomains. A workspace named the same as a real service shadows that service — and if you name it after the Coder UI itself, you shadow the very UI you’d need to rename or delete it with. That’s a self-inflicted footgun, easy to avoid once you know about it; name workspaces feature-X or scratch-Y, not after your real services.
Workspace connectivity — Tailscale inside Coder#
There’s a second network path inside Coder that’s easy to miss, because it doesn’t show up in the compose. Coder uses Tailscale’s networking internally — the DERP relay protocol — so that workspace terminals, SSH, and the in-browser IDE can reach the dashboard even when the workspace container lives on a different Docker network than the browser-facing edge. Concretely: every workspace has to be able to reach CODER_ACCESS_URL. If a workspace can’t resolve or route to that URL, the dashboard’s terminal and SSH buttons silently fail to connect — the workspace is up, but unreachable through the UI.
This is separate from the Tailscale mesh I use to administer the host machines. Coder ships its own Tailscale, scoped to workspace connectivity; the admin mesh is the one I SSH through. Don’t conflate them — they’re different overlays for different jobs. The admin-mesh context is in the zero-trust homelab write-up.
The practical fix, when workspace terminals won’t connect: make sure the workspace container can reach CODER_ACCESS_URL. In a reverse-proxy-fronted setup that usually means attaching workspace containers to the same Docker network the proxy lives on, so the workspace can reach it by name. The wildcard subdomain also has to be in DNS, pointing at the edge, or port-forwarding via the dashboard breaks for the same underlying reason.
Where Coder fits in the homelab#
Coder is one of several services co-located on the work VM. It’s reachable the same way everything else is — through Cloudflare Tunnel, Traefik, and the wildcard DNS — and monitored the same way: scraped on :2112 by the central Prometheus, with logs shipped by the per-VM Alloy agent like every other container.
In practice, that means a Coder workspace build shows up in the same Grafana dashboards as every other service, and a workspace container crash appears in the same Loki queries. The edge trust boundary — outbound-only tunnel, no inbound ports — applies to Coder exactly as it does to the rest of the fleet. The full edge and observability story is in the self-hosted zero-trust homelab write-up; this article is the Coder-specific slice.
The trust boundary — read this before you copy it#
I’ll say this plainly because the rest of the article makes the setup sound free, and it isn’t.
Mounting /var/run/docker.sock into the Coder container means the Coder process can drive the host Docker daemon as root — and anyone who can create or modify a workspace template can effectively direct that access. A template that mounts the socket into a workspace hands that workspace the ability to spin up containers, inspect others, attach to their volumes, and read environment variables — including secrets — of every other container on the same daemon. The Docker socket is root-equivalent on the host.
For a single-user homelab, or a small team that mutually trusts each other, this is a reasonable trade-off — and it’s the one I run. The Docker-provider setup is simple, it has one moving part, and it works. For multi-tenant use, or where workspace users don’t fully trust each other, this is not the right shape: you’d want each workspace against a separate Docker daemon, or under a sandboxing runtime like gVisor. This setup does not do that. That’s a deliberate choice — the goal is simplicity for trusted users, not isolation between untrusted ones.
If your trust model is different, the trade-off is different, and this article is not your blueprint.
Day-2 — what to watch, what breaks#
The metrics worth alerting on are the ones about builds, because builds are where the user-visible pain shows up:
coderd_workspace_builds_total
coderd_workspace_build_duration_seconds
The first counts workspace builds; the second is a histogram of build duration. A spike in failed builds, or a climb in duration, is the early signal — usually a template that pulled a broken image, or a Postgres that’s gotten slow.
Where things break, and where to look:
- Workspace build fails. First place to look is the Coder container’s logs —
docker logs coderon the host — for the build error. If the template image can’t be pulled, that’s the cause. If builds are slow or stall, checkcoder-dbhealth withpg_isready -U coderfrom inside thecoder-dbcontainer; a Postgres that’s struggling shows up as build timeouts before it shows up anywhere else. - Workspace unreachable at its subdomain. First check the name doesn’t collide with an existing subdomain (
coder, or any other app already routed on this homelab). If it does, the request is hitting the more specific route and never reaches Coder. - Subdomain returns 404. Verify the workspace route is present in the edge proxy and that
CODER_WILDCARD_ACCESS_URLis set in Coder’s environment. Coder needs to know its own wildcard to construct the right URLs and to validate inbound workspace requests. - Workspace is up but terminal or SSH won’t connect. That’s the Tailscale-inside-Coder path above. Confirm the workspace container can reach
CODER_ACCESS_URL— usually a Docker-network attachment issue in the template — and that wildcard DNS resolves.
Frequently asked#
Is mounting docker.sock safe?#
For a trusted single user or a small team that mutually trusts each other, yes. For untrusted users, no — the socket is root-equivalent on the host. See the trust-boundary section above for the full trade-off.
How do workspaces get their own subdomains?#
Wildcard DNS plus CODER_WILDCARD_ACCESS_URL. Coder maps each workspace to its own subdomain. No per-workspace DNS entry, no per-workspace routing rule.
Why does the workspace terminal sometimes fail to connect?#
Almost always a workspace-to-CODER_ACCESS_URL reachability problem. Coder’s in-browser terminal and SSH ride its internal Tailscale (DERP) networking, which needs the workspace container to be able to route to the access URL. Attach the workspace to the Traefik network in the Coder template, verify wildcard DNS resolves, and the terminal connects.
Want this behind your team?#
The same Docker-socket-and-wildcard-subdomain pattern scales from a one-person homelab to a small, mutually-trusting team without changing shape. If you want a setup like this behind your own product — provisioned, monitored, integrated with your edge — look at what I offer or start a conversation.

