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
Four templates exist, each with a distinct routing model:
| Template | Web port | Routing model |
|---|---|---|
user-defined-web |
80 | Direct bind, no ddev-router |
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
Run these before every push to avoid CI failures:
# Terraform formatting (CI runs terraform fmt -check -recursive)
terraform fmt -recursive
# Terraform validation for each template you touched
terraform -chdir=user-defined-web init -backend=false && terraform -chdir=user-defined-web validate
terraform -chdir=drupal-core init -backend=false && terraform -chdir=drupal-core validate
terraform -chdir=drupal-contrib init -backend=false && terraform -chdir=drupal-contrib validate
terraform -chdir=freeform init -backend=false && terraform -chdir=freeform validate
# Terraform tests (plan-level, no real infrastructure)
terraform -chdir=drupal-core test
terraform -chdir=drupal-contrib test
terraform -chdir=freeform testterraform 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.
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 four 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
make push-template-user-defined-web
# Build image + push image + push template (user-defined-web only)
make deploy-user-defined-web
# List all templates
coder templates list
# Delete template (must delete workspaces first)
coder templates delete user-defined-web --yes# Create workspace
coder create --template user-defined-web <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-user-defined-web# 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 user-defined-web/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 - Isolation: Each workspace gets separate host directory and Docker volume
Key template variables in user-defined-web/template.tf:
workspace_image_registry- Docker registry URL (default:index.docker.io/ddev/coder-ddev)image_version- Image tag (default: read fromVERSIONfile orv0.1)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
user-defined-web/template.tf- Generic user-defined workspace templatedrupal-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/volumesVERSION- Image version used by all templates (read automatically by Makefile)openspec/project.md- Project conventions and constraintsopenspec/AGENTS.md- OpenSpec workflow instructions