[Guide] Development Instances on Coder (Terraform) -v16

Updated for v16. Includes a script to quickly stop running bench. Use: stop-bench

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" {}

# --- Parameters ---

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-16"
  mutable      = true
  
  option {
    name  = "Version 16 (Stable) - Python 3.14"
    value = "version-16"
  }
  option {
    name  = "Develop - Python 3.14"
    value = "develop"
  }
}

data "coder_parameter" "webserver_port" {
  name         = "webserver_port"
  display_name = "Webserver Port"
  description  = "The port for the Frappe webserver."
  default      = "8001"
  mutable      = true
}

data "coder_parameter" "socketio_port" {
  name         = "socketio_port"
  display_name = "SocketIO Port"
  description  = "The port for SocketIO."
  default      = "9001"
  mutable      = true
}

# --- Infrastructure ---

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"
  lifecycle {
    ignore_changes = all
  }
  labels {
    label = "coder.owner"
    value = data.coder_workspace_owner.me.name
  }
}

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=%",
  ]
  ports {
    internal = 3306
    external = 3306
    protocol = "tcp"
  }
  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  = "150s"
    retries  = 5
  }

  lifecycle {
    ignore_changes = all
  }
}

resource "docker_container" "redis" {
  image = "redis:alpine"
  name  = "redis-${data.coder_workspace.me.name}"
  ports {
    internal = 6379
    external = 6379
    protocol = "tcp"
  }

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

  lifecycle {
    ignore_changes = all
  }
}

# --- Coder Agent ---

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
    "WEBSERVER_PORT"    = data.coder_parameter.webserver_port.value
    "SOCKETIO_PORT"     = data.coder_parameter.socketio_port.value
    "DB_HOST"           = "127.0.0.1"
    "REDIS_CACHE"       = "127.0.0.1:6379"
    "REDIS_QUEUE"       = "127.0.0.1:6379"
    "REDIS_SOCKETIO"    = "127.0.0.1:6379"
    "SHELL"             = "/usr/bin/zsh"
  }

  startup_script = <<-EOT
    set -e

    if ! command -v zsh >/dev/null 2>&1; then
        sudo apt-get update && sudo apt-get install -y zsh git
    fi

    if [ ! -d "$HOME/.oh-my-zsh" ]; then
        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}"
    STOP_SCRIPT="/home/frappe/stop.py"
    WEBSERVER_PORT="${data.coder_parameter.webserver_port.value}"
    SOCKETIO_PORT="${data.coder_parameter.socketio_port.value}"

    cat << 'EOF' > $STOP_SCRIPT
#!/usr/bin/env python
import os, socket, errno, sys

def stop_bench():
    webserver_port = int(os.environ.get('WEBSERVER_PORT', '8001'))
    socketio_port = int(os.environ.get('SOCKETIO_PORT', '9001'))
    
    os.system("ps aux | grep 'frappe-bench/env/bin/python' | grep -v grep | awk '{print $2}' | xargs -r kill -9")
    os.system("ps aux | grep 'apps/frappe/socketio.js' | grep -v grep | awk '{print $2}' | xargs -r kill -9")
    os.system("pkill -f '^bench' || true")
    for port in [webserver_port, socketio_port]:
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        try:
            sock.bind(("127.0.0.1", port))
        except socket.error as e:
            if e.errno == errno.EADDRINUSE:
                os.system(f"fuser {port}/tcp -k >/dev/null 2>&1")
        finally:
            sock.close()

if __name__ == '__main__':
    stop_bench()
    print('--- Bench Stopped (IDE Connection Safe) ---')
EOF
    chmod +x $STOP_SCRIPT

    if ! grep -q "alias stop-bench" ~/.zshrc; then
        echo "alias stop-bench='python3 $STOP_SCRIPT'" >> ~/.zshrc
        echo "alias start-bench='bench start'" >> ~/.zshrc
        echo "cd $PROJECT_DIR" >> ~/.zshrc
    fi

    BRANCH="${data.coder_parameter.frappe_version.value}"
    PYTHON_VERSION="3.14" 
    [ "$BRANCH" = "develop" ] && PYTHON_VERSION="3.14"

    if command -v uv >/dev/null 2>&1; then
        uv python install $PYTHON_VERSION
    fi

    if [ ! -f "$PROJECT_DIR/Procfile" ]; then
        bench init --skip-redis-config-generation --frappe-branch $BRANCH --python $PYTHON_VERSION $PROJECT_DIR
        cd "$PROJECT_DIR"
        bench set-config -g db_host 127.0.0.1
        bench set-config -g redis_cache redis://127.0.0.1:6379
        bench set-config -g redis_queue redis://127.0.0.1:6379
        bench set-config -g redis_socketio redis://127.0.0.1:6379
        bench set-config -g webserver_port $WEBSERVER_PORT
        bench set-config -g socketio_port $SOCKETIO_PORT
        sed -i "s/--port 8000/--port $WEBSERVER_PORT/" Procfile
        sed -i "s/--bind 0.0.0.0:8000/--bind 0.0.0.0:$WEBSERVER_PORT/" Procfile
        sed -i "s/--port 9000/--port $SOCKETIO_PORT/" Procfile
        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
    else
        cd "$PROJECT_DIR"
        
        # Update global config for localhost (host networking)
        bench set-config -g db_host 127.0.0.1
        bench set-config -g redis_cache redis://127.0.0.1:6379
        bench set-config -g redis_queue redis://127.0.0.1:6379
        bench set-config -g redis_socketio redis://127.0.0.1:6379
        bench set-config -g webserver_port $WEBSERVER_PORT
        bench set-config -g socketio_port $SOCKETIO_PORT
        
        # Update site-specific config
        if [ -f "sites/dev.localhost/site_config.json" ]; then
            bench --site dev.localhost set-config redis_cache redis://127.0.0.1:6379 2>/dev/null || true
            bench --site dev.localhost set-config redis_queue redis://127.0.0.1:6379 2>/dev/null || true
            bench --site dev.localhost set-config redis_socketio redis://127.0.0.1:6379 2>/dev/null || true
        fi
        
        # Update Procfile to use configured ports if not already updated
        if grep -q "bind 0.0.0.0:8000" Procfile 2>/dev/null; then
            sed -i "s/--bind 0.0.0.0:8000/--bind 0.0.0.0:$WEBSERVER_PORT/" Procfile
            echo "✅ Updated Procfile to use port $WEBSERVER_PORT"
        fi
        if grep -q "port 9000" Procfile 2>/dev/null; then
            sed -i "s/--port 9000/--port $SOCKETIO_PORT/" Procfile
            echo "✅ Updated Procfile to use socketio port $SOCKETIO_PORT"
        fi
    fi

    echo "🎉 Ready! Frappe running on port $WEBSERVER_PORT (SocketIO: $SOCKETIO_PORT), Terminal set to Zsh in $PROJECT_DIR"
  EOT
}

# --- Apps & Container ---

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 "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"
  network_mode = "host"
  env = ["CODER_AGENT_TOKEN=${coder_agent.main.token}"]
  entrypoint = ["sh", "-c", replace(coder_agent.main.init_script, "/localhost|127\\.0\\.0\\.1/", "host.docker.internal")]

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

  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
  }
}