# Coder workspace template: Docker workspaces on the loca operator host. # # The provisioner is the `coder` server container itself (compose project # /home/loca/dev/coder), which is the only thing on this host that holds # /var/run/docker.sock. Workspaces created here are plain Docker containers on # that same daemon. # # Every construct below is derived from sources cited in README.md, not from # memory. Read that file before changing anything. terraform { required_version = ">= 1.0" required_providers { # coder/coder 2.x requires Coder Server >= v2.18.0 (provider "Version 2 # Upgrade Guide"). This deployment runs v2.35.3, so the 2.x line applies. # 2.18.0 is the latest release published on registry.terraform.io. coder = { source = "coder/coder" version = "~> 2.18" } # 4.5.0 is the latest release published on registry.terraform.io. docker = { source = "kreuzwerker/docker" version = "~> 4.5" } } } provider "coder" {} # No `host` argument: the provisioner runs inside the `coder` container, which # already has /var/run/docker.sock bind-mounted, so the provider's default # socket is the right one. Nothing needs to be exposed over TCP. provider "docker" {} data "coder_provisioner" "me" {} data "coder_workspace" "me" {} data "coder_workspace_owner" "me" {} # --------------------------------------------------------------------------- # Parameters # # The upstream Docker example ships no parameters; the upstream Kubernetes # example does, and this follows that idiom. Defaults are deliberately modest: # the host has 10 cores and ~22 GiB available RAM, but its 15 GiB of swap is # already ~97% consumed, so workspaces must not be sized to lean on swap. # # There is intentionally NO disk-size parameter. The home volume uses Docker's # `local` driver on an overlay2/extfs root (verified with `docker info`), which # cannot enforce a size quota without XFS project quotas. A disk parameter here # would be decorative and would mislead the operator; home disk usage is # reported through agent metadata instead. # --------------------------------------------------------------------------- data "coder_parameter" "cpu" { name = "cpu" display_name = "CPU" description = "CPU cores available to the workspace. This is a hard ceiling, not a reservation." type = "number" default = "2" icon = "/icon/memory.svg" mutable = true order = 1 option { name = "1 core" value = "1" } option { name = "2 cores (default)" value = "2" } option { name = "4 cores" value = "4" } } data "coder_parameter" "memory" { name = "memory" display_name = "Memory (GB)" description = "Memory ceiling in GB. Container swap is pinned to this value, so a workspace can never push the host further into swap." type = "number" default = "2" icon = "/icon/memory.svg" mutable = true order = 2 option { name = "2 GB (default)" value = "2" } option { name = "4 GB" value = "4" } option { name = "8 GB" value = "8" } } locals { username = data.coder_workspace_owner.me.name # Matches the image used by the upstream Docker example at the tag this # server runs (v2.35.3). Upstream `main` has since renamed the same image to # codercom/example-base:ubuntu. workspace_image = "codercom/enterprise-base:ubuntu" memory_mb = tonumber(data.coder_parameter.memory.value) * 1024 # Hostname the workspace must reach to find the Coder server, read from the # access URL (https://code.loca.zone on this deployment) instead of hardcoded. # regexall, not regex: regex throws a build-time error on no match, and an # unset access URL must degrade to "don't add a host entry", not fail. access_url_match = regexall("^[a-zA-Z][a-zA-Z0-9+.-]*://([^/:]+)", data.coder_workspace.me.access_url) access_host = length(local.access_url_match) > 0 ? local.access_url_match[0][0] : "" # Pin that hostname to the Docker gateway so agent -> server traffic reaches # the host's nginx directly rather than depending on hairpin NAT out to the # public IP and back. Verified reachable: an HTTPS request to /healthz with # code.loca.zone resolved to 172.17.0.1 returns 200 OK with a valid # certificate. Skipped for a bare IPv4 access URL, which the entrypoint # rewrite further down already handles. pin_access_host = local.access_host != "" && length(regexall("^[0-9]+(\\.[0-9]+){3}$", local.access_host)) == 0 } # --------------------------------------------------------------------------- # Agent # --------------------------------------------------------------------------- resource "coder_agent" "main" { arch = data.coder_provisioner.me.arch os = "linux" # "blocking" is the behaviour the provider docs recommend: the workspace is # not advertised as ready until the script below exits. startup_script_behavior = "blocking" # Deliberately no `set -e`. Every step here is optional, idempotent, and must # never fail the workspace build; failures are logged and stepped over. The # script ends with an explicit `exit 0` for the same reason. startup_script = <<-EOT set -u log() { printf '[startup] %s\n' "$*"; } # 1. Seed the home directory on first boot only. The home volume is # persistent, so this must not clobber an existing home. if [ ! -f "$HOME/.init_done" ]; then if [ -d /etc/skel ]; then cp -rT /etc/skel "$HOME" 2>/dev/null || log "skel copy skipped" fi touch "$HOME/.init_done" 2>/dev/null || log "could not mark init_done" fi # 2. code-server, installed into the persistent home so a workspace restart # does not re-download it. Skipped cleanly if curl or the network is # unavailable; the code-server app then just reports unhealthy. CS_DIR="$HOME/.local/share/code-server-dist" CS_BIN="$CS_DIR/bin/code-server" if [ ! -x "$CS_BIN" ]; then if command -v curl >/dev/null 2>&1; then log "installing code-server into $CS_DIR" curl -fsSL --max-time 180 https://code-server.dev/install.sh \ | sh -s -- --method=standalone --prefix="$CS_DIR" \ || log "code-server install failed; skipping" else log "curl not present; skipping code-server install" fi fi # 3. Start code-server unless something is already serving that port. if [ -x "$CS_BIN" ] && ! curl -fsS --max-time 2 http://localhost:13337/healthz >/dev/null 2>&1; then "$CS_BIN" --auth none --bind-addr 127.0.0.1:13337 \ >"$HOME/.code-server.log" 2>&1 & log "code-server started on 127.0.0.1:13337" fi exit 0 EOT # Lets users commit immediately. These take precedence over ~/.gitconfig. env = { GIT_AUTHOR_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name) GIT_AUTHOR_EMAIL = data.coder_workspace_owner.me.email GIT_COMMITTER_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name) GIT_COMMITTER_EMAIL = data.coder_workspace_owner.me.email } # Observability, mirroring the upstream Docker example. The host rows matter # on this box because the daemon, both wikis, and every workspace share it. metadata { display_name = "CPU Usage" key = "0_cpu_usage" script = "coder stat cpu" interval = 10 timeout = 1 } metadata { display_name = "RAM Usage" key = "1_ram_usage" script = "coder stat mem" interval = 10 timeout = 1 } metadata { display_name = "Home Disk" key = "2_home_disk" script = "coder stat disk --path $${HOME}" interval = 60 timeout = 1 } metadata { display_name = "CPU Usage (Host)" key = "3_cpu_usage_host" script = "coder stat cpu --host" interval = 10 timeout = 1 } metadata { display_name = "Memory Usage (Host)" key = "4_mem_usage_host" script = "coder stat mem --host" interval = 10 timeout = 1 } metadata { display_name = "Swap Usage (Host)" key = "5_swap_host" script = <<-EOT free -b | awk '/^Swap/ { printf("%.1f/%.1f GiB", $3/1024/1024/1024, $2/1024/1024/1024) }' EOT interval = 30 timeout = 1 } } # --------------------------------------------------------------------------- # Apps # # CRITICAL: every coder_app in this template MUST set `subdomain = false`. # # This deployment has no wildcard access URL. Nested names such as # deep.nested.code.loca.zone do resolve, but the certbot lineage covers only # code.loca.zone and wiki.code.loca.zone; a wildcard certificate would require # a DNS-01 challenge with DNS-provider credentials that are not configured on # this host. Subdomain apps would therefore be served with a name-mismatched # certificate and fail in the browser. Path-based apps # (/@user/workspace.main/apps/) are the only working mode here. # # The upstream example pulls code-server in from the # registry.coder.com/coder/code-server/coder module. This template declares the # app directly instead, for two reasons: a module-owned coder_app is not under # our control and could ship subdomain = true, and modules add a provision-time # dependency on registry.coder.com. # --------------------------------------------------------------------------- resource "coder_app" "code_server" { agent_id = coder_agent.main.id slug = "code-server" display_name = "code-server" icon = "/icon/code.svg" url = "http://localhost:13337/?folder=/home/coder" share = "owner" order = 1 # See the block comment above. Do not change this to true. subdomain = false healthcheck { url = "http://localhost:13337/healthz" interval = 5 threshold = 12 } } # --------------------------------------------------------------------------- # Infrastructure # --------------------------------------------------------------------------- resource "docker_image" "workspace" { name = local.workspace_image # Every workspace on this host shares this image. Deleting one workspace must # not pull it out from under the others. keep_locally = true } resource "docker_volume" "home" { # Keyed to the immutable workspace id rather than its name, so stopping, # starting, renaming, or rebuilding a workspace all reattach the same volume # instead of silently discarding /home/coder. name = "coder-${data.coder_workspace.me.id}-home" lifecycle { # Never let an attribute diff (labels, driver options) destroy user data. ignore_changes = all } labels { label = "coder.owner" value = data.coder_workspace_owner.me.name } labels { label = "coder.owner_id" value = data.coder_workspace_owner.me.id } labels { label = "coder.workspace_id" value = data.coder_workspace.me.id } # Goes stale if the workspace is renamed, but useful when hunting dangling # volumes by hand. labels { label = "coder.workspace_name_at_creation" value = data.coder_workspace.me.name } } resource "docker_container" "workspace" { count = data.coder_workspace.me.start_count image = docker_image.workspace.image_id # lower() because Docker rejects uppercase in container names. name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}" # Makes the shell prompt friendly: coder@my-workspace:~$ hostname = data.coder_workspace.me.name # Rewrites a loopback access URL to the Docker gateway. A no-op on this # deployment, where CODER_ACCESS_URL is https://code.loca.zone. entrypoint = ["sh", "-c", replace(coder_agent.main.init_script, "/localhost|127\\.0\\.0\\.1/", "host.docker.internal")] env = ["CODER_AGENT_TOKEN=${coder_agent.main.token}"] # Hard ceilings from the parameters above. memory_swap is set equal to memory, # which disables container swap entirely: the host's 15 GiB swap is already # ~97% used, so a workspace must not be allowed to add to it. cpus = tostring(data.coder_parameter.cpu.value) memory = local.memory_mb memory_swap = local.memory_mb # SECURITY BOUNDARY: /var/run/docker.sock is deliberately NOT mounted here. # The socket stays with the `coder` server container, which needs it to # provision workspaces. Handing it to a workspace would give anyone inside # that workspace root-equivalent control of the whole host, including the # nginx/certbot stack, the Postgres container behind Coder, and every other # workspace. Docker-in-workspace, if it is ever wanted, must be done with a # sysbox/rootless runtime, not by exposing this socket. volumes { container_path = "/home/coder" volume_name = docker_volume.home.name read_only = false } host { host = "host.docker.internal" ip = "host-gateway" } # See local.pin_access_host: keeps agent -> server traffic on the host # gateway instead of routing out to the public IP and back. dynamic "host" { for_each = local.pin_access_host ? [local.access_host] : [] iterator = h content { host = h.value ip = "host-gateway" } } labels { label = "coder.owner" value = data.coder_workspace_owner.me.name } labels { label = "coder.owner_id" value = data.coder_workspace_owner.me.id } labels { label = "coder.workspace_id" value = data.coder_workspace.me.id } labels { label = "coder.workspace_name" value = data.coder_workspace.me.name } } # Surfaces the limits actually applied, so the dashboard does not require an # operator to shell in and run `docker inspect` to see them. resource "coder_metadata" "workspace" { count = data.coder_workspace.me.start_count resource_id = docker_container.workspace[0].id item { key = "image" value = local.workspace_image } item { key = "cpu limit" value = "${data.coder_parameter.cpu.value} core(s)" } item { key = "memory limit" value = "${data.coder_parameter.memory.value} GB (swap disabled)" } item { key = "home volume" value = docker_volume.home.name } item { key = "app routing" value = "path-based (`subdomain = false`); no wildcard access URL on this deployment" } }