This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
This project provides a Coder v2+ template for DDEV-based development environments using Docker-in-Docker via Sysbox runtime. It creates isolated workspaces with full Docker daemon support for running DDEV projects.
Key Technologies:
- Terraform (HCL) - Infrastructure as Code for Coder templates
- Docker + Sysbox - Nested containerization without privileged mode
- DDEV - PHP/Node/Python development environment tool (supports 20+ project types)
- VS Code for Web - Browser-based IDE via official Coder module
- Ubuntu 24.04 - Base container OS
Note: User-facing documentation has moved to /docs/. This file focuses on developer/contributor guidance.
When writing or reviewing coder CLI commands in documentation, always verify against the actual CLI (coder <subcommand> --help) and the Coder CLI reference docs. Common past mistakes to avoid:
coder deletetakes only one workspace argument — use aforloop for multiplecoder stop/coder starttake only one workspace argumentcoder scpdoes not exist — usescpaftercoder config-sshcoder templates showdoes not exist — usecoder templates list -o json | jq ...coder publickeyhas noadd/list/removesubcommands — it only outputs your keycoder listhas no--userflag — use--search "owner:<username>"with-acoder tokensusesremovenotrevoke;createtakes--name, not a positional argcoder templates listhas no--organizationflag — use the global--orgcoder sharing addrequires--user <username>:<role>(role required)
ddev composer createis obsolete — always useddev composer create-project <package> .(note the trailing.for current directory)- Do not pin packages to
:dev-main(e.g.package:dev-main) — omit the version constraint and let Composer resolve it - All templates omit
ddev-routerviaddev config global --omit-containers=ddev-router; each template uses direct port binding instead
Three templates exist, each with a distinct routing model:
| Template | Web port | Routing model |
|---|---|---|
drupal-core |
80 | Direct bind, no ddev-router |
drupal-contrib |
8080 | Direct bind, no ddev-router |
freeform |
8080 | ddev-router on 8080, Host-header dispatch per project |
The freeform template is unique: it keeps ddev-router running so multiple DDEV projects can coexist in one workspace, distinguished by Host header.
- Use
jq(notpython3 -m json.tool) for JSON pretty-printing and querying - For
git commit -mandgh pr create --body, write the message to a file first (e.g. under the scratchpad directory) and pass it viagit commit -F <file>/gh pr create --body-file <file>, rather than inlining a heredoc on the command line. Multi-paragraph messages containing backticks, apostrophes, or other shell-special characters reliably break inline heredoc quoting.
Run these before every push to avoid CI failures:
# Terraform formatting (CI runs terraform fmt -check -recursive)
terraform fmt -recursive
# Vendor shared assets, then validate + test every template
make validate
make test-templatesterraform fmt -recursive must be run from the repo root. It is non-destructive (rewrites in place) and the CI check fails with exit code 3 if any file is not formatted.
Always use make validate / make test-templates, not raw terraform validate/terraform test run directly in a template directory. Both targets depend on make sync-shared, which vendors modules/claude-remote-control and shared/vscode-extensions.tf into each template directory first (see "Shared Terraform Assets" below). Running terraform validate/test directly against a template dir will happily validate against a stale vendored copy and miss the fact that it's out of sync with the canonical source.
Some Terraform config (currently modules/claude-remote-control and shared/vscode-extensions.tf) is shared across all three templates but must exist as a physical copy inside each template directory — coder templates push and CI's terraform validate/test only ever operate on a single template directory, so a relative ../modules or ../shared reference would not survive the push and would not be visible to CI.
- Edit only the canonical source:
modules/claude-remote-control/orshared/vscode-extensions.tfat the repo root. Never hand-edit the vendored copies (<template>/modules/claude-remote-control/,<template>/vscode-extensions.tf) — they get overwritten the next time anyone runs the sync. - Always resync through
make, never by hand-copying:make sync-shared(ormake sync-claude-module/make sync-vscode-extensionsindividually).make validate,make test-templates, and everymake push-template-*/make push-all-templatestarget already depend onsync-shared, so day-to-day you rarely need to invoke it directly. - Commit the vendored copies. They are tracked files, not build artifacts — CI validates each template directory standalone with no
makestep, so the vendored copies must already be correct and committed for CI to pass.
After running coder config-ssh --yes, workspaces are available as SSH hosts named <workspace>.coder. Use scp to copy files in or out, then ssh to execute scripts non-interactively:
# Configure SSH (once)
coder config-ssh --yes
# Copy a file into a workspace
scp ./local-file.sh mp1.coder:/tmp/
# Execute a script non-interactively (preferred — avoids PTY/pipe issues)
ssh mp1.coder bash /tmp/local-file.sh
# One-liner for quick commands
ssh mp1.coder ddev listWhen running commands via coder ssh -- ... or piped heredocs, the PTY allocation causes interactive prompts and pipe-stall issues. Writing a script to /tmp/ and executing it via ssh workspace.coder bash /tmp/script.sh is reliable for multi-step operations.
CI scripting rule: In GitHub Actions, for any coder ssh invocation that does more than a single trivial command, push a script file and run it — do not use bash -c "...". Single commands with standard flags (e.g. git -C /path branch --show-current) are fine inline. Anything with &&, conditionals, or multiple statements belongs in a script file under the template's scripts/ directory.
To run a command in a specific directory without a shell wrapper, use env -C <dir> <cmd> (available on Ubuntu 24.04 via GNU coreutils): coder ssh ws -- env -C /home/coder/myproject ddev drush status
# Push all templates (no image build — use when only HCL changed)
make push-all-templates
# Push a single template
make push-template-drupal-core
make push-template-drupal-contrib
make push-template-freeform
# List all templates
coder templates list
# Delete template (must delete workspaces first)
coder templates delete <template-name> --yes# Create workspace
coder create --template freeform <workspace-name> --yes
# List workspaces
coder list
# SSH into workspace
coder ssh <workspace-name>
# Stop/start workspace
coder stop <workspace-name>
coder start <workspace-name>
# Delete workspace
coder delete <workspace-name> --yes# Build image
make build
# Build and push to registry
make build-and-push
# Update version for new releases
echo "v0.2" > VERSION
make deploy-drupal-core# Initialize DDEV in project directory
# Replace <project-type> with your type: php, wordpress, laravel, drupal, etc.
ddev config --project-type=<project-type> --docroot=web
# Common examples:
# ddev config --project-type=wordpress --docroot=web
# ddev config --project-type=laravel --docroot=public
# ddev config --project-type=drupal --docroot=web
# ddev config --project-type=php --docroot=web
# Start DDEV environment
ddev start
# Stop DDEV environment
ddev stop
# Check DDEV status
ddev describe# VS Code for Web is automatically available via Coder's official module
# Access via Coder dashboard under "Apps" section
# Opens at /home/coder directory with full IDE features
# Module: registry.coder.com/coder/vscode-web/coder ~> 1.0 (auto-updates to latest 1.0.x)# List active change proposals
openspec list
# List existing specifications
openspec list --specs
# Validate a change proposal
openspec validate <change-id> --strict
# Archive completed change
openspec archive <change-id> --yesThe template uses Sysbox-runc instead of privileged Docker containers:
- Provides safe nested Docker without
--privilegedflag - Each workspace gets its own isolated Docker daemon inside the container
- Docker data persisted in dedicated volume at
/var/lib/docker - Security profiles:
apparmor:unconfined,seccomp:unconfined
/home/coder/ # Persistent workspace home (volume-backed)
├── projects/ # DDEV projects location
├── .ddev/ # DDEV global configuration
│ └── global_config.yaml # Copied from image on first run
├── WELCOME.txt # Workspace welcome message
└── .npm-global/ # User-scoped npm packages
/home/coder-files/ # Image-embedded files (outside volume)
├── .ddev/global_config.yaml # DDEV defaults
└── WELCOME.txt # Welcome template
Critical: The /home/coder volume mount hides image contents, so files must be copied from /home/coder-files/ during startup script execution.
The startup script is inline in each template's template.tf (e.g. freeform/template.tf, inside the coder_agent resource's startup_script field). It performs:
- Permissions - Fix ownership of
/home/codervolume - Home initialization - Copy skeleton files if first run
- Git SSH setup - Configure Coder's GitSSH wrapper
- File copy - Transfer
/home/coder-files/*to home directory - Docker daemon - Start
dockerdvia sudo, wait for socket - DDEV config - Copy
global_config.yamlto~/.ddev/ - DDEV verification - Verify DDEV installation and Docker connectivity
- Environment - Set locale, PATH, workspace variables
Note: VS Code for Web is managed by the official Coder module and starts automatically.
- Home directory: Host path
/coder-workspaces/<owner>-<workspace>→ Container/home/coder - Docker cache: Named volume
coder-<owner>-<workspace>-dind-cache→/var/lib/docker - Homebrew: Named volume
coder-<owner>-<workspace>-linuxbrew→/home/linuxbrew— persists the Homebrew Cellar across workspace stop/start, sobrew upgrade/brew installdone inside a workspace survives restarts. Unlike the/home/coderbind mount, a fresh named volume is auto-populated by Docker from the image's existing/home/linuxbrewcontents on first mount, so no manual copy step is needed — only an ownership fix (sudo chown -R coder:coder /home/linuxbrew) in the startup script.HOMEBREW_CACHEis set to/home/linuxbrew/.cache/Homebrew(via the Dockerfile) so the cache and Cellar always stay on the same filesystem, avoiding cross-device link (EXDEV) errors. - Isolation: Each workspace gets separate host directory and Docker volumes
Key template variables (e.g. in freeform/template.tf):
workspace_image_registry- Docker registry URL (default:index.docker.io/ddev/coder-ddev)image_version- Image tag (default:v0.1; the Makefile passes--variable image_version=$(cat VERSION)at push time, so the rootVERSIONfile is the single source of truth — no per-template copies)cpu/memory- Resource limits (defaults: 4 cores, 8GB RAM)node_version- Node.js version (default:24, informational only — Node is pre-installed in image)docker_gid- Docker group GID (default:988)registry_username/registry_password- Registry authentication (optional)
- No bundled dev tools - Avoid pre-installing AI agents, custom shells, or opinionated tooling
- Infrastructure-only - This is a base template; users add their own content
- Manual project setup - Template does NOT auto-clone repositories or bootstrap projects
- Standard paths - Always use
/home/coderas workspace home
- This is an infrastructure repository managing Coder templates
- Use feature branches for changes
- Never use the local
mainbranch — alwaysgit fetch upstreamand base branches onupstream/main. Useupstream/mainfor comparisons (e.g.git diff upstream/main...HEAD), not localmain. - Always use OpenSpec for architectural changes (see AGENTS.md)
This project uses OpenSpec for spec-driven development:
- Trigger OpenSpec for: new features, breaking changes, architecture shifts, performance/security work
- Skip OpenSpec for: bug fixes, typos, config tweaks, dependency updates
- Always check
openspec/AGENTS.mdwhen planning significant changes - Read
openspec/project.mdfor project-specific conventions
When to create change proposals:
- Adding/modifying DDEV configuration patterns
- Changing Terraform template structure
- Updating Sysbox integration approach
- Modifying Docker image build process
- Altering workspace lifecycle (startup/shutdown)
The image/Dockerfile builds the base workspace image:
- Base packages - curl, wget, git, vim, sudo, build tools, bash-completion
- User setup - Rename ubuntu user → coder (UID 1000)
- Scripts copy -
COPY scripts /home/coder-files(outside volume mount) - Python/Node - Install Python 3, Node.js 24.x LTS
- Global npm tools - OpenSpec, TypeScript (in image, re-attempted in startup)
- Docker daemon -
docker-ce,docker-ce-cli,containerd, systemd for Sysbox - DDEV - from official apt package (pkg.ddev.com)
- Homebrew (Linuxbrew) -
bats-core,bats-support,bats-assert,go,golangci-lint
- User
codergets passwordless sudo:coder ALL=(ALL) NOPASSWD:ALL - npm global packages go to
/home/coder/.npm-global(user-scoped) - Docker service enabled via systemd:
systemctl enable docker - PATH additions for
.local/binand.npm-global/binin/etc/profile.d/
- Host must have Sysbox installed: download
.debfrom nestybox/sysbox releases and install withapt-get install ./sysbox-ce_<version>.deb(no apt repo available) - Coder agent nodes must use
sysbox-runcruntime - Workspaces must specify
runtime = "sysbox-runc"in Terraform
- DDEV uses Coder's port forwarding (not direct host binding)
- Default ports: HTTP 80/8080, HTTPS 443/8443, Mailpit 8025/8026
- Configure
host_webserver_portin.ddev/global_config.yamlif needed
- Do NOT mount host Docker socket (
/var/run/docker.sock) - Each workspace has isolated Docker daemon via Sysbox
- Container user must be in docker group (GID 988):
group_add = ["988"]
All startup output goes to Coder agent logs. Check with:
# View agent logs in Coder UI or:
docker logs coder-<workspace-id>Additional logs in workspace:
/tmp/dockerd.log- Docker daemon output
Docker daemon not starting:
- Check Sysbox is installed on host:
sysbox-runc --version - Verify container uses
runtime = "sysbox-runc" - Check AppArmor/seccomp profiles in
security_opts
DDEV containers fail to start:
- Ensure Docker daemon is running:
docker ps - Check socket permissions:
ls -la /var/run/docker.sock - Verify Docker volume mounted:
df -h /var/lib/docker
File ownership issues:
- Startup script runs
chown coder:coder /home/coderon each start - Volume may have host UID, fixed automatically
npm global packages missing:
- Packages pre-installed in image, re-attempted in startup script
- Check PATH includes
~/.npm-global/binand~/.local/bin - Manual install:
npm install -g @fission-ai/openspec
drupal-core/template.tf- Drupal core development template (most actively developed)drupal-contrib/template.tf- Drupal contributed module development templatefreeform/template.tf- Multi-project freeform workspace template (keeps ddev-router)image/Dockerfile- Base image build instructions (shared by all templates)image/scripts/.ddev/global_config.yaml- DDEV defaults copied into workspacesscripts/coder-delete-workspace-dir.sh- Sudo wrapper for workspace host dir cleanup (must be installed on server)scripts/cleanup-deleted-workspaces.sh- Manual cleanup for orphaned workspace dirs/volumesscripts/workspace-lifecycle-cleanup.sh- Notifies then deletes idle workspaces; runs via systemd timer on coder.ddev.com (production only, seescripts/workspace-lifecycle-cleanup.service/.timer, must be installed on server)VERSION- Single source of truth for the image version; read by the Makefile and passed to every template as--variable image_version=...at push time (no per-template copies)openspec/project.md- Project conventions and constraintsopenspec/AGENTS.md- OpenSpec workflow instructions