[Guide] Development Instances on Coder (Terraform)

Here is a terraform template to quickly spin-up frappe development workspaces on Coder.

terraform {
  required_providers {
    coder = {
      source = "coder/coder"
    }
    docker = {
      source = "kreuzwerker/docker"
    }
  }
}

provider "docker" {
}

data "coder_provisioner" "me" {}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}

data "coder_parameter" "project_name" {
  name         = "project_name"
  display_name = "Project Name"
  description  = "The name of the Frappe Bench folder."
  default      = "frappe-bench"
  mutable      = true
}

data "coder_parameter" "frappe_version" {
  name         = "frappe_version"
  display_name = "Frappe Version"
  description  = "Select the Frappe branch to install."
  default      = "version-15"
  mutable      = true
  
  option {
    name  = "Version 15 (Stable) - Python 3.11"
    value = "version-15"
  }
  option {
    name  = "Develop (v16 Alpha) - Python 3.14"
    value = "develop"
  }
}

resource "docker_network" "frappe_network" {
  name = "frappe-${data.coder_workspace.me.id}"
}

resource "docker_volume" "home_volume" {
  name = "coder-${data.coder_workspace.me.id}-home"
  lifecycle {
    ignore_changes = all
  }
  labels {
    label = "coder.owner"
    value = data.coder_workspace_owner.me.name
  }
}

resource "docker_volume" "mariadb_data" {
  name = "coder-${data.coder_workspace.me.id}-mariadb"
}

resource "docker_container" "mariadb" {
  image = "mariadb:10.6"
  name  = "mariadb-${data.coder_workspace.me.name}"
  
  command = [
    "--character-set-server=utf8mb4",
    "--collation-server=utf8mb4_unicode_ci",
    "--skip-character-set-client-handshake"
  ]
  
  env = [
    "MYSQL_ROOT_PASSWORD=123",
    "MYSQL_ROOT_HOST=%"
  ]
  
  networks_advanced {
    name = docker_network.frappe_network.name
    aliases = ["mariadb"]
  }
  
  volumes {
    container_path = "/var/lib/mysql"
    volume_name    = docker_volume.mariadb_data.name
  }
  
  healthcheck {
    test     = ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-p123"]
    interval = "10s"
    timeout  = "5s"
    retries  = 5
  }
}

resource "docker_container" "redis" {
  image = "redis:alpine"
  name  = "redis-${data.coder_workspace.me.name}"
  
  networks_advanced {
    name = docker_network.frappe_network.name
    aliases = ["redis-cache", "redis-queue", "redis-socketio"]
  }

  healthcheck {
    test     = ["CMD", "redis-cli", "ping"]
    interval = "10s"
    timeout  = "5s"
    retries  = 5
  }
}

resource "coder_agent" "main" {
  arch = data.coder_provisioner.me.arch
  os   = "linux"

  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}"
    "FRAPPE_BRANCH"     = data.coder_parameter.frappe_version.value
    "DB_HOST"           = "mariadb"
    "REDIS_CACHE"       = "redis-cache:6379"
    "REDIS_QUEUE"       = "redis-queue:6379"
    "REDIS_SOCKETIO"    = "redis-socketio:6379"
    "SHELL"             = "/usr/bin/zsh"
  }

  startup_script = <<-EOT
    set -e

    if ! command -v zsh >/dev/null 2>&1; then
        echo "🎨 Installing Zsh..."
        sudo apt-get update && sudo apt-get install -y zsh git
    fi

    if [ ! -d "$HOME/.oh-my-zsh" ]; then
        echo "🖌️  Installing Oh My Zsh..."
        sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended
        sed -i 's/ZSH_THEME="robbyrussell"/ZSH_THEME="bira"/' ~/.zshrc
        sed -i 's/plugins=(git)/plugins=(git python sudo)/' ~/.zshrc
        sudo chsh -s /usr/bin/zsh $(whoami)
    fi

    PROJECT_DIR="/home/frappe/${data.coder_parameter.project_name.value}"

    if ! grep -q "cd $PROJECT_DIR" ~/.zshrc; then
        echo "" >> ~/.zshrc
        echo "# Default to Bench Directory" >> ~/.zshrc
        echo "cd $PROJECT_DIR" >> ~/.zshrc
    fi

    BRANCH="${data.coder_parameter.frappe_version.value}"
    PYTHON_VERSION="3.11" 

    if [ "$BRANCH" = "develop" ]; then
        echo "🚧 Branch is 'develop', switching to Python 3.14..."
        PYTHON_VERSION="3.14"
    fi

    if command -v uv >/dev/null 2>&1; then
        echo "🐍 Installing Python $PYTHON_VERSION via uv..."
        uv python install $PYTHON_VERSION
    fi

    if [ ! -f "$PROJECT_DIR/Procfile" ]; then
        echo "🚀 Initializing New Bench..."
        bench init --skip-redis-config-generation --frappe-branch $BRANCH --python $PYTHON_VERSION $PROJECT_DIR
        cd "$PROJECT_DIR"
        
        bench set-config -g db_host mariadb
        bench set-config -g redis_cache redis://redis-cache:6379
        bench set-config -g redis_queue redis://redis-queue:6379
        bench set-config -g redis_socketio redis://redis-socketio:6379
        
        bench new-site dev.localhost --mariadb-root-password "123" --admin-password "admin" --mariadb-user-host-login-scope='%' --db-root-username root
        bench --site dev.localhost set-config developer_mode 1
        bench --site dev.localhost set-config ignore_csrf 1
        bench --site dev.localhost set-config allow_cors '*'
        bench use dev.localhost
    fi

    echo "🎉 Terminal set to Zsh in $PROJECT_DIR"
  EOT
}

module "code-server" {
  count    = data.coder_workspace.me.start_count
  source   = "registry.coder.com/coder/code-server/coder"
  version  = "~> 1.0"
  agent_id = coder_agent.main.id
  folder   = "/home/frappe/${data.coder_parameter.project_name.value}"
  order    = 1
}

resource "coder_app" "frappe" {
  agent_id     = coder_agent.main.id
  slug         = "frappe"
  display_name = "Frappe Desk"
  url          = "http://localhost:8000"
  icon         = "https://frappe.io/files/frappe-framework-logo.png"
  subdomain    = true
  share        = "owner"

  healthcheck {
    url       = "http://localhost:8000"
    interval  = 5
    threshold = 10
  }
}

resource "docker_container" "workspace" {
  count = data.coder_workspace.me.start_count
  image = "frappe/bench:latest"
  name  = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}"
  user = "1000"
  
  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}"
  ]
  
  networks_advanced {
    name = docker_network.frappe_network.name
  }

  volumes {
    container_path = "/home/frappe"
    volume_name    = docker_volume.home_volume.name
    read_only      = false
  }

  host {
    host = "host.docker.internal"
    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
  }
}