From 1fc121ed7e9b99fbe85fa41dd66ea497439aa0d5 Mon Sep 17 00:00:00 2001 From: Antigravity Date: Thu, 20 Aug 2026 10:50:27 +0200 Subject: [PATCH] coder: initial import of deployed stack (v2.35.3 compose, docker template, SSOT vault) --- .gitignore | 8 + config/runtime-version.txt | 1 + deploy/code.nginx.conf | 78 +++++ docker-compose.yml | 50 +++ q5vault/architecture/index.md | 30 ++ q5vault/architecture/runtime-topology.md | 107 ++++++ q5vault/index.md | 112 ++++++ q5vault/operations/auth-model.md | 84 +++++ q5vault/operations/index.md | 42 +++ q5vault/operations/install-upgrade.md | 99 ++++++ q5vault/operations/verification.md | 163 +++++++++ q5vault/operations/workspaces.md | 149 ++++++++ q5vault/reference/config-paths.md | 153 ++++++++ q5vault/reference/index.md | 28 ++ q5vault/reference/limits.md | 87 +++++ quartz.config.coder.yaml | 224 ++++++++++++ templates/docker/.terraform.lock.hcl | 44 +++ templates/docker/README.md | 294 ++++++++++++++++ templates/docker/main.tf | 423 +++++++++++++++++++++++ 19 files changed, 2176 insertions(+) create mode 100644 .gitignore create mode 100644 config/runtime-version.txt create mode 100644 deploy/code.nginx.conf create mode 100644 docker-compose.yml create mode 100644 q5vault/architecture/index.md create mode 100644 q5vault/architecture/runtime-topology.md create mode 100644 q5vault/index.md create mode 100644 q5vault/operations/auth-model.md create mode 100644 q5vault/operations/index.md create mode 100644 q5vault/operations/install-upgrade.md create mode 100644 q5vault/operations/verification.md create mode 100644 q5vault/operations/workspaces.md create mode 100644 q5vault/reference/config-paths.md create mode 100644 q5vault/reference/index.md create mode 100644 q5vault/reference/limits.md create mode 100644 quartz.config.coder.yaml create mode 100644 templates/docker/.terraform.lock.hcl create mode 100644 templates/docker/README.md create mode 100644 templates/docker/main.tf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..83fb8d1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.env +.admin-credentials +q5vault/.obsidian/ + +# terraform working dirs; the committed .terraform.lock.hcl is intentional +templates/**/.terraform/ +templates/**/terraform.tfstate +templates/**/terraform.tfstate.backup diff --git a/config/runtime-version.txt b/config/runtime-version.txt new file mode 100644 index 0000000..c9a7b2e --- /dev/null +++ b/config/runtime-version.txt @@ -0,0 +1 @@ +v2.35.3 diff --git a/deploy/code.nginx.conf b/deploy/code.nginx.conf new file mode 100644 index 0000000..f9aacd7 --- /dev/null +++ b/deploy/code.nginx.conf @@ -0,0 +1,78 @@ +server { + listen 443 ssl; + server_name code.loca.zone; + + include snippets/security-headers-app.conf; + client_max_body_size 128m; + + # No Authelia gate on purpose. Coder owns identity; workspace agents and the + # `coder` CLI authenticate with bearer tokens, not the authelia_session + # cookie, so an auth_request gate would break agent connectivity. + location / { + proxy_pass http://127.0.0.1:52226; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_buffering off; + proxy_read_timeout 86400; + } + + ssl_certificate /etc/letsencrypt/live/code.loca.zone/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/code.loca.zone/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; +} + +server { + listen 443 ssl; + server_name wiki.code.loca.zone; + + include snippets/security-headers.conf; + root /home/loca/dev/wikis/code/current; + index index.html; + + location ~* \.(css|js|png|webp|ico|json|woff2?|svg|map)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + location / { + try_files $uri $uri.html $uri/ =404; + } + + ssl_certificate /etc/letsencrypt/live/code.loca.zone/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/code.loca.zone/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; +} + +server { + listen 80; + server_name code.loca.zone; + + location /.well-known/acme-challenge/ { + root /var/www/html; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 80; + server_name wiki.code.loca.zone; + + location /.well-known/acme-challenge/ { + root /var/www/html; + } + + location / { + return 301 https://$host$request_uri; + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..dbee6ae --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,50 @@ +services: + database: + image: postgres:17 + container_name: coder-database + restart: unless-stopped + environment: + POSTGRES_USER: coder + POSTGRES_PASSWORD: ${CODER_PG_PASSWORD} + POSTGRES_DB: coder + volumes: + - coder_pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U coder -d coder"] + interval: 5s + timeout: 5s + retries: 12 + mem_limit: 2g + + coder: + image: ghcr.io/coder/coder:v2.35.3 + container_name: coder + restart: unless-stopped + depends_on: + database: + condition: service_healthy + ports: + - "127.0.0.1:52226:7080" + environment: + CODER_HTTP_ADDRESS: 0.0.0.0:7080 + CODER_ACCESS_URL: https://code.loca.zone + CODER_PG_CONNECTION_URL: postgresql://coder:${CODER_PG_PASSWORD}@database:5432/coder?sslmode=disable + CODER_TELEMETRY_ENABLE: "false" + # No CODER_WILDCARD_ACCESS_URL. Nested names such as foo.code.loca.zone + # DO resolve via the *.loca.zone wildcard; the actual blocker is TLS + # issuance: this lineage covers only code.loca.zone and + # wiki.code.loca.zone, and a *.code.loca.zone certificate needs DNS-01 + # validation with DNS-provider credentials that are not configured here. + # Path-based workspace apps and `coder port-forward` are used instead. + group_add: + # host gid of /var/run/docker.sock; required so the built-in provisioner + # can build docker-template workspaces. + - "105" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - coder_home:/home/coder + mem_limit: 8g + +volumes: + coder_pgdata: + coder_home: diff --git a/q5vault/architecture/index.md b/q5vault/architecture/index.md new file mode 100644 index 0000000..4a0fded --- /dev/null +++ b/q5vault/architecture/index.md @@ -0,0 +1,30 @@ +--- +title: "🧱 Architecture" +description: "How the Coder control plane, its private postgres, its provisioner and its two traffic planes fit together on the loca operator host." +source: "verified deployment on the loca operator host, 2026-08-19" +reviewed: 2026-08-19 +--- + +# 🧱 Architecture + +The deployed shape in one paragraph: nginx terminates TLS for `code.loca.zone` and proxies everything to a loopback-published Coder container, which stores all state in a private postgres container and builds workspaces by talking to the host docker socket. No component of this stack is reachable from the network except through the two nginx vhosts. + +## 🧭 Two traffic planes, one vhost + +- human plane: browser to dashboard, web terminal and path-based workspace apps, authenticated by the host-scoped `coder_session_token` cookie +- machine plane: the `coder` CLI, workspace agents and DERP relay traffic, authenticated by the `Coder-Session-Token` header over long-lived websockets +- both planes enter through the same `code.loca.zone:443` server block, which is exactly why an `auth_request` gate cannot be applied to only one of them; see [[operations/auth-model]] + +## πŸ—ΊοΈ Pages + +| Need | Page | +| --- | --- | +| Diagram, exact container, volume and port wiring | [[architecture/runtime-topology]] | +| Path, port and env var lookup table | [[reference/config-paths]] | +| What is deliberately not enabled | [[reference/limits]] | +| Operational commands | [[operations/index]] | + +## πŸ“š Sources + +- https://coder.com/docs/admin/networking +- https://coder.com/docs/reference/api/authentication diff --git a/q5vault/architecture/runtime-topology.md b/q5vault/architecture/runtime-topology.md new file mode 100644 index 0000000..94184b5 --- /dev/null +++ b/q5vault/architecture/runtime-topology.md @@ -0,0 +1,107 @@ +--- +title: "πŸ—ΊοΈ Runtime topology" +description: "Exact wiring of the Coder stack: nginx 443 to loopback 52226 to container coder, private postgres over the compose network, the docker.sock provisioner path, and the separate agent and CLI path." +source: "verified deployment on the loca operator host, 2026-08-19" +reviewed: 2026-08-19 +--- + +# πŸ—ΊοΈ Runtime topology + +## 🧩 Diagram + +```mermaid +flowchart TD + browser["Browser: dashboard, web terminal, path-based apps"] + cli["coder CLI and workspace agents"] + nginx["nginx 443 vhost code.loca.zone, TLS lineage code.loca.zone, no auth_request"] + wikivhost["nginx 443 vhost wiki.code.loca.zone, static root wikis/code/current"] + publish["host publish 127.0.0.1:52226 to container 7080"] + coder["container coder, ghcr.io/coder/coder:v2.35.3, listens 0.0.0.0:7080, mem_limit 8g"] + db["container coder-database, postgres:17, volume coder_pgdata, mem_limit 2g"] + sock["host /var/run/docker.sock, gid 105, bind-mounted into coder"] + ws["workspace containers built by the built-in provisioner"] + + browser -->|https 443| nginx + cli -->|https 443 with Coder-Session-Token| nginx + browser -->|https 443| wikivhost + nginx -->|proxy_pass, http 1.1, Upgrade, read timeout 86400| publish + publish --> coder + coder -->|compose network, database port 5432, sslmode disable| db + coder -->|terraform docker provider| sock + sock --> ws + ws -->|agent websockets back to CODER_ACCESS_URL| nginx +``` + +## 🌐 Browser and CLI path into the server + +- Browser or `coder` CLI to `https://code.loca.zone` + - nginx server block, from `/home/loca/dev/coder/deploy/code.nginx.conf` + - `listen 443 ssl`, `server_name code.loca.zone` + - `include snippets/security-headers-app.conf` + - `client_max_body_size 128m` so template tarball pushes are not truncated + - no `auth_request`, no Authelia include: deliberate, see [[operations/auth-model]] + - `location /` + - `proxy_pass http://127.0.0.1:52226` + - `proxy_http_version 1.1` + - `proxy_set_header Upgrade $http_upgrade` + - `proxy_set_header Connection $connection_upgrade` + - `proxy_set_header Host $host`, `X-Real-IP $remote_addr`, `X-Forwarded-For $proxy_add_x_forwarded_for`, `X-Forwarded-Proto $scheme` + - `proxy_buffering off` so the web terminal and log streams are not held in nginx buffers + - `proxy_read_timeout 86400` because the dashboard, web terminal and agent websockets are long-lived; upstream requires that any reverse proxy between the control plane and clients or agents supports websockets + - TLS material `/etc/letsencrypt/live/code.loca.zone/fullchain.pem` and `privkey.pem` + - one lineage named `code.loca.zone` covering `code.loca.zone` and `wiki.code.loca.zone`, expiring 2026-11-17 + - port 80 blocks exist for both hostnames: `/.well-known/acme-challenge/` from `/var/www/html`, everything else `301` to https +- Wiki vhost `wiki.code.loca.zone` + - `include snippets/security-headers.conf`, static `root /home/loca/dev/wikis/code/current`, `index index.html` + - immutable one-year cache for hashed assets, `try_files $uri $uri.html $uri/ =404` for pages + - shares the same certificate lineage; it is public by omission because Authelia `default_policy` is `bypass` + +## πŸ“¦ Containers, volumes and the compose network + +- compose project root `/home/loca/dev/coder`, so the compose project name is `coder` +- service `coder`, container name `coder` + - image `ghcr.io/coder/coder:v2.35.3`, `restart: unless-stopped`, `mem_limit: 8g` + - `depends_on` service `database` with `condition: service_healthy`, so the server never starts against an unready database + - volumes: `/var/run/docker.sock:/var/run/docker.sock` and named volume `coder_home` at `/home/coder` + - `group_add: "105"`, the host gid of the docker socket, read at write time with `getent group docker | cut -d: -f3` +- service `database`, container name `coder-database` + - image `postgres:17`, `restart: unless-stopped`, `mem_limit: 2g` + - named volume `coder_pgdata` at `/var/lib/postgresql/data` + - healthcheck `pg_isready -U coder -d coder` every 5s, timeout 5s, 12 retries + - publishes no host port at all; the only route to it is the compose network hostname `database` on port 5432 +- named volumes as declared in `docker-compose.yml`: `coder_pgdata`, `coder_home` + - compose creates named volumes prefixed with the project name, so on the docker side they appear as `coder_coder_pgdata` and `coder_coder_home` + - always address them through `sudo -n docker compose` from `/home/loca/dev/coder` rather than by raw docker volume name +- the compose network is the only path between the two containers; nothing else on the host can reach postgres + +## πŸ”Œ Why 0.0.0.0:7080 inside and loopback outside + +- `CODER_HTTP_ADDRESS=0.0.0.0:7080` binds all interfaces of the container's own network namespace + - a container-local `127.0.0.1` bind would be unreachable from the host publish path, so the server would appear dead even though it is healthy +- the publish spec `"127.0.0.1:52226:7080"` is the actual exposure boundary + - docker opens the port on the host loopback only, so the sole public route is nginx + - `52226` sits inside the loca.zone `51000-53000` application range and does not collide with `52222` pwiz, `52223` reserved for pwiz, or `52224` cdsr +- consequence: `CODER_TLS_ENABLE` is never set; nginx terminates TLS, and `CODER_ACCESS_URL=https://code.loca.zone` tells Coder the external scheme and hostname to advertise to browsers and agents + +## πŸ—οΈ Provisioner path to workspace containers + +- the built-in provisioner runs inside the `coder` container; there is no external provisioner daemon +- template Terraform uses the `kreuzwerker/docker` provider, which talks to the bind-mounted `/var/run/docker.sock` +- `group_add: "105"` gives the non-root container user access to that socket; upstream's docker install guide sets this from `getent group docker | cut -d: -f3` +- workspace containers are therefore siblings on the host docker daemon, not children of the `coder` container +- the socket is never passed into a workspace; the risk model and its containment list live in [[operations/workspaces]] + +## πŸ€– Agent and CLI plane + +- a workspace agent dials `CODER_ACCESS_URL`, that is `https://code.loca.zone`, and holds open `/api/v2` websockets for the duration of the workspace +- the `coder` CLI authenticates with the `Coder-Session-Token` header, never with a cookie +- upstream requires that all clients and agents can reach the server over HTTP or HTTPS and that any proxy in between supports websockets; `proxy_read_timeout 86400` plus `proxy_buffering off` satisfy that here +- relayed connections use the server's built-in DERP service over the same 443 vhost, so anything that interrupts unauthenticated-by-cookie requests interrupts workspace connectivity too + +## πŸ“š Sources + +- https://coder.com/docs/install/docker +- https://coder.com/docs/admin/setup +- https://coder.com/docs/admin/networking +- https://coder.com/docs/reference/api/authentication +- https://docs.docker.com/reference/compose-file/volumes/ diff --git a/q5vault/index.md b/q5vault/index.md new file mode 100644 index 0000000..ac2bb42 --- /dev/null +++ b/q5vault/index.md @@ -0,0 +1,112 @@ +--- +title: "πŸ—οΈ Coder SSOT" +description: "Single source of truth for the self-hosted Coder control plane at https://code.loca.zone, its private postgres, its deliberate auth model, and its operations." +source: "verified deployment on the loca operator host, 2026-08-19; first user, template and first workspace verified 2026-08-20" +reviewed: 2026-08-20 +--- + +# πŸ—οΈ Coder SSOT + +Self-hosted Coder control plane (a workspace manager, not an editor) running as a pinned docker compose stack in `/home/loca/dev/coder`, reverse-proxied by Nginx, published loopback-only on `127.0.0.1:52226`, backed by a private postgres container. Coder owns its own identity, so this vhost is deliberately not behind Authelia. + +Sibling stack: the single-user browser IDE at `cdsr.loca.zone` is a different deployment and is documented in its own wiki. + +## πŸš€ Quick start + +- open: https://code.loca.zone + - the Coder dashboard runs its own login; there is no `auth.loca.zone` redirect on this host + - wiki (public, no login): https://wiki.code.loca.zone +- stack root: `/home/loca/dev/coder` + - compose: `docker-compose.yml`, image pin `ghcr.io/coder/coder:v2.35.3`, database `postgres:17` + - runtime pin: `config/runtime-version.txt` contains `v2.35.3` + - nginx source of truth: `deploy/code.nginx.conf`, installed as `/etc/nginx/sites-available/code.loca.zone` + - secrets: `.env` (mode 600, git-ignored); it holds `CODER_PG_PASSWORD` and its contents are never read into docs + - admin login: `.admin-credentials` (mode 600, git-ignored); it holds the initial Owner login and a CLI session token, and its contents are never read into docs, a ticket or a chat message +- lifecycle, all of it needs `sudo -n` because `loca` is not in the `docker` group: + - bring up: `cd /home/loca/dev/coder && sudo -n docker compose up -d` + - status: `sudo -n docker compose ps` + - logs: `sudo -n docker compose logs -f coder` + - restart server only: `sudo -n docker compose restart coder` + - stop: `sudo -n docker compose down` (containers go, the postgres volume stays) +- loopback health, from the host shell: + - `curl -sf http://127.0.0.1:52226/healthz` returns `OK` + - `curl -sf http://127.0.0.1:52226/api/v2/buildinfo` returns version `v2.35.3+65e2bfb`, `dashboard_url` `https://code.loca.zone`, telemetry `false` +- port `52226` is the only host-visible socket, bound on `127.0.0.1` inside the `51000-53000` loca.zone app range; container-internal listener is `0.0.0.0:7080` + +## πŸ” Auth model in two lines + +- Coder authenticates users itself: the browser gets a host-scoped `coder_session_token` cookie, while the `coder` CLI and every workspace agent authenticate with the `Coder-Session-Token` header, so nginx passes all traffic through untouched with no `auth_request`. +- Authelia `default_policy` is `bypass` and no rule names `code.loca.zone`, so nothing gates this vhost; adding a rule would answer every cookie-less agent and CLI request with a 302 to `auth.loca.zone` and break the deployment. Full reasoning: [[operations/auth-model]]. + +## πŸ‘€ Bootstrapped state and the operator's next actions + +- setup is done; this is no longer a deployment waiting for a first pass: + - Owner account `loca` (`admin@loca.zone`) exists, is active and holds the Owner role, so `GET /api/v2/users/first` answers `200` + - the default organization is `coder` + - the `docker` template is pushed and its version is active, and one workspace (`ws1`) is running against it; see [[operations/workspaces]] +- how that account was created, for the record: `POST /api/v2/users/first` over loopback, with a randomly generated 32-character password written straight into `/home/loca/dev/coder/.admin-credentials` (mode 600, git-ignored) and never displayed, logged or copied anywhere else +- the operator's actual next actions, in order: + - sign in at https://code.loca.zone and rotate that generated password in Account then Security, so the only stored copy stops being the live credential + - create further workspaces from the `docker` template, respecting the memory gate in [[reference/limits]] +- Coder OSS has no open signup: `POST /api/v2/users` requires authentication, so every later account is admin-created only (`coder users create`, or Users then New user in the dashboard) + +## 🧩 Reaching a workspace + +- workspace app, path-based because `coder_app` keeps `subdomain = false`: `https://code.loca.zone/@/./apps//` + - the trailing slash matters; the server redirects when it is missing +- web terminal: `https://code.loca.zone/@/./terminal` +- arbitrary ports from your own machine: `coder port-forward --tcp 8000:8080`, or `coder ssh ` plus normal SSH forwarding +- dashboard port forwarding and `subdomain = true` apps are unavailable here because no wildcard access URL is configured; see [[reference/limits]] and [[operations/workspaces]] + +## ⬆️ Upgrade in four steps + +```bash +cd /home/loca/dev/coder +# 1. snapshot first: upstream states Coder does not support rollbacks +sudo -n docker compose exec -T database pg_dump -U coder coder | gzip > /home/loca/dev/coder/backup-$(date +%F).sql.gz +# 2. edit the image tag in docker-compose.yml and config/runtime-version.txt +sudo -n docker compose pull coder && sudo -n docker compose up -d coder +curl -sf http://127.0.0.1:52226/api/v2/buildinfo +``` + +Full procedure, including restore: [[operations/install-upgrade]]. + +## βœ… Do / don't + +- do: pin the image tag and keep `config/runtime-version.txt` in step with `docker-compose.yml` +- do: keep postgres private; it publishes no host port and is reachable only over the compose network +- do: record `free -m` before and after every workspace build; this host runs about ten co-tenant stacks and the gate is documented in [[reference/limits]] +- do: leave TLS to nginx; the container speaks plain HTTP on loopback only +- don't: add an Authelia rule for `code.loca.zone`; it would break agents, the CLI and DERP traffic +- don't: pass `/var/run/docker.sock` into a workspace container; the socket stays on the control plane +- don't: create Coder accounts for other people; this is a single-admin deployment with the Owner authoring every template +- don't: put secrets in this repo; `.env` is the only home for `CODER_PG_PASSWORD` and it is git-ignored +- don't: commit, paste or quote `.admin-credentials` anywhere; it stays at mode 600 in a git-ignored path, and its only legitimate use is one interactive sign-in before the password is rotated + +## Start here + +| Need | Page | +| --- | --- | +| Architecture section hub | [[architecture/index]] | +| Understand the deployed shape | [[architecture/runtime-topology]] | +| Operations section hub | [[operations/index]] | +| Bring up, upgrade, back up | [[operations/install-upgrade]] | +| Understand or change auth | [[operations/auth-model]] | +| Build templates and reach workspaces | [[operations/workspaces]] | +| Re-run the proof checks | [[operations/verification]] | +| Reference section hub | [[reference/index]] | +| Look up a path, port or env var | [[reference/config-paths]] | +| Know what is deliberately off | [[reference/limits]] | + +## πŸ“š Sources + +- https://coder.com/docs/install/docker +- https://coder.com/docs/admin/setup +- https://coder.com/docs/admin/networking/port-forwarding +- https://coder.com/docs/install/upgrade +- https://coder.com/docs/admin/users +- https://coder.com/docs/reference/api/users +- https://coder.com/docs/reference/api/authentication +- https://github.com/coder/coder +- 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 diff --git a/q5vault/operations/auth-model.md b/q5vault/operations/auth-model.md new file mode 100644 index 0000000..6bd056d --- /dev/null +++ b/q5vault/operations/auth-model.md @@ -0,0 +1,84 @@ +--- +title: "πŸ” Auth model" +description: "Why code.loca.zone is deliberately not behind Authelia, why a browser-only gate is impossible, why the cookies do not collide, and how the initial Owner was bootstrapped over loopback." +source: "verified deployment on the loca operator host, 2026-08-19; first-user bootstrap verified 2026-08-20" +reviewed: 2026-08-20 +--- + +# πŸ” Auth model + +## 🧾 Decision + +- Coder is the identity provider for its own deployment; the `code.loca.zone` vhost has no `auth_request` gate and no Authelia access rule +- this is the only loca.zone application host that is intentionally ungated at the proxy, and the reason is protocol, not laziness +- Authelia was not modified for this stack: its `default_policy` is `bypass`, so a host with no rule is simply passed through. Adding nothing was the correct action + +## πŸ’₯ Precise failure mode if an Authelia rule were added + +- the site-wide gate answers any request without a valid `authelia_session` cookie with a `302` to `https://auth.loca.zone` +- what that breaks, concretely: + - the `coder` CLI, which authenticates with the `Coder-Session-Token` header and has no cookie jar; `coder login`, `coder templates push`, `coder ssh` and `coder port-forward` all receive a redirect where they expect JSON + - workspace agents, which dial `CODER_ACCESS_URL` and hold long-lived `/api/v2` websockets; a redirect on the upgrade request means the agent never connects and the workspace sits in a connecting state forever + - relayed workspace traffic, since the built-in DERP relay rides the same vhost +- upstream is explicit that all clients and agents must be able to reach the server over HTTP or HTTPS and that any proxy in between must support websockets; an unconditional redirect violates both + +## 🚫 Why a partial browser-only gate is impossible + +- the dashboard is a single-page application served from the same origin as the API; the browser and the agents both call `/api/v2/...` +- there is no stable path prefix that separates human traffic from machine traffic, and no header an nginx gate could trust to tell them apart, since a browser can be made to send anything a header-based rule would key on +- gating `/` while exempting `/api/v2` would exempt the entire attack surface and leave only the static bundle protected, which is security theatre +- the workspace app and web terminal routes (`/@user/workspace.agent/apps/...`, `/@user/workspace.agent/terminal`) are proxied by the same server and are used by both planes +- conclusion: either the whole vhost is gated, which breaks the product, or none of it is, which is what is deployed + +## πŸͺ Why the cookies do not collide + +- Authelia's session cookie is domain-scoped to `loca.zone`, so `authelia_session` is presented on requests to `code.loca.zone` as well +- Coder ignores it; it reads only its own `coder_session_token`, which Coder sets host-scoped for `code.loca.zone` +- nothing writes both names, so no overwrite, no truncation and no cross-application session confusion +- practical consequence: being logged into `auth.loca.zone` grants nothing on this host, and being logged into Coder grants nothing on the Authelia-gated hosts + +## πŸ‘€ First-user bootstrap is done + +- state now, verified 2026-08-20: the initial Owner exists, so nothing here is waiting on a setup form + - username `loca`, email `admin@loca.zone`, role Owner, status active + - organization `coder`, the default one, id `9c28ebd1-4fe6-446b-83e2-f723954fec10` + - created with `POST /api/v2/users/first` against `http://127.0.0.1:52226`, so the credential never traversed the public vhost + - `GET /api/v2/users/first` flipped from `404` to `200` at that moment, which is upstream's own existence check for the initial user +- how the password was handled, which is the only acceptable shape for an agent-run bootstrap: + - randomly generated, 32 characters, written straight into `/home/loca/dev/coder/.admin-credentials` at mode 600 in a git-ignored path + - never displayed on a terminal, in a log, in a ticket, in a chat message or in this wiki, and never read back into any document + - that file also holds the session token minted by the same call, which is the second reason it stays at 600 and out of git +- rotation is the operator's responsibility and nobody else's: sign in at https://code.loca.zone and set a new password under Account then Security, after which the stored copy is stale by design +- what was verified alongside the bootstrap, none of it credential-bearing: + - `curl -sSI https://code.loca.zone/` returns `200` with no auth redirect + - `curl -sf http://127.0.0.1:52226/api/v2/buildinfo` reports `v2.35.3+65e2bfb` with `dashboard_url` `https://code.loca.zone` + +## πŸ”’ No open signup + +- `POST /api/v2/users/first` is the only account-creating endpoint reachable without authentication and it exists solely to create the initial Owner +- `POST /api/v2/users` requires authentication, so every later account is created by an admin, either with `coder users create` or through Users then New user in the dashboard +- there is no self-service registration in Coder OSS with password auth; a user appears only because an admin made it +- password reset for the initial Owner is a host-side operation: `coder reset-password ` only works when run on the machine running the control plane, which here means inside the `coder` container + +## πŸ”­ Future upgrade path: Authelia as an OIDC provider + +Not implemented, and it requires operator approval because it means editing the shared Authelia deployment. + +- shape of the change: + - register a new OIDC client in Authelia for Coder, with redirect URI `https://code.loca.zone/api/v2/users/oidc/callback` + - set `CODER_OIDC_ISSUER_URL`, `CODER_OIDC_CLIENT_ID`, `CODER_OIDC_CLIENT_SECRET` and, if wanted, `CODER_OIDC_EMAIL_DOMAIN` on the `coder` service + - optionally add `offline_access` to `CODER_OIDC_SCOPES` so sessions survive access-token expiry + - optionally set `CODER_DISABLE_PASSWORD_AUTH=true` once OIDC login is proven +- what it does and does not change: + - it unifies identity inside Coder, at the application layer, where the CLI and agents keep using their bearer tokens + - it does not, and must not, add an nginx `auth_request` gate; the failure modes above still apply +- prerequisites the operator must approve: a new Authelia client definition, a client secret placed in `.env`, and a restart of both stacks + +## πŸ“š Sources + +- https://coder.com/docs/reference/api/authentication +- https://coder.com/docs/reference/api/users +- https://coder.com/docs/admin/users +- https://coder.com/docs/admin/users/oidc-auth +- https://coder.com/docs/admin/networking +- https://coder.com/docs/admin/setup diff --git a/q5vault/operations/index.md b/q5vault/operations/index.md new file mode 100644 index 0000000..d49edd4 --- /dev/null +++ b/q5vault/operations/index.md @@ -0,0 +1,42 @@ +--- +title: "πŸ› οΈ Operations" +description: "Operational entry point for the Coder stack: bring-up and upgrade, the auth model, workspace and template work, and the verification checklist." +source: "verified deployment on the loca operator host, 2026-08-19" +reviewed: 2026-08-19 +--- + +# πŸ› οΈ Operations + +Every command on these pages runs from `/home/loca/dev/coder` and every docker invocation needs `sudo -n`, because `loca` is deliberately not a member of the `docker` group. + +## 🧭 Pick a page + +| Situation | Page | +| --- | --- | +| Stack is down, needs starting, or a new image tag is out | [[operations/install-upgrade]] | +| Someone asks why this host is not behind Authelia, or how the Owner account was bootstrapped | [[operations/auth-model]] | +| A template needs writing or pushing, or a workspace app must be reached | [[operations/workspaces]] | +| Proving the deployment is healthy after any change | [[operations/verification]] | +| Looking up a path, container, volume, port or env var | [[reference/config-paths]] | +| Explaining a missing feature | [[reference/limits]] | + +## ⚑ Five commands that cover most days + +- `cd /home/loca/dev/coder && sudo -n docker compose ps` +- `sudo -n docker compose logs -f coder` +- `curl -sf http://127.0.0.1:52226/healthz` +- `curl -sf http://127.0.0.1:52226/api/v2/buildinfo` +- `free -m` before and after anything that builds a workspace + +## 🚨 Order of debugging when the dashboard is unreachable + +- prove the container is up: `sudo -n docker compose ps` shows `coder` running and `coder-database` healthy +- prove the server itself: `curl -sf http://127.0.0.1:52226/healthz` returns `OK` +- prove the proxy: `sudo -n nginx -t`, then `curl -sSI https://code.loca.zone/` +- a `302` to `auth.loca.zone` from this vhost means an Authelia rule was added by mistake; remove it, see [[operations/auth-model]] +- a `502` with a healthy loopback means nginx lost the upstream; check the publish spec is still `127.0.0.1:52226:7080` + +## πŸ“š Sources + +- https://coder.com/docs/install/docker +- https://coder.com/docs/admin/setup diff --git a/q5vault/operations/install-upgrade.md b/q5vault/operations/install-upgrade.md new file mode 100644 index 0000000..4f9f584 --- /dev/null +++ b/q5vault/operations/install-upgrade.md @@ -0,0 +1,99 @@ +--- +title: "⬆️ Install, upgrade, backup" +description: "Bring-up, status, logs, the pinned-image upgrade procedure, and the postgres backup and restore commands for the Coder stack in /home/loca/dev/coder." +source: "verified deployment on the loca operator host, 2026-08-19" +reviewed: 2026-08-19 +--- + +# ⬆️ Install, upgrade, backup + +## πŸ”‘ Ground rules + +- every docker command needs `sudo -n`; user `loca` is not in the `docker` group, which is intentional because docker group membership grants root-level privileges on the host +- always run compose from the project root so the project name, `.env` and volume scoping resolve: `cd /home/loca/dev/coder` +- `.env` holds `CODER_PG_PASSWORD` at mode 600 and is git-ignored; never echo it, copy it into a template, or paste it into a ticket +- the deployed version is pinned twice on purpose: the image tag in `docker-compose.yml` and the plain-text pin in `config/runtime-version.txt` (currently `v2.35.3`). Both change together or neither changes + +## πŸš€ Bring-up + +```bash +cd /home/loca/dev/coder +sudo -n docker compose up -d +sudo -n docker compose ps +curl -sf http://127.0.0.1:52226/healthz +``` + +- `database` starts first and `coder` waits on its healthcheck (`pg_isready -U coder -d coder`), so a cold start can take a few seconds before `/healthz` answers +- expected steady state: container `coder` running, container `coder-database` running and healthy, one published socket `127.0.0.1:52226` +- full proof sequence: [[operations/verification]] + +## πŸ“‹ Status, logs, stop + +```bash +cd /home/loca/dev/coder +sudo -n docker compose ps # both containers, database health +sudo -n docker compose logs -f coder # server log stream +sudo -n docker compose logs --tail 200 database +sudo -n docker compose restart coder # server only, database untouched +sudo -n docker compose stop # keep containers, stop processes +sudo -n docker compose down # remove containers, keep named volumes +``` + +- `down` never removes `coder_pgdata` or `coder_home`; only an explicit `down -v` would, and that destroys every user, template and workspace record +- restarting `coder` alone is safe and is the normal way to pick up an environment change in `docker-compose.yml` + +## ⬆️ Upgrade the pinned image + +Upstream states plainly that Coder does not support rollbacks, so the database snapshot is step one, not an optional extra. + +```bash +cd /home/loca/dev/coder + +# 1. snapshot the database first +sudo -n docker compose exec -T database pg_dump -U coder coder | gzip > /home/loca/dev/coder/backup-$(date +%F).sql.gz + +# 2. edit the tag in docker-compose.yml, for example ghcr.io/coder/coder:v2.36.0 +# and write the same value into config/runtime-version.txt + +# 3. pull and recreate only the server +sudo -n docker compose pull coder +sudo -n docker compose up -d coder + +# 4. verify the running build matches the new pin +curl -sf http://127.0.0.1:52226/api/v2/buildinfo +curl -sf http://127.0.0.1:52226/healthz +curl -sSI https://code.loca.zone/ +``` + +- upstream's docker-compose upgrade path is exactly `pull` then `up -d` for the `coder` service; the database service is left alone unless the postgres major version itself is being changed +- `buildinfo` is the authority on what is actually running: it reports the semantic version plus commit, the advertised `dashboard_url`, and whether telemetry is enabled +- if `buildinfo` still shows the old version, the tag edit did not land or `pull` fetched a cached digest; re-check `docker-compose.yml` before restarting anything else +- a postgres major-version bump is a separate operation: dump with the old image, `down`, change the image, bring up a fresh volume, restore the dump + +## πŸ’Ύ Backup and restore + +```bash +# backup +cd /home/loca/dev/coder && sudo -n docker compose exec -T database pg_dump -U coder coder | gzip > /home/loca/dev/coder/backup-$(date +%F).sql.gz + +# restore +gunzip -c /home/loca/dev/coder/backup-2026-08-19.sql.gz | sudo -n docker compose exec -T database psql -U coder -d coder +``` + +- `-T` is required; without it compose allocates a TTY and the dump stream is corrupted by terminal translation +- the dump carries everything Coder persists: users, templates, template versions, workspace records and Terraform state +- restore into a running database that is otherwise idle; stop the `coder` service first with `sudo -n docker compose stop coder` so no writes race the restore, then start it again +- keep at least the snapshot taken immediately before the last upgrade; store it outside the repo tree if it must be retained long-term + +## πŸ” If an upgrade goes wrong + +- there is no supported downgrade path, so recovery is: `sudo -n docker compose stop coder`, restore the pre-upgrade dump, set the old tag back in `docker-compose.yml` and `config/runtime-version.txt`, then `sudo -n docker compose up -d coder` +- verify with `buildinfo` that the old version is live before letting any workspace agent reconnect +- if only the proxy is broken, nothing about the image needs touching: `sudo -n nginx -t` and the debug order in [[operations/index]] cover that case + +## πŸ“š Sources + +- https://coder.com/docs/install/docker +- https://coder.com/docs/install/upgrade +- https://coder.com/docs/admin/setup +- https://docs.docker.com/engine/install/linux-postinstall/ diff --git a/q5vault/operations/verification.md b/q5vault/operations/verification.md new file mode 100644 index 0000000..fa935de --- /dev/null +++ b/q5vault/operations/verification.md @@ -0,0 +1,163 @@ +--- +title: "βœ… Verification" +description: "Numbered proof checklist for the Coder stack with exact commands and expected output, including the deliberate contrast against the Authelia-gated cdsr.loca.zone host." +source: "verified deployment on the loca operator host, 2026-08-19; user, template, workspace and app checks verified 2026-08-20" +reviewed: 2026-08-20 +--- + +# βœ… Verification + +Run this list in order after any change to the compose file, the nginx vhost, the certificate or the image pin. Items 1 to 6 were observed on 2026-08-19 with the outputs shown; items 8, 9 and 10 were observed on 2026-08-20 after the first-user bootstrap, the template push and the first workspace build, and item 11 was re-measured the same day. Item 7 becomes true again on every wiki build. + +## 1️⃣ Both containers up, database healthy + +```bash +cd /home/loca/dev/coder && sudo -n docker compose ps +``` + +- expect container `coder` with state `running` and the single port mapping `127.0.0.1:52226->7080/tcp` +- expect container `coder-database` with state `running` and health `healthy`, and no port mapping at all +- a `coder-database` stuck in `starting` means the `pg_isready -U coder -d coder` healthcheck has not passed yet; `coder` will not start until it does + +## 2️⃣ Server health on loopback + +```bash +curl -sf http://127.0.0.1:52226/healthz +``` + +- expect exactly `OK` +- a non-zero exit from `curl -sf` means the server is not answering; check `sudo -n docker compose logs --tail 200 coder` + +## 3️⃣ Running build matches the pin + +```bash +curl -sf http://127.0.0.1:52226/api/v2/buildinfo +``` + +- expect `version` `v2.35.3+65e2bfb`, matching `config/runtime-version.txt` (`v2.35.3`) and the image tag in `docker-compose.yml` +- expect `dashboard_url` `https://code.loca.zone`, which proves `CODER_ACCESS_URL` reached the server +- expect `telemetry` `false`, which proves `CODER_TELEMETRY_ENABLE` took effect +- no jq on this host; if the raw JSON needs shaping, use `python3 -c` with the `json` module + +## 4️⃣ Public app host answers directly, with no auth redirect + +```bash +curl -sSI https://code.loca.zone/ +``` + +- expect `HTTP/1.1 200 OK` (or `HTTP/2 200`), served by the Coder SPA, with no `Location` header +- expect the security headers from `snippets/security-headers-app.conf` +- a `302` to `https://auth.loca.zone/...` here is a regression: someone added an Authelia rule for `code.loca.zone`. Remove it, reload Authelia, and re-read [[operations/auth-model]] + +## 5️⃣ Contrast check against the gated sibling host + +```bash +curl -sSI https://cdsr.loca.zone/ +``` + +- expect `HTTP/1.1 302` with a `Location` pointing at `https://auth.loca.zone/`, because `cdsr.loca.zone` carries an explicit `one_factor` Authelia rule +- this is the control for item 4: the same nginx and the same Authelia produce a redirect there and a direct `200` here, which proves the difference is the per-host rule and not a broken gate +- if both hosts return `200`, Authelia itself is down or its rule set was truncated; if both return `302`, the Coder vhost was gated by mistake + +## 6️⃣ Certificate lineage covers both names + +```bash +sudo -n certbot certificates +``` + +- expect one lineage named `code.loca.zone` whose domains are `code.loca.zone` and `wiki.code.loca.zone`, expiring 2026-11-17 +- expect no `*.code.loca.zone` entry; a wildcard would need DNS-01 validation with DNS-provider credentials that are not configured here, see [[reference/limits]] +- the vhost reads `/etc/letsencrypt/live/code.loca.zone/fullchain.pem` and `privkey.pem`, so a renewal that creates a second lineage such as `code.loca.zone-0001` silently leaves nginx on the old files + +## 7️⃣ Wiki host serves the built vault + +```bash +cd /home/loca/dev/wikis && ./build.sh code +curl -sSI https://wiki.code.loca.zone/ +``` + +- the build publishes a dated `dist-*` directory and repoints `/home/loca/dev/wikis/code/current` at it; that symlink is the nginx root for the wiki vhost +- expect `HTTP/1.1 200 OK` with no auth redirect once the build has published `current`; before the first build there is no `current` symlink, so nginx cannot serve the site +- `./build.sh code --check-only` validates without publishing, which is the safe way to test frontmatter changes +- the wiki is public by design: Authelia `default_policy` is `bypass` and no rule names `wiki.code.loca.zone` + +## 8️⃣ Initial user exists + +```bash +curl -so /dev/null -w '%{http_code}\n' http://127.0.0.1:52226/api/v2/users/first +``` + +- expect `200`: the initial Owner exists, which is upstream's own existence check. Observed on 2026-08-20, after the bootstrap flipped this endpoint from `404` to `200` +- `404` is correct only on a fresh deployment that has never been bootstrapped; seeing it here means the postgres volume was lost and every user, template and workspace went with it +- no credential is ever recorded in this wiki; the account and the handling of its generated password are described in [[operations/auth-model]] + +## 9️⃣ Template pushed and workspace running + +```bash +coder login https://code.loca.zone && coder templates list && coder list +sudo -n docker ps --filter name=coder-loca-ws1 --format '{{.Names}} {{.Status}}' +``` + +- expect `docker` in the template list with an active version, and workspace `ws1` owned by `loca` with status `Started` and health `healthy`; observed 2026-08-20 +- expect exactly one workspace container `coder-loca-ws1` in state `Up`; it is a sibling container on the host daemon rather than part of this compose project, so `docker compose ps` will never list it +- there is no `coder` binary on the host: the CLI lives inside the server container at `/opt/coder`, and the session file staged there during the bootstrap was deleted on purpose, so an in-container invocation needs a fresh login; the clean route is `coder login` from the operator's own machine +- a workspace stuck in `Starting` for several minutes is usually the image pull; the first build pulled `codercom/enterprise-base:ubuntu` in about 1m30s and started in about 95s in total, see [[operations/workspaces]] + +## πŸ”Ÿ Agent connected, app healthy, app URL loads + +```bash +coder show ws1 +``` + +- expect agent `main` connected with lifecycle `ready`, and app `code-server` with health `healthy` and `subdomain` false +- then open https://code.loca.zone/@loca/ws1.main/apps/code-server/ in a browser: expect a full code-server workbench, redirected to `?folder=/home/coder`. Verified in a real browser on 2026-08-20, and this is the proof that path-based apps work with no wildcard access URL and no extra certificate +- the trailing slash matters; without it the server answers with a redirect +- a `502` or a workbench that never finishes connecting points at the agent rather than nginx; re-run `coder show ws1` and read the agent logs inside the workspace container + +## 1️⃣1️⃣ Resource pressure gate + +```bash +free -m +``` + +- measured on 2026-08-19 before Coder existed: 36447 of 60278 MB used, and swap 15926 of 16383 MB consumed, alongside roughly ten co-tenant stacks including zulip, mattermost and eight postgres instances +- re-measured on 2026-08-20 immediately before the first workspace build: swap had drained to 0 of 16383 MB and roughly 31 GB of RAM was available, so the eight-gigabyte floor below passed on measurement rather than assumption; record `free -m` before and after every later build so the delta of one workspace stays known +- rule: do not create additional workspaces if available memory drops below about 8 GB +- container ceilings already in place: `mem_limit: 8g` on `coder`, `mem_limit: 2g` on `database`; see [[reference/limits]] + +## 1️⃣2️⃣ Ad-hoc port hygiene + +```bash +ss -ltn +``` + +- expect `127.0.0.1:52226` present and no other new listener from this stack +- any dev server started from a workspace or an IDE terminal must avoid every port in the AGENTS.md registry; `52222` pwiz, `52223` reserved for pwiz, `52224` cdsr and `52226` code are taken +- loca.zone application backends bind loopback inside `51000-53000` + +## 🧹 Pre-publish wiki lint gate + +Run this before every `build.sh` invocation, so before item 7 above. It is a gate, not a smoke test: it exits non-zero and publishes nothing. + +```bash +python3 /home/loca/dev/wikis/lint-vault.py /home/loca/dev/coder/q5vault +``` + +- expect `11 pages, 0 findings, all 5 checks clean` and exit code `0`; `--quiet` suppresses that line so only failures print, which is the form to use in a wrapper +- the vault may also be named instead of pathed: `python3 /home/loca/dev/wikis/lint-vault.py code` resolves `/home/loca/dev/wikis/code/content`, the symlink into this vault +- why it exists: a frontmatter `description:` whose value held an unquoted colon-space made the Quartz note-properties transformer fail its YAML parse, and esbuild then died with a Go `all goroutines are asleep - deadlock!` panic rather than a readable error. One unquoted colon cost a whole build with no usable diagnostic +- it complements `scripts/validate-ssot.py`, which `build.sh` runs itself: that validator only gates wikis shipping a `wiki-ia.json` manifest, and this wiki ships none, so its `SSOT Validation passed` line is a vacuous pass here and this linter is the only real pre-publish gate on the vault +- check 1, frontmatter scalars: unquoted values containing a colon-space, starting with `#`, `@`, `*`, `&`, `!`, `%` or a backtick, ending in a colon, carrying an inline ` #` comment that would silently truncate the value, or holding an unterminated quote. Reported as `file:line` plus the offending key +- check 2, wikilinks in tables: an unescaped `|` inside `[[...]]` on a table row splits the cell, and an alias containing `/` is truncated at the slash when rendered +- check 3, dead wikilinks: every target must resolve to a real `.md` in the vault, tried vault-root-relative first and then relative to the linking page, tolerating a leading `./` and an optional `#anchor` +- check 4, house style: no markdown bold anywhere +- check 5, page shape: no second H1 in one file, and no page without a frontmatter block +- checks 2 to 5 ignore fenced blocks and inline code spans, because nothing in those regions renders as markdown; a wikilink shown as an example is not a link +- standard library only, so it runs on any host with `python3` and needs no pyyaml + +## πŸ“š Sources + +- https://coder.com/docs/install/docker +- https://coder.com/docs/admin/setup +- https://coder.com/docs/reference/api/users +- https://coder.com/docs/admin/networking/wildcard-access-url diff --git a/q5vault/operations/workspaces.md b/q5vault/operations/workspaces.md new file mode 100644 index 0000000..daf30a4 --- /dev/null +++ b/q5vault/operations/workspaces.md @@ -0,0 +1,149 @@ +--- +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 + +## πŸ“š 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/ diff --git a/q5vault/reference/config-paths.md b/q5vault/reference/config-paths.md new file mode 100644 index 0000000..f149408 --- /dev/null +++ b/q5vault/reference/config-paths.md @@ -0,0 +1,153 @@ +--- +title: "πŸ—‚οΈ Paths, ports, env vars" +description: "Exhaustive lookup of every file, container, volume, port and CODER_* environment variable in the code.loca.zone deployment, plus the deployed docker-compose.yml verbatim." +source: "verified deployment on the loca operator host, 2026-08-19" +reviewed: 2026-08-19 +--- + +# πŸ—‚οΈ Paths, ports, env vars + +## πŸ“ Project files + +| Path | Purpose | Owner and notes | +| --- | --- | --- | +| `/home/loca/dev/coder/` | stack root; compose project name `coder` derives from this directory name | `loca`; run every compose command from here | +| `/home/loca/dev/coder/docker-compose.yml` | the whole deployment definition: images, pins, ports, env, volumes, memory ceilings | `loca`; edit here, never with `docker run` flags | +| `/home/loca/dev/coder/.env` | supplies `CODER_PG_PASSWORD` to compose interpolation | `loca`, mode 600, git-ignored; contents are never read into or quoted by any document | +| `/home/loca/dev/coder/.gitignore` | ignores `.env`, `q5vault/.obsidian/`, and terraform working state under `templates/` | `loca`; keeps the secret, editor state and tfstate out of version control while the provider lockfile stays committed | +| `/home/loca/dev/coder/config/runtime-version.txt` | plain-text pin, currently `v2.35.3`; the human-readable twin of the image tag | `loca`; changes in the same commit as the tag | +| `/home/loca/dev/coder/deploy/code.nginx.conf` | source of truth for the four nginx server blocks | `loca`; the installed site file is a copy, so edit here first | +| `/home/loca/dev/coder/templates/docker/main.tf` | pre-validated docker workspace template; one `coder_app` at `subdomain = false`, persistent `/home/coder` volume, no docker socket in the workspace | `loca`; pushed by the operator with `coder templates push` | +| `/home/loca/dev/coder/templates/docker/.terraform.lock.hcl` | provider hash lock: `coder/coder` 2.18.0, `kreuzwerker/docker` 4.5.0 | `loca`; generated by the provisioner's own terraform (linux_amd64), committed on purpose | +| `/home/loca/dev/coder/templates/docker/README.md` | operator runbook for the template, incl. cited upstream sources and the validation performed | `loca` | +| `/home/loca/dev/coder/q5vault/` | this wiki, the real content vault | `loca`; the wiki grid symlinks into it | +| `/home/loca/dev/coder/quartz.config.coder.yaml` | Quartz configuration for this wiki: `pageTitle` `Coder SSOT`, `baseUrl` `wiki.code.loca.zone`, footer links to https://code.loca.zone and https://loca.zone | `loca`; derived from the pwiz config with the same plugin set | + +## 🌐 Nginx, TLS and the wiki grid + +| Path | Purpose | Owner and notes | +| --- | --- | --- | +| `/etc/nginx/sites-available/code.loca.zone` | installed copy of `deploy/code.nginx.conf`; four server blocks (app 443, wiki 443, app 80, wiki 80) | root; `sudo -n nginx -t` before any reload | +| `/etc/nginx/sites-enabled/code.loca.zone` | symlink activating the site | root | +| `/etc/letsencrypt/live/code.loca.zone/` | certificate lineage named `code.loca.zone`, covering `code.loca.zone` and `wiki.code.loca.zone`, expiring 2026-11-17; both vhosts read `fullchain.pem` and `privkey.pem` here | root, certbot-managed; HTTP-01 via `/var/www/html` webroot | +| `/home/loca/dev/wikis/code/content` | symlink to `/home/loca/dev/coder/q5vault` | `loca`; the vault stays in the project, the grid only points at it | +| `/home/loca/dev/wikis/code/quartz.config.yaml` | symlink to `/home/loca/dev/coder/quartz.config.coder.yaml` | `loca` | +| `/home/loca/dev/wikis/code/current` | symlink to the published `dist-*` directory and the nginx root for the wiki vhost | produced by `cd /home/loca/dev/wikis && ./build.sh code`; absent until the first build runs | +| `/home/loca/dev/services/authelia/config/configuration.yml` | shared Authelia access rules; `default_policy: bypass`, and deliberately no rule for `code.loca.zone` or `wiki.code.loca.zone` | root-adjacent shared service; do not add a rule for this stack, see [[operations/auth-model]] | + +## πŸ“¦ Containers, volumes, network + +| Object | Definition | Notes | +| --- | --- | --- | +| container `coder` | service `coder`, image `ghcr.io/coder/coder:v2.35.3`, `restart: unless-stopped`, `mem_limit: 8g` | the control plane; also runs the built-in provisioner | +| container `coder-database` | service `database`, image `postgres:17`, `restart: unless-stopped`, `mem_limit: 2g` | healthcheck `pg_isready -U coder -d coder`, interval 5s, timeout 5s, 12 retries; publishes no host port | +| volume `coder_pgdata` | mounted at `/var/lib/postgresql/data` in `coder-database` | all Coder state: users, templates, template versions, workspaces, Terraform state; survives `docker compose down` | +| volume `coder_home` | mounted at `/home/coder` in `coder` | server-side cache and config root | +| bind mount `/var/run/docker.sock` | host socket into `coder` at the same path | required by the built-in provisioner; never mounted into a workspace | +| `group_add: "105"` | host gid of the docker group, added to the `coder` container user | obtained with `getent group docker \| cut -d: -f3`, not guessed | +| compose network | default project network; `coder` reaches postgres at hostname `database` port 5432 | the only route to the database | + +Named volumes are declared as `coder_pgdata` and `coder_home`; compose creates named volumes prefixed with the project name, so the docker-side names are `coder_coder_pgdata` and `coder_coder_home`. Address them through `sudo -n docker compose` from the project root. + +## πŸ”Œ Ports + +| Port | Bound where | Meaning | +| --- | --- | --- | +| `52226` | host `127.0.0.1` only, published as `"127.0.0.1:52226:7080"` | the sole host-visible socket for this stack; nginx proxies to it | +| `7080` | inside the `coder` container, `0.0.0.0:7080` | the server's own listener; all interfaces of its own namespace, unreachable from outside the publish spec | +| `5432` | inside the `coder-database` container | reachable only over the compose network | +| `443` and `80` | host, nginx | `code.loca.zone` and `wiki.code.loca.zone` vhosts | + +Registry context: `52222` pwiz, `52223` reserved for pwiz, `52224` cdsr (code-server), `52226` code (Coder). All loca.zone application backends bind loopback inside `51000-53000`. Ad-hoc dev servers started from an IDE or workspace terminal must avoid every registered port; check with `ss -ltn` first. + +## βš™οΈ Environment variables actually set + +| Variable | Value | Why | +| --- | --- | --- | +| `CODER_HTTP_ADDRESS` | `0.0.0.0:7080` | binds all interfaces of the container namespace so the host publish can reach it; a container-local loopback bind would be unreachable | +| `CODER_ACCESS_URL` | `https://code.loca.zone` | the external URL that users and workspace agents use; upstream requires it whenever the built-in tunnel is not used, and it must not be localhost | +| `CODER_PG_CONNECTION_URL` | `postgresql://coder:${CODER_PG_PASSWORD}@database:5432/coder?sslmode=disable` | external-database mode against the private postgres container; `sslmode=disable` is acceptable only because the traffic never leaves the compose network | +| `CODER_TELEMETRY_ENABLE` | `"false"` | no usage data leaves the host; confirmed by `telemetry` `false` in `/api/v2/buildinfo` | +| `POSTGRES_USER` | `coder` | database role | +| `POSTGRES_PASSWORD` | `${CODER_PG_PASSWORD}` | interpolated from `.env`; the literal value is never documented | +| `POSTGRES_DB` | `coder` | database name | + +Deliberately unset: `CODER_WILDCARD_ACCESS_URL` (see [[reference/limits]]), `CODER_TLS_ENABLE` and friends (nginx terminates TLS), every `CODER_OIDC_*` variable (see [[operations/auth-model]]), and `CODER_DISABLE_PATH_APPS` (path-based apps are the only app routing available without a wildcard). + +## πŸ“„ Deployed docker-compose.yml + +The password appears only as the `${CODER_PG_PASSWORD}` placeholder, exactly as it does on disk. + +```yaml +services: + database: + image: postgres:17 + container_name: coder-database + restart: unless-stopped + environment: + POSTGRES_USER: coder + POSTGRES_PASSWORD: ${CODER_PG_PASSWORD} + POSTGRES_DB: coder + volumes: + - coder_pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U coder -d coder"] + interval: 5s + timeout: 5s + retries: 12 + mem_limit: 2g + + coder: + image: ghcr.io/coder/coder:v2.35.3 + container_name: coder + restart: unless-stopped + depends_on: + database: + condition: service_healthy + ports: + - "127.0.0.1:52226:7080" + environment: + CODER_HTTP_ADDRESS: 0.0.0.0:7080 + CODER_ACCESS_URL: https://code.loca.zone + CODER_PG_CONNECTION_URL: postgresql://coder:${CODER_PG_PASSWORD}@database:5432/coder?sslmode=disable + CODER_TELEMETRY_ENABLE: "false" + # No CODER_WILDCARD_ACCESS_URL. Nested names such as foo.code.loca.zone + # DO resolve via the *.loca.zone wildcard; the actual blocker is TLS + # issuance: this lineage covers only code.loca.zone and + # wiki.code.loca.zone, and a *.code.loca.zone certificate needs DNS-01 + # validation with DNS-provider credentials that are not configured here. + # Path-based workspace apps and `coder port-forward` are used instead. + group_add: + # host gid of /var/run/docker.sock; required so the built-in provisioner + # can build docker-template workspaces. + - "105" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - coder_home:/home/coder + mem_limit: 8g + +volumes: + coder_pgdata: + coder_home: +``` + +## πŸ”— Endpoints worth bookmarking + +| URL | Purpose | +| --- | --- | +| `http://127.0.0.1:52226/healthz` | loopback liveness, returns `OK` | +| `http://127.0.0.1:52226/api/v2/buildinfo` | running version, advertised dashboard URL, telemetry flag | +| `http://127.0.0.1:52226/api/v2/users/first` | `200`, the initial Owner exists; `404` only on a never-bootstrapped deployment | +| `https://code.loca.zone/` | dashboard, no auth redirect | +| `https://code.loca.zone/@/./apps//` | path-based workspace app | +| `https://code.loca.zone/@/./terminal` | web terminal | +| `https://wiki.code.loca.zone/` | this wiki, public | + +## πŸ“š Sources + +- https://coder.com/docs/admin/setup +- https://coder.com/docs/install/docker +- https://coder.com/docs/reference/api/users +- https://docs.docker.com/reference/compose-file/volumes/ +- 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 diff --git a/q5vault/reference/index.md b/q5vault/reference/index.md new file mode 100644 index 0000000..6710eeb --- /dev/null +++ b/q5vault/reference/index.md @@ -0,0 +1,28 @@ +--- +title: "πŸ“– Reference" +description: "Lookup tables for the Coder stack: every path, container, volume, port and environment variable, plus the deliberate limits of this deployment." +source: "verified deployment on the loca operator host, 2026-08-19" +reviewed: 2026-08-19 +--- + +# πŸ“– Reference + +Two pages, both lookup-first with no narrative. + +| Need | Page | +| --- | --- | +| Where does file X live, what is container Y, which env var sets Z | [[reference/config-paths]] | +| Why can I not port-forward from the dashboard, why is there no login gate, how much memory may I use | [[reference/limits]] | + +## ⚑ The five facts most often needed + +- app URL https://code.loca.zone, wiki URL https://wiki.code.loca.zone +- stack root `/home/loca/dev/coder`, compose project name `coder` +- loopback publish `127.0.0.1:52226` mapped to container port `7080` +- image pin `ghcr.io/coder/coder:v2.35.3`, database `postgres:17` +- port registry neighbours: `52222` pwiz, `52223` reserved for pwiz, `52224` cdsr, `52226` code; all loca.zone app backends bind loopback inside `51000-53000` + +## 🧭 Elsewhere + +- deployed wiring and diagram: [[architecture/runtime-topology]] +- day-to-day commands: [[operations/index]] diff --git a/q5vault/reference/limits.md b/q5vault/reference/limits.md new file mode 100644 index 0000000..c5f57af --- /dev/null +++ b/q5vault/reference/limits.md @@ -0,0 +1,87 @@ +--- +title: "🚧 Deliberate limits" +description: "What this Coder deployment does not enable and what each omission costs: no wildcard access URL, no Authelia gate, no open signup, no external provisioner, and a hard memory pressure gate." +source: "verified deployment on the loca operator host, 2026-08-19" +reviewed: 2026-08-19 +--- + +# 🚧 Deliberate limits + +Every entry here is a choice, not an oversight. Each one states the cost, the true blocker, and what it would take to lift it. + +## 🌐 No wildcard access URL + +`CODER_WILDCARD_ACCESS_URL` is unset. + +- the true blocker is TLS issuance, not DNS + - nested names under the host wildcard do resolve; `foo.code.loca.zone` and even `deep.nested.code.loca.zone` answer on this host, as does any nonexistent label under `loca.zone` + - each certbot lineage covers only the SANs it was issued for; the `code.loca.zone` lineage covers `code.loca.zone` and `wiki.code.loca.zone` and nothing else + - a `*.code.loca.zone` certificate requires DNS-01 validation with DNS-provider API credentials, which are not configured on this host; HTTP-01 via the `/var/www/html` webroot cannot issue wildcards + - upstream's own nginx walkthrough confirms the shape: a wildcard certificate is obtained with a DNS plugin and a provider API token, not with webroot validation +- lost features, exactly: + - dashboard port forwarding, including the Open Ports panel and port sharing URLs + - `coder_app` resources with `subdomain = true`; upstream states such apps are not accessible when the administrator has not set up wildcards + - web apps that require an absolute root path, notably Vite, the React dev server, the Next.js dev server, JupyterLab and RStudio per upstream +- kept features, exactly: + - path-based `coder_app` resources at `/@/./apps//` + - the dashboard and the web terminal + - `coder port-forward` from the CLI + - SSH port forwarding, including `coder ssh -R` and anything set up by `coder config-ssh` + - Coder Desktop and the IDE remotes (VS Code extension, JetBrains Gateway) +- documented upgrade path, all of it requiring operator approval: + - obtain a DNS-01 wildcard certificate for `*.code.loca.zone`, which means adding DNS-provider API credentials to this host + - add an nginx server block for `server_name *.code.loca.zone` proxying to `127.0.0.1:52226` + - set `CODER_WILDCARD_ACCESS_URL` on the `coder` service and restart it + - once wildcards work, upstream advises disabling path-based apps (`CODER_DISABLE_PATH_APPS=true`), because path apps share the Coder API origin and a malicious workspace could reuse Coder cookies against the API + - note the RFC 1035 label limit: app, agent, workspace and username together must stay under 63 characters in one hostname label + +## πŸ”“ No Authelia gate + +- cost: the dashboard is publicly reachable and Coder's own login page is the only thing between the internet and the deployment; there is no second factor at the proxy +- why the cost is accepted: an `auth_request` gate would 302 every cookie-less request, which is exactly what the `coder` CLI, the workspace agents and DERP relay traffic send. A browser-only gate is impossible because the SPA and the agents share `/api/v2` +- compensating controls: Coder's own session handling, no open signup, a pinned server image, and a single admin account +- full argument and the OIDC-via-Authelia upgrade path: [[operations/auth-model]] + +## 🚫 No open signup + +- cost: onboarding another person is a manual admin action; there is no self-service registration +- why: `POST /api/v2/users` requires authentication, so only an admin can mint accounts, and `POST /api/v2/users/first` exists solely for the initial Owner +- consequence to respect: do not create accounts for other people on this deployment. Template Admin is root-equivalent here because of the docker socket, so every additional privileged account widens host exposure +- if multi-user access is ever wanted, do it through OIDC with Authelia as the provider rather than by handing out passwords + +## πŸ—οΈ No external provisioner + +- cost: template builds run inside the `coder` container, so a heavy build competes with the control plane for the same 8 GB ceiling, and a build failure shows up in the server log rather than in a separate daemon's log +- why: an external provisioner needs its own credentialed daemon and a scoped provisioner key; for a single-admin docker-only deployment that is more moving parts than it removes +- security note carried over from upstream: provisioner daemons hold the credentials that can create compute. Here that credential is the bind-mounted `/var/run/docker.sock`, which makes the server root-equivalent on the host. Containment list: [[operations/workspaces]] + +## 🧠 Memory and swap pressure gate + +The measured state of this host on 2026-08-19, before Coder existed: + +- `free -m` showed 36447 of 60278 MB used +- swap was 15926 of 16383 MB consumed, that is effectively exhausted +- roughly ten co-tenant stacks were already running, including zulip, mattermost and eight postgres instances + +Containment already in place: + +- `mem_limit: 8g` on the `coder` container, so the control plane plus its in-process provisioner cannot exceed 8 GB +- `mem_limit: 2g` on the `coder-database` container +- postgres publishes no host port, so nothing outside the compose network can drive load into it + +The rules, in order: + +- record `free -m` before bring-up and again after the first workspace build, so the real cost of one workspace is known rather than estimated +- do not create additional workspaces if available memory drops below about 8 GB +- workspace containers are siblings on the host daemon and are not covered by the compose `mem_limit` values, so give every template an explicit resource ceiling +- if swap is already exhausted, stop and free memory elsewhere before building anything; a build that lands on an exhausted swap will thrash the co-tenant stacks, not just this one + +## πŸ“š Sources + +- https://coder.com/docs/admin/setup +- https://coder.com/docs/admin/networking/wildcard-access-url +- https://coder.com/docs/admin/networking/port-forwarding +- https://coder.com/docs/tutorials/reverse-proxy-nginx +- https://coder.com/docs/tutorials/best-practices/security-best-practices +- https://coder.com/docs/reference/api/users +- https://registry.terraform.io/providers/coder/coder/latest/docs/resources/app diff --git a/quartz.config.coder.yaml b/quartz.config.coder.yaml new file mode 100644 index 0000000..193b920 --- /dev/null +++ b/quartz.config.coder.yaml @@ -0,0 +1,224 @@ +# yaml-language-server: $schema=./quartz/plugins/quartz-plugins.schema.json +configuration: + pageTitle: "Coder SSOT" + pageTitleSuffix: '' + enableSPA: true + enablePopovers: true + locale: en-US + baseUrl: wiki.code.loca.zone + ignorePatterns: + - private + - templates + - .obsidian + theme: + fontOrigin: googleFonts + cdnCaching: true + typography: + header: Schibsted Grotesk + body: Source Sans Pro + code: IBM Plex Mono + colors: + lightMode: + light: '#faf8f8' + lightgray: '#e5e5e5' + gray: '#b8b8b8' + darkgray: '#4e4e4e' + dark: '#2b2b2b' + secondary: '#21759b' + tertiary: '#464646' + highlight: rgba(33, 117, 155, 0.15) + textHighlight: '#21759b88' + darkMode: + light: '#161618' + lightgray: '#393639' + gray: '#646464' + darkgray: '#d4d4d4' + dark: '#ebebec' + secondary: '#4db6e6' + tertiary: '#464646' + highlight: rgba(33, 117, 155, 0.15) + textHighlight: '#4db6e688' +plugins: +- source: github:quartz-community/created-modified-date + enabled: true + options: + defaultDateType: modified + priority: + - frontmatter + - git + - filesystem + order: 10 +- source: github:quartz-community/syntax-highlighting + enabled: true + options: + theme: + light: github-light + dark: github-dark + keepBackground: false + order: 20 +- source: github:quartz-community/obsidian-flavored-markdown + enabled: true + options: + enableInHtmlEmbed: false + enableCheckbox: true + order: 30 +- source: github:quartz-community/github-flavored-markdown + enabled: true + order: 40 +- source: github:quartz-community/table-of-contents + enabled: true + order: 50 + layout: + position: right + priority: 30 +- source: github:quartz-community/crawl-links + enabled: true + options: + markdownLinkResolution: shortest + order: 60 +- source: github:quartz-community/description + enabled: true + order: 70 +- source: github:quartz-community/latex + enabled: true + options: + renderEngine: katex + order: 80 +- source: github:quartz-community/fonts + enabled: true +- source: github:quartz-community/remove-draft + enabled: true +- source: github:quartz-community/alias-redirects + enabled: true +- source: github:quartz-community/content-index + enabled: true + options: + enableSiteMap: true + enableRSS: true + rssLimit: 10 + includeEmptyFiles: false +- source: github:quartz-community/favicon + enabled: true +- source: github:quartz-community/og-image + enabled: true +- source: github:quartz-community/cname + enabled: true +- source: github:quartz-community/canvas-page + enabled: true +- source: github:quartz-community/content-page + enabled: true +- source: github:quartz-community/folder-page + enabled: true +- source: github:quartz-community/tag-page + enabled: true +- source: github:quartz-community/explorer + enabled: true + options: + title: "🧭 Explorer" + folderClickBehavior: "link" + folderDefaultState: "collapsed" + useSavedState: true + layout: + position: left + priority: 50 +- source: github:quartz-community/graph + enabled: false + layout: + position: right + priority: 10 +- source: github:quartz-community/search + enabled: true + layout: + position: left + priority: 20 + group: toolbar + groupOptions: + grow: true +- source: github:quartz-community/backlinks + enabled: false +- source: github:quartz-community/article-title + enabled: false +- source: github:quartz-community/content-meta + enabled: true + layout: + position: beforeBody + priority: 20 +- source: github:quartz-community/page-title + enabled: true + layout: + position: left + priority: 10 +- source: github:quartz-community/darkmode + enabled: true + layout: + position: left + priority: 30 + group: toolbar +- source: github:quartz-community/reader-mode + enabled: true + layout: + position: left + priority: 35 + group: toolbar +- source: github:quartz-community/breadcrumbs + enabled: true + layout: + position: beforeBody + priority: 5 + condition: not-index +- source: github:quartz-community/footer + enabled: true + options: + links: + Coder: https://code.loca.zone + loca.zone: https://loca.zone +- source: github:quartz-community/bases-page + enabled: true + options: {} + order: 50 +- source: github:quartz-community/note-properties + enabled: true + options: + includeAll: false + includedProperties: + - description + - tags + - aliases + excludedProperties: [] + hidePropertiesView: true + delimiters: '---' + language: yaml + order: 5 + layout: + position: beforeBody + priority: 15 + display: all +- source: github:quartz-community/unlisted-pages + enabled: true + options: {} + order: 45 +layout: + groups: + toolbar: + priority: 35 + direction: row + gap: 0.5rem + byPageType: + '404': + positions: + beforeBody: [] + left: [] + right: [] + content: {} + folder: + exclude: + - reader-mode + positions: + right: [] + tag: + exclude: + - reader-mode + positions: + right: [] + canvas: {} + bases: {} diff --git a/templates/docker/.terraform.lock.hcl b/templates/docker/.terraform.lock.hcl new file mode 100644 index 0000000..10d73d7 --- /dev/null +++ b/templates/docker/.terraform.lock.hcl @@ -0,0 +1,44 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/coder/coder" { + version = "2.18.0" + constraints = "~> 2.18" + hashes = [ + "h1:O25nA2tkM1JFJtQpaX2TOTeWvsSyuxDoJo6/QTv78hM=", + "zh:107c4eb7a36335ac94637679845c040759e21d3fb108d07081e0689cb93121b1", + "zh:1f8d904d59e35948f8f795e58d7895b791bd9a12c8409079f5bb90221303c1b6", + "zh:3942d5c6b6fad45b3b92f82c7b3f40a2d7f2b7b72da07f1ac63e1022b46bdbc7", + "zh:464f3b53eccfefc3ca02b46da07fb277362c16f81f45cc4570150520e5b9bf75", + "zh:6ae37bef989559a212ce04a615444d40452425e01081d8499843a24db0a09e85", + "zh:6f1410911124b0078a0f1c077899b4d834cd6821596ed350e4b4ad8f5b0030e7", + "zh:9ca356eb0e23bc034091fc9672bbe307a69e242c341a669f10db81e9a75fab2d", + "zh:b36687f0793295337286ae71d8157dc4c78a13fb5a9b1276093db82cdb8edd76", + "zh:c60a39cbde9fb473bfd0c2ad1685a0ce8d4a56408f060c6d1b858bae2e535d98", + "zh:d26ffdb52e095662e16b19942b7579bea7dc60577bd81f5cd671e6632ce48a0b", + "zh:da5414d74b21ecdcf8f93adc2199925c530cde1f53cde014201713d315916366", + "zh:e7e801f91b8afba90d40aa390c8b822ba064a54932a4a25bbff1593942e9bf72", + "zh:f064fb800fc687b2d60d4c12e1e65875a2f4b21cf8bb0b5e018d3ecbf7d29de2", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + ] +} + +provider "registry.terraform.io/kreuzwerker/docker" { + version = "4.5.0" + constraints = "~> 4.5" + hashes = [ + "h1:mEARBnOnl9C1tcmrdNS+79cDiSUVBFX4gnwGEm4UQcE=", + "zh:0ee4e9121632158a9ae1049bdd53c71fd9e06357de915dd193df2c1c55ae8814", + "zh:12c6991012b5a548a406e1960c323484f95a9538c2522af15ec82f47fdb832d1", + "zh:19c86ae59ed1e062e8402a49ea842f957fcda2f9689e06ecbd9480cc9bd1e41a", + "zh:3ac8bc9805acd20c40c0ed474c9f4605aa73b61cf52809e1131e335fcbfe8e49", + "zh:5a0e70d143759712fdda109f024a41d59d4758b5615378aa21ee6b959f65a416", + "zh:5d3599285f71cc53c88a5802fa6e2f8ba87b9deea03abe5fe8bb2639e9d95654", + "zh:a42f573a2526cddc9b8a93637a2c5478bc6e903ff29556ab8b2f1467681d5bde", + "zh:c8de50d902cc8457e565a5d959f1e3e49064d31c2f14f7adf5f3ec632cfe3695", + "zh:d40543d51eb4e902125d2c5a8a0f425a60edcec6c1dc07c4a89d92335ec05541", + "zh:dd3105018ffddc8114083dcf84798c32fdecca7ef708eceaee49eb4b306fc061", + "zh:e51e0199700698b962085683af4551d1f982dce37f3b6950da062c65e0145505", + "zh:ffe415d3d3deffffdbd3a7173e3a9f90b74601cb6d8acafec1ab701a5b91dbff", + ] +} diff --git a/templates/docker/README.md b/templates/docker/README.md new file mode 100644 index 0000000..f2e4089 --- /dev/null +++ b/templates/docker/README.md @@ -0,0 +1,294 @@ +# Coder template: docker + +Docker-backed workspace template for the Coder deployment at https://code.loca.zone +(compose project `/home/loca/dev/coder`, image `ghcr.io/coder/coder:v2.35.3`). + +Ready to push as-is. Nothing in this directory has been applied; no workspace, +user, or template exists on the server yet. + +## Prerequisites + +order_matters: yes, top to bottom +steps: + 1_create_first_user: + what: Coder currently has zero users, deliberately. `GET /api/v2/users/first` + returns 404 until this is done. + how: open https://code.loca.zone in a browser and complete the setup form. + note: this is the only step that cannot be scripted from the host; it mints + the first owner account. + 2_authenticate_the_cli: + what: the `coder` CLI needs a session before it can push. + how: `sudo -n docker exec -it coder coder login https://code.loca.zone` + note: the server binary and the CLI are the same executable, so the + container already has it. Verified: `coder` is NOT on the host PATH. + 3_push_this_template: + canonical_command: coder templates push docker -d /home/loca/dev/coder/templates/docker + flag_check: `-d, --directory` verified against the v2.35.3 CLI reference + (see Sources). `-d` defaults to `.`, so the explicit path is what makes + this runnable from any cwd. The positional `docker` is the template name. + path_caveat: that command as written requires a CLI that can see this + directory, meaning a host-installed `coder`. Verified today: the host has + no `coder` binary, and this directory is NOT visible inside the container + (the only bind mount is /var/run/docker.sock). So pick one: + option_a_host_cli: + steps: install the `coder` CLI on the host, `coder login + https://code.loca.zone`, then run the canonical command verbatim. + note: preferred if the operator will manage templates regularly. + option_b_no_install: + what: stream the directory in as a tar. The CLI reference documents `-d -` + as "read tar from stdin", so no file needs to be copied anywhere. + command: tar -cf - -C /home/loca/dev/coder/templates/docker . | sudo -n + docker exec -i coder coder templates push docker -d - + note: run `coder login` inside the container first (step 2). Untested + here, because pushing requires the authenticated session that does not + exist yet. + lockfile: `.terraform.lock.hcl` is committed here, so the push will not warn + about a missing lockfile. `--ignore-lockfile` defaults to false; do not + pass it. Either option above preserves the lockfile, option B because tar + includes dotfiles via `.`. + +## What this template provisions + +per_workspace: + coder_agent.main: + startup_script_behavior: blocking + startup_script: seeds `$HOME` from /etc/skel once, installs code-server into + the persistent home, starts it on 127.0.0.1:13337. + idempotence: guarded by `$HOME/.init_done`, an `-x` test on the code-server + binary, and a healthcheck probe before launching. Re-running is a no-op. + failure_policy: no `set -e`; every optional step is `|| log`-guarded and the + script ends in `exit 0`. A missing `curl`, a dead network, or a failed + code-server install degrades to "app reports unhealthy", never a failed + workspace build. + coder_app.code_server: + url: http://localhost:13337/?folder=/home/coder + subdomain: false (mandatory β€” see below) + healthcheck: /healthz every 5s, unhealthy after 12 consecutive failures + scope: this is the IN-WORKSPACE code-server, one instance per workspace, + reached through the Coder proxy. It is a different thing from the host IDE + at https://cdsr.loca.zone (port 52224, Authelia-gated), which serves the + operator's own filesystem. They share a name and nothing else. + docker_image.workspace: + name: codercom/enterprise-base:ubuntu (~404 MB; first build pulls it) + keep_locally: true, so deleting one workspace does not yank the image out + from under the others. + upstream_drift: the v2.35.3 example uses `enterprise-base`; upstream `main` + renamed the same image to `codercom/example-base:ubuntu`. Both tags exist + on Docker Hub with identical size. This template tracks the tag matching + the running server. + docker_volume.home: + name: coder--home, mounted at /home/coder + persistence: keyed to the immutable workspace id, not its name, so stop, + start, rename, and rebuild all reattach the same volume. `lifecycle { + ignore_changes = all }` stops an attribute diff from destroying user data. + deletion: the volume goes away only when the workspace itself is deleted. + docker_container.workspace: + count: data.coder_workspace.me.start_count, so a stopped workspace has no + container but keeps its volume. + network: default bridge. Two /etc/hosts entries are injected: + `host.docker.internal` and the Coder access hostname, both pointing at + `host-gateway`. + coder_metadata.workspace: + shows image, cpu limit, memory limit, home volume name, and app routing mode + in the dashboard, so the limits are visible without `docker inspect`. + agent_metadata: + rows: workspace CPU, workspace RAM, home disk, host CPU, host RAM, host swap. + why_host_rows: the daemon, both Quartz wikis, Coder's Postgres, and every + workspace share this one box. The host swap row is there because swap is + the resource actually under pressure. + +## Why `subdomain = false` is mandatory here + +constraint: every `coder_app` in this template sets `subdomain = false`. Do not + change it without doing the certificate work first. +reasoning: + - dns_is_not_the_blocker: nested names do resolve on this host; verified that + `deep.nested.code.loca.zone` answers with 178.18.250.170. + - tls_is_the_blocker: the certbot lineage covers exactly `code.loca.zone` and + `wiki.code.loca.zone`. A subdomain app would be served a certificate that + does not match its name, so the browser refuses it. + - why_not_just_issue_one: a wildcard certificate (`*.code.loca.zone`) requires + a DNS-01 challenge, which needs DNS-provider API credentials. Those are not + configured on this host. HTTP-01 cannot satisfy a wildcard. + - also_missing: no `CODER_WILDCARD_ACCESS_URL` is set on the server, so Coder + would not route subdomain apps even with a valid certificate. +consequence: apps are reached path-based, at + `https://code.loca.zone/@/.main/apps/code-server/`. +to_change_this_later: obtain DNS-01 credentials, issue `*.code.loca.zone`, set + `CODER_WILDCARD_ACCESS_URL=*.code.loca.zone` on the server, add the wildcard + to the nginx server block, and only then flip `subdomain`. +module_avoidance: the upstream example gets code-server from the + `registry.coder.com/coder/code-server/coder` module. This template declares + the `coder_app` directly instead, because a module-owned app is not under our + control and could ship `subdomain = true`, and because modules add a + provision-time dependency on registry.coder.com. + +## Docker socket boundary + +rule: `/var/run/docker.sock` is NOT mounted into workspace containers. +where_it_lives: only the `coder` server container has it, bind-mounted + read-write with `group_add "105"`. It needs the socket because it is the + Terraform provisioner that creates these workspaces. +why_workspaces_must_not_get_it: the socket is root-equivalent control of the + whole host. A workspace holding it could reach the nginx and certbot stack, + the `coder-database` Postgres container, and every other user's workspace. + There is no container boundary left once the socket is inside. +provider_config: the `docker` provider block sets no `host`, so it uses the + socket already present in the provisioner. Nothing is exposed over TCP. +if_docker_in_workspace_is_ever_wanted: use a nested-container runtime such as + sysbox or rootless dockerd inside the workspace image. Do not mount the + socket. + +## Resource defaults and how to change them + +sizing_rationale: the host has 10 cores and roughly 22 GiB available RAM, but + its 15 GiB of swap is already about 97 percent consumed. Defaults are set so a + workspace cannot make that worse. +parameters: + cpu: + default: 2 cores + options: 1, 2, 4 + enforced_by: `cpus` on the container, a hard ceiling rather than a + reservation. + mutable: yes, takes effect on the next start. + memory: + default: 2 GB + options: 2, 4, 8 + enforced_by: `memory` in MB, with `memory_swap` pinned to the same value. + Equal values disable container swap entirely, which is the point: a + workspace must not add to the host's already exhausted swap. + mutable: yes, takes effect on the next start. +no_disk_parameter: + deliberate: yes. The home volume uses Docker's `local` driver on an + overlay2/extfs root (confirmed with `docker info`: `driver=overlay2`, + `Backing Filesystem: extfs`). That combination cannot enforce a size quota + without XFS project quotas. + why_it_matters: a `home_disk_size` parameter, as the upstream Kubernetes + example has, would render in the UI and enforce nothing. Home disk usage is + reported through agent metadata instead, which is true. +how_to_edit: change the `option` blocks in `data "coder_parameter" "cpu"` and + `"memory"` in main.tf, then re-push. Existing workspaces keep their current + values until restarted. +image_change: edit `local.workspace_image`. + +## Validation performed + +context: run entirely against the terraform binary already inside the running + `coder` container. Nothing was installed on the host, no container was + restarted, no authenticated Coder API call was made, and `terraform apply` was + never run. The staging directory `/tmp/tmpl` inside the container was removed + afterwards. +binary: `sudo -n docker exec coder terraform version` -> `Terraform v1.15.5 on + linux_amd64`, at /usr/local/bin/terraform. This is the same binary and + platform that will provision workspaces, which is what makes the committed + lockfile correct. +succeeded: + terraform_fmt: + command: terraform fmt -check -diff -no-color + result: exit 0, empty diff. The file is already canonically formatted. + terraform_init: + command: terraform init -no-color + result: exit 0. "Installing coder/coder v2.18.0... (signed by a HashiCorp + partner, key ID 93C75807601AA0EC)", "Installing kreuzwerker/docker + v4.5.0... (self-signed, key ID 0DCE698927DAF8EC)", "Terraform has been + successfully initialized!" + note: registry network access from inside the container works, so no + fallback was needed. This also confirms `~> 2.18` resolves to 2.18.0 and + `~> 4.5` resolves to 4.5.0. + terraform_validate: + command: terraform validate -no-color + result: exit 0, "Success! The configuration is valid." + terraform_plan: + command: terraform plan -no-color -refresh=false -lock=false + result: exit 0, "Plan: 6 to add, 0 to change, 0 to destroy." (coder_agent, + coder_app, coder_metadata, docker_image, docker_volume, docker_container) + why_it_matters: `validate` does not evaluate expressions against real + values; `plan` does. It proved the `regexall`, `tonumber`, and `tostring` + expressions and the `dynamic "host"` block all resolve. + values_observed: `cpus = "2"`, `memory = 2048`, `memory_swap = 2048`, + `volume_name = coder--home`, container name `coder--`. + access_host_branches: + both_branches_planned_cleanly: exit 0 each. + hostname_case: `CODER_AGENT_URL=https://code.loca.zone` produced host + entries `code.loca.zone` -> host-gateway and `host.docker.internal` -> + host-gateway. + ipv4_case: `CODER_AGENT_URL=http://127.0.0.1:52226` produced only + `host.docker.internal`, correctly declining to write an IP-literal host + entry. + gateway_reachability: + command: curl --resolve code.loca.zone:443:172.17.0.1 + https://code.loca.zone/healthz + result: HTTP 200, body `OK`, certificate verified. This is what justifies + pinning the access hostname to `host-gateway`: agent-to-server traffic + goes straight to the host's nginx instead of depending on hairpin NAT out + to the public IP and back. +not_performed: + terraform_apply: never run. It would create a real workspace container and + requires a workspace build, not a bare terraform invocation. + coder_templates_push: not run. Requires an authenticated session, which + requires the first user, which is a browser-only step. + end_to_end_workspace_build: not possible before the first user exists. The + first real build is also the first test of the code-server download path. + +## Sources + +read_verbatim_while_writing_this_template: + upstream_docker_example_at_running_tag: + https://raw.githubusercontent.com/coder/coder/v2.35.3/examples/templates/docker/main.tf + upstream_docker_example_at_main: + https://raw.githubusercontent.com/coder/coder/main/examples/templates/docker/main.tf + upstream_kubernetes_example_for_the_parameter_and_app_idiom: + https://raw.githubusercontent.com/coder/coder/v2.35.3/examples/templates/kubernetes/main.tf + cli_reference_for_templates_push: + https://raw.githubusercontent.com/coder/coder/v2.35.3/docs/reference/cli/templates_push.md + coder_provider_published_versions: + https://registry.terraform.io/v1/providers/coder/coder + coder_provider_version_2_upgrade_guide: + https://raw.githubusercontent.com/coder/terraform-provider-coder/v2.18.0/docs/guides/version-2-upgrade.md + coder_agent_schema: + https://raw.githubusercontent.com/coder/terraform-provider-coder/v2.18.0/docs/resources/agent.md + coder_app_schema: + https://raw.githubusercontent.com/coder/terraform-provider-coder/v2.18.0/docs/resources/app.md + coder_metadata_schema: + https://raw.githubusercontent.com/coder/terraform-provider-coder/v2.18.0/docs/resources/metadata.md + coder_parameter_schema: + https://raw.githubusercontent.com/coder/terraform-provider-coder/v2.18.0/docs/data-sources/parameter.md + coder_workspace_data_source_implementation: + https://raw.githubusercontent.com/coder/terraform-provider-coder/v2.18.0/provider/workspace.go + docker_provider_published_versions: + https://registry.terraform.io/v1/providers/kreuzwerker/docker + docker_container_schema: + https://raw.githubusercontent.com/kreuzwerker/terraform-provider-docker/v4.5.0/docs/resources/container.md + docker_image_schema: + https://raw.githubusercontent.com/kreuzwerker/terraform-provider-docker/v4.5.0/docs/resources/image.md + docker_volume_schema: + https://raw.githubusercontent.com/kreuzwerker/terraform-provider-docker/v4.5.0/docs/resources/volume.md + +version_pinning: + coder: `~> 2.18`, resolving to 2.18.0, the latest published release. The 2.x + line requires Coder Server v2.18.0 or later per the upgrade guide; this + server is v2.35.3. + docker: `~> 4.5`, resolving to 4.5.0, the latest published release. + lockfile: `.terraform.lock.hcl` records both exact versions with registry + hashes, generated by the provisioner's own terraform on linux_amd64. + +## Divergences from upstream, and why + +- providers_pinned: upstream pins neither provider. Unpinned providers on a + shared box mean a future major release silently changes workspace behaviour. +- code_server_declared_directly: upstream uses the registry module. A + module-owned `coder_app` could set `subdomain = true`, which is broken here. +- jetbrains_module_dropped: it only adds a button for a desktop client that + needs JetBrains Toolbox on the operator's machine. +- code_server_installed_into_persistent_home: the upstream Kubernetes example + installs to /tmp, which re-downloads on every restart. Here it lands in + `$HOME/.local/share/code-server-dist`, on the persistent volume. +- startup_script_hardened: upstream opens with `set -e`, which turns any + optional step into a failed build. This one logs and continues. +- cpu_memory_parameters_added: the upstream Docker example exposes none, so + every workspace would be unbounded on a box whose swap is exhausted. +- container_swap_disabled: `memory_swap` equals `memory`, which upstream does + not set at all. +- access_host_pinned_to_gateway: upstream only rewrites loopback access URLs. + This adds a host entry for the real access hostname, verified reachable + through the gateway. diff --git a/templates/docker/main.tf b/templates/docker/main.tf new file mode 100644 index 0000000..815dd77 --- /dev/null +++ b/templates/docker/main.tf @@ -0,0 +1,423 @@ +# 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" + } +}