coder: desktop template (KasmVNC + dotfiles) for the homelab cluster

Kubernetes template for coder.nic-oconnor.com: an XFCE desktop served by
the KasmVNC module, with this repo applied by the dotfiles module. Home is
a truenas-iscsi PVC so $HOME tools and op.env survive stops.

Pinned to enterprise-desktop:ubuntu-noble + KasmVNC 1.5.0 because the
plain `ubuntu` tag is now resolute, which KasmVNC has no package for.
Path-based app (no wildcard access URL). An ephemeral 1Password token
parameter lets bootstrap's secrets module run unattended on first start.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-18 13:08:35 -04:00
co-authored by Claude Opus 5
parent ffedc84cfb
commit 1524fa8203
5 changed files with 476 additions and 0 deletions
+7
View File
@@ -95,6 +95,13 @@ Already have a clone? Just run it directly:
are read from the **local checkout**, not re-downloaded from Gitea, so this
repo does not have to be public for a shell to work; see its own README.
Not run by `bootstrap.sh`:
- `coder/desktop/` — Coder template for coder.nic-oconnor.com: an XFCE
desktop (KasmVNC) pod on the homelab cluster that applies this repo via the
dotfiles module. Push with `coder templates push desktop -d coder/desktop`;
see its README.
## Per-machine customizations and secrets
`~/.zshrc` is managed — bootstrap overwrites it, so never hand-edit it.
+2
View File
@@ -0,0 +1,2 @@
.terraform/
*.tfstate*
+43
View File
@@ -0,0 +1,43 @@
# 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.5.0"
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/hashicorp/kubernetes" {
version = "3.2.1"
hashes = [
"h1:XccFuTe/eJ94vkoZNYUL3wsdXJwlxHguIY36jildRQ8=",
"zh:067fe16a852d42e0f571712e36cb3e71855f917ea2041415e155f56ebc480d7f",
"zh:2815e174f8f0f032ea3a64f2196740ad000a39f88ae5646e7061bf15ed589f62",
"zh:2f94f6b689c59c43e596e724228f2861095d02c2a2ac2a257a4619667135ac75",
"zh:3e807310c84f11561b9ba06b978f03c46cfeca2e84ad0803d34d5d30a8a637cb",
"zh:5cba6f92202c60cac6898141356420709f5341b80ae4c360725cc647f86188ff",
"zh:72b841b6f0820d8f87c3d7c5a3611c35121ab9a4c1db4ea7a98b0319f209e474",
"zh:74770b892ee9b04829d92318d9e8ca96f8143b0c6c766e4141901908173fd01d",
"zh:7a723c8ebf9e218d0f7a0cfe6c0437f2b5eeb7ae015a14fad16e0f7fd9ef79ab",
"zh:a0f5073b2636a3894d4e9dd1b6853d5f324dd78728313bff79b842e5e9eca96f",
"zh:c13241cba993ef63a537beb6a1caf00e233bb045b50d197349530de0ee3276d5",
"zh:d52826f4b0227b7db99ea4a1d48f49a0bfb440563c92ebd2f8faec273c856c2d",
"zh:dc1cf5505a39a264a650b0830f74150ad02368787e5ead89e4007034f8f47831",
]
}
+50
View File
@@ -0,0 +1,50 @@
# coder/desktop — Coder desktop template
A Coder template for <https://coder.nic-oconnor.com>: an XFCE desktop in the
browser (the [KasmVNC module](https://registry.coder.com/modules/coder/kasmvnc))
running as a pod on the homelab Kubernetes cluster, set up from this repo by the
[dotfiles module](https://registry.coder.com/modules/coder/dotfiles).
## Push / update
```sh
coder login https://coder.nic-oconnor.com
coder templates push desktop -d coder/desktop -y
```
The in-cluster provisioner uses the `coder` service account, whose workspace
Role is bound in the `coder` namespace — which is where workspaces go.
## What a workspace gets
- `codercom/enterprise-desktop:ubuntu-noble`: XFCE, git, python3, and the
`coder` user (uid 1000) with passwordless sudo.
- A PVC (`truenas-iscsi`, default 30 GiB) at `/home/coder`. Everything the
dotfiles install into `$HOME` survives stops; system packages (zsh, docker
CLI, KasmVNC) are reinstalled on each start because the root filesystem
is not persisted.
- The KasmVNC app, path-based (`subdomain = false`) because the deployment has
no wildcard access URL.
- A "Dotfiles" startup script: clones this repo into
`~/.config/coderv2/dotfiles` and runs `bootstrap.sh` (log:
`~/.dotfiles.log`), plus a "Refresh Dotfiles" button. The first run takes
about 5 minutes.
- Pod requests 0.5 CPU / 1 GiB; the CPU and memory parameters set limits (the
nodes are ~4 CPU / ~5 GiB and mostly requested already).
## 1Password secrets
bootstrap's `secrets/` module needs `OP_SERVICE_ACCOUNT_TOKEN` when there is
no TTY. Paste it into the **1Password service-account token** parameter when
you create the workspace. It is ephemeral: used for that build only, then
`~/.config/dotfiles/op.env` on the home volume carries it. Leave it empty and
bootstrap still applies everything else; `secrets`, `mcp-config` and `hermes`
are reported as not completed. Fix that later from a workspace terminal with
`~/.config/coderv2/dotfiles/secrets/install.sh`, then refresh the dotfiles.
## Why these pins
- `ubuntu-noble`, not `ubuntu`: the `ubuntu` tag is now 26.04 (resolute), and
KasmVNC publishes no resolute package, so the module's install 404s.
- `kasm_version = "1.5.0"`: the newest KasmVNC release, which has a noble
build. Revisit both once KasmVNC ships a resolute `.deb`.
+374
View File
@@ -0,0 +1,374 @@
# coder/desktop — Coder template: an XFCE desktop in the browser (KasmVNC)
# on the homelab Kubernetes cluster, set up from this dotfiles repo.
#
# Push it with: coder templates push desktop -d coder/desktop
#
# The workspace is a Deployment (replicas 0/1 follows start/stop) plus a PVC
# for /home/coder, so the home directory — ~/.local/bin tools, the dotfiles
# checkout, ~/.config/dotfiles/op.env — survives stops and rebuilds.
terraform {
required_providers {
coder = {
source = "coder/coder"
}
kubernetes = {
source = "hashicorp/kubernetes"
}
}
}
provider "coder" {}
variable "use_kubeconfig" {
type = bool
description = "Use ~/.kube/config instead of the in-cluster service account. Leave false when the provisioner runs inside the cluster (the default Helm install)."
default = false
}
variable "namespace" {
type = string
description = "Namespace for workspaces. The coder service account's workspace Role is bound here."
default = "coder"
}
variable "storage_class" {
type = string
description = "StorageClass for the home volume (the cluster has no default class)."
default = "truenas-iscsi"
}
provider "kubernetes" {
config_path = var.use_kubeconfig ? "~/.kube/config" : null
}
data "coder_provisioner" "me" {}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
# --- parameters --------------------------------------------------------------
# Nodes have ~4 CPU / ~5 GiB allocatable and are already mostly requested, so
# the pod REQUESTS little (it schedules anywhere) and these set the LIMITS.
data "coder_parameter" "cpu" {
name = "cpu"
display_name = "CPU limit"
description = "CPU cores the workspace may burst to."
icon = "/icon/memory.svg"
type = "number"
default = "2"
mutable = true
order = 1
option {
name = "2 cores"
value = "2"
}
option {
name = "3 cores"
value = "3"
}
option {
name = "4 cores"
value = "4"
}
}
data "coder_parameter" "memory" {
name = "memory"
display_name = "Memory limit"
description = "Memory in GiB the workspace may use (includes the 1 GiB /dev/shm)."
icon = "/icon/memory.svg"
type = "number"
default = "4"
mutable = true
order = 2
option {
name = "3 GB"
value = "3"
}
option {
name = "4 GB"
value = "4"
}
}
data "coder_parameter" "home_disk_size" {
name = "home_disk_size"
display_name = "Home disk size"
description = "Size of /home/coder in GiB. Can be grown later, not shrunk."
icon = "/emojis/1f4be.png"
type = "number"
default = "30"
mutable = false
order = 3
validation {
min = 10
max = 200
}
}
# The dotfiles secrets/ module reads the 1Password vault non-interactively
# when OP_SERVICE_ACCOUNT_TOKEN is in the environment. It writes
# ~/.config/dotfiles/op.env on the home volume, so the token is only needed
# on the FIRST start: ephemeral means it is not carried into later builds.
data "coder_parameter" "op_service_account_token" {
name = "op_service_account_token"
display_name = "1Password service-account token"
description = "Optional, first start only. Lets the dotfiles secrets module pull your vault (LITELLM_KEY, GITEA_KEY, ...). Stored in ~/.config/dotfiles/op.env after that. Leave empty to skip."
icon = "/icon/1password.svg"
type = "string"
default = ""
mutable = true
ephemeral = true
order = 4
styling = jsonencode({
mask_input = true
})
}
data "coder_parameter" "op_vault" {
name = "op_vault"
display_name = "1Password vault"
description = "Vault holding your secrets item. Empty = the only vault the token can see."
icon = "/icon/1password.svg"
type = "string"
default = "t-mint"
mutable = true
order = 5
}
data "coder_parameter" "op_item" {
name = "op_item"
display_name = "1Password item"
description = "Item whose fields become env vars. Empty = the only item in the vault."
icon = "/icon/1password.svg"
type = "string"
default = "noconnor"
mutable = true
order = 6
}
locals {
name = "coder-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}"
labels = {
"app.kubernetes.io/name" = "coder-workspace"
"app.kubernetes.io/instance" = local.name
"app.kubernetes.io/part-of" = "coder"
"com.coder.resource" = "true"
"com.coder.workspace.id" = data.coder_workspace.me.id
"com.coder.workspace.name" = data.coder_workspace.me.name
"com.coder.user.id" = data.coder_workspace_owner.me.id
"com.coder.user.username" = data.coder_workspace_owner.me.name
}
# Only the non-empty ones: an empty OP_VAULT/OP_ITEM would override the
# secrets module's own auto-detection.
op_env = { for k, v in {
OP_SERVICE_ACCOUNT_TOKEN = data.coder_parameter.op_service_account_token.value
OP_VAULT = data.coder_parameter.op_vault.value
OP_ITEM = data.coder_parameter.op_item.value
} : k => v if v != "" }
}
# --- agent -------------------------------------------------------------------
resource "coder_agent" "main" {
arch = data.coder_provisioner.me.arch
os = "linux"
# The home PVC hides whatever the image put in /home/coder, so seed it on
# first start: the skel dotfiles, plus the image's .xsessionrc (XFCE session
# env that KasmVNC's X session reads).
startup_script = <<-EOT
set -e
if [ ! -f ~/.init_done ]; then
cp -rT /etc/skel ~ || true
touch ~/.init_done
fi
if [ ! -f ~/.xsessionrc ]; then
printf '%s\n' \
'export GNOME_SHELL_SESSION_MODE=ubuntu' \
'export XDG_CURRENT_DESKTOP=xfce' \
'export XDG_SESSION_TYPE=x11' > ~/.xsessionrc
fi
EOT
env = merge({
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
}, local.op_env)
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
}
}
# --- modules -----------------------------------------------------------------
# Clones the repo into ~/.config/coderv2/dotfiles and runs its bootstrap.sh on
# every start (each module is idempotent). The first run takes ~5 minutes;
# progress is in ~/.dotfiles.log and the "Dotfiles" script log in the UI.
module "dotfiles" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder"
version = "1.4.2"
agent_id = coder_agent.main.id
default_dotfiles_uri = "http://git.nic-oconnor.com/public/dotfiles.git"
manual_update = true
}
# XFCE is in the image; the module installs KasmVNC and serves it on :6800.
# kasm_version: KasmVNC ships a .deb per Ubuntu codename, and the module's
# default (1.4.0) is older than we need; 1.5.0 has noble.
# subdomain = false: the deployment has no wildcard access URL, so the app
# is served path-based under coder.nic-oconnor.com.
module "kasmvnc" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/kasmvnc/coder"
version = "1.3.0"
agent_id = coder_agent.main.id
desktop_environment = "xfce"
kasm_version = "1.5.0"
subdomain = false
}
# --- kubernetes resources ----------------------------------------------------
resource "kubernetes_persistent_volume_claim_v1" "home" {
metadata {
name = "${local.name}-home"
namespace = var.namespace
labels = merge(local.labels, { "app.kubernetes.io/component" = "home" })
annotations = { "com.coder.user.email" = data.coder_workspace_owner.me.email }
}
wait_until_bound = false
spec {
access_modes = ["ReadWriteOnce"]
storage_class_name = var.storage_class
resources {
requests = {
storage = "${data.coder_parameter.home_disk_size.value}Gi"
}
}
}
}
resource "kubernetes_deployment_v1" "main" {
count = data.coder_workspace.me.start_count
depends_on = [kubernetes_persistent_volume_claim_v1.home]
wait_for_rollout = false
metadata {
name = local.name
namespace = var.namespace
labels = merge(local.labels, { "app.kubernetes.io/component" = "workspace" })
annotations = { "com.coder.user.email" = data.coder_workspace_owner.me.email }
}
spec {
replicas = 1
selector {
match_labels = local.labels
}
# RWO home volume: the old pod must let go of it before the new one starts.
strategy {
type = "Recreate"
}
template {
metadata {
labels = local.labels
}
spec {
# Runs as the image's coder user (uid 1000), which has passwordless
# sudo. The KasmVNC module and the dotfiles modules (zsh, docker)
# need it for apt; the namespace enforces PSA baseline, which allows it.
security_context {
run_as_user = 1000
fs_group = 1000
run_as_non_root = true
}
container {
name = "dev"
image = "codercom/enterprise-desktop:ubuntu-noble"
image_pull_policy = "IfNotPresent"
command = ["sh", "-c", coder_agent.main.init_script]
env {
name = "CODER_AGENT_TOKEN"
value = coder_agent.main.token
}
resources {
requests = {
cpu = "500m"
memory = "1Gi"
}
limits = {
cpu = data.coder_parameter.cpu.value
memory = "${data.coder_parameter.memory.value}Gi"
}
}
volume_mount {
mount_path = "/home/coder"
name = "home"
}
# The default 64 MiB /dev/shm crashes browsers inside the desktop.
volume_mount {
mount_path = "/dev/shm"
name = "dshm"
}
}
volume {
name = "home"
persistent_volume_claim {
claim_name = kubernetes_persistent_volume_claim_v1.home.metadata[0].name
}
}
volume {
name = "dshm"
empty_dir {
medium = "Memory"
size_limit = "1Gi"
}
}
affinity {
pod_anti_affinity {
preferred_during_scheduling_ignored_during_execution {
weight = 1
pod_affinity_term {
topology_key = "kubernetes.io/hostname"
label_selector {
match_expressions {
key = "app.kubernetes.io/name"
operator = "In"
values = ["coder-workspace"]
}
}
}
}
}
}
}
}
}
}