Google Compute Engine (Devcontainer)

Google Compute Engine (Devcontainer)

By coder

Provision a Devcontainer on Google Compute Engine instances as Coder workspaces

Architecture Diagram

Prerequisites

Authentication

This template assumes that coderd is run in an environment that is authenticated with Google Cloud. For example, run gcloud auth application-default login to import credentials on the system and user running coderd. For other ways to authenticate consult the Terraform docs.

Coder requires a Google Cloud Service Account to provision workspaces. To create a service account:

  1. Navigate to the CGP console, and select your Cloud project (if you have more than one project associated with your account)

  2. Provide a service account name (this name is used to generate the service account ID)

  3. Click Create and continue, and choose the following IAM roles to grant to the service account:

    • Compute Admin
    • Service Account User

    Click Continue.

  4. Click on the created key, and navigate to the Keys tab.

  5. Click Add key > Create new key.

  6. Generate a JSON private key, which will be what you provide to Coder during the setup process.

Architecture

This template provisions the following resources:

Coder persists the root volume. The full filesystem is preserved when the workspace restarts. When the GCP VM starts, a startup script runs that ensures a running Docker daemon, and starts an Envbuilder container using this Docker daemon. The Docker socket is also mounted inside the container to allow running Docker containers inside the workspace.

Note This template is designed to be a starting point! Edit the Terraform to extend the template to support your use case.

Caching

To speed up your builds, you can use a container registry as a cache. When creating the template, set the parameter cache_repo to a valid Docker repository in the form host.tld/path/to/repo.

See the Envbuilder Terraform Provider Examples for a more complete example of how the provider works.

code-server

code-server is installed via the code-server registry module. Please check Coder Registry for a list of all modules and templates.

1terraform {
2  required_providers {
3    coder = {
4      source = "coder/coder"
5    }
6    google = {
7      source = "hashicorp/google"
8    }
9    envbuilder = {
10      source = "coder/envbuilder"
11    }
12  }
13}
14
15provider "coder" {}
16
17provider "google" {
18  zone    = module.gcp_region.value
19  project = var.project_id
20}
21
22data "google_compute_default_service_account" "default" {}
23
24data "coder_workspace" "me" {}
25data "coder_workspace_owner" "me" {}
26
27variable "project_id" {
28  description = "Which Google Compute Project should your workspace live in?"
29}
30
31variable "cache_repo" {
32  default     = ""
33  description = "(Optional) Use a container registry as a cache to speed up builds. Example: host.tld/path/to/repo."
34  type        = string
35}
36
37variable "cache_repo_docker_config_path" {
38  default     = ""
39  description = "(Optional) Path to a docker config.json containing credentials to the provided cache repo, if required. This will depend on your Coder setup. Example: `/home/coder/.docker/config.json`."
40  sensitive   = true
41  type        = string
42}
43
44# See https://registry.coder.com/modules/coder/gcp-region
45module "gcp_region" {
46  source = "registry.coder.com/coder/gcp-region/coder"
47  # This ensures that the latest non-breaking version of the module gets downloaded, you can also pin the module version to prevent breaking changes in production.
48  version = "~> 1.0"
49  regions = ["us", "europe"]
50}
51
52data "coder_parameter" "instance_type" {
53  name         = "instance_type"
54  display_name = "Instance Type"
55  description  = "Select an instance type for your workspace."
56  type         = "string"
57  mutable      = false
58  order        = 2
59  default      = "e2-micro"
60  option {
61    name  = "e2-micro (2C, 1G)"
62    value = "e2-micro"
63  }
64  option {
65    name  = "e2-small (2C, 2G)"
66    value = "e2-small"
67  }
68  option {
69    name  = "e2-medium (2C, 2G)"
70    value = "e2-medium"
71  }
72}
73
74data "coder_parameter" "fallback_image" {
75  default      = "codercom/enterprise-base:ubuntu"
76  description  = "This image runs if the devcontainer fails to build."
77  display_name = "Fallback Image"
78  mutable      = true
79  name         = "fallback_image"
80  order        = 3
81}
82
83data "coder_parameter" "devcontainer_builder" {
84  description  = <<-EOF
85Image that will build the devcontainer.
86Find the latest version of Envbuilder here: https://ghcr.io/coder/envbuilder
87Be aware that using the `:latest` tag may expose you to breaking changes.
88EOF
89  display_name = "Devcontainer Builder"
90  mutable      = true
91  name         = "devcontainer_builder"
92  default      = "ghcr.io/coder/envbuilder:latest"
93  order        = 4
94}
95
96data "coder_parameter" "repo_url" {
97  name         = "repo_url"
98  display_name = "Repository URL"
99  default      = "https://github.com/coder/envbuilder-starter-devcontainer"
100  description  = "Repository URL"
101  mutable      = true
102}
103
104data "local_sensitive_file" "cache_repo_dockerconfigjson" {
105  count    = var.cache_repo_docker_config_path == "" ? 0 : 1
106  filename = var.cache_repo_docker_config_path
107}
108
109# Be careful when modifying the below locals!
110locals {
111  # Ensure Coder username is a valid Linux username
112  linux_user = lower(substr(data.coder_workspace_owner.me.name, 0, 32))
113  # Name the container after the workspace and owner.
114  container_name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}"
115  # The devcontainer builder image is the image that will build the devcontainer.
116  devcontainer_builder_image = data.coder_parameter.devcontainer_builder.value
117  # We may need to authenticate with a registry. If so, the user will provide a path to a docker config.json.
118  docker_config_json_base64 = try(data.local_sensitive_file.cache_repo_dockerconfigjson[0].content_base64, "")
119  # The envbuilder provider requires a key-value map of environment variables. Build this here.
120  envbuilder_env = {
121    # ENVBUILDER_GIT_URL and ENVBUILDER_CACHE_REPO will be overridden by the provider
122    # if the cache repo is enabled.
123    "ENVBUILDER_GIT_URL" : data.coder_parameter.repo_url.value,
124    # The agent token is required for the agent to connect to the Coder platform.
125    "CODER_AGENT_TOKEN" : try(coder_agent.dev.0.token, ""),
126    # The agent URL is required for the agent to connect to the Coder platform.
127    "CODER_AGENT_URL" : data.coder_workspace.me.access_url,
128    # The agent init script is required for the agent to start up. We base64 encode it here
129    # to avoid quoting issues.
130    "ENVBUILDER_INIT_SCRIPT" : "echo ${base64encode(try(coder_agent.dev[0].init_script, ""))} | base64 -d | sh",
131    "ENVBUILDER_DOCKER_CONFIG_BASE64" : try(data.local_sensitive_file.cache_repo_dockerconfigjson[0].content_base64, ""),
132    # The fallback image is the image that will run if the devcontainer fails to build.
133    "ENVBUILDER_FALLBACK_IMAGE" : data.coder_parameter.fallback_image.value,
134    # The following are used to push the image to the cache repo, if defined.
135    "ENVBUILDER_CACHE_REPO" : var.cache_repo,
136    "ENVBUILDER_PUSH_IMAGE" : var.cache_repo == "" ? "" : "true",
137    # You can add other required environment variables here.
138    # See: https://github.com/coder/envbuilder/?tab=readme-ov-file#environment-variables
139  }
140  # If we have a cached image, use the cached image's environment variables. Otherwise, just use
141  # the environment variables we've defined above.
142  docker_env_input = try(envbuilder_cached_image.cached.0.env_map, local.envbuilder_env)
143  # Convert the above to the list of arguments for the Docker run command.
144  # The startup script will write this to a file, which the Docker run command will reference.
145  docker_env_list_base64 = base64encode(join("\n", [for k, v in local.docker_env_input : "${k}=${v}"]))
146
147  # Builder image will either be the builder image parameter, or the cached image, if cache is provided.
148  builder_image = try(envbuilder_cached_image.cached[0].image, data.coder_parameter.devcontainer_builder.value)
149
150  # The GCP VM needs a startup script to set up the environment and start the container. Defining this here.
151  # NOTE: make sure to test changes by uncommenting the local_file resource at the bottom of this file
152  # and running `terraform apply` to see the generated script. You should also run shellcheck on the script
153  # to ensure it is valid.
154  startup_script = <<-META
155    #!/usr/bin/env sh
156    set -eux
157
158    # If user does not exist, create it and set up passwordless sudo
159    if ! id -u "${local.linux_user}" >/dev/null 2>&1; then
160      useradd -m -s /bin/bash "${local.linux_user}"
161      echo "${local.linux_user} ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/coder-user
162    fi
163
164    # Check for Docker, install if not present
165    if ! command -v docker >/dev/null 2>&1; then
166      echo "Docker not found, installing..."
167      curl -fsSL https://get.docker.com -o get-docker.sh && sudo sh get-docker.sh >/dev/null 2>&1
168      sudo usermod -aG docker ${local.linux_user}
169      newgrp docker
170    else
171      echo "Docker is already installed."
172    fi
173
174    # Write the Docker config JSON to disk if it is provided.
175    if [ -n "${local.docker_config_json_base64}" ]; then
176      mkdir -p "/home/${local.linux_user}/.docker"
177      printf "%s" "${local.docker_config_json_base64}" | base64 -d | tee "/home/${local.linux_user}/.docker/config.json"
178      chown -R ${local.linux_user}:${local.linux_user} "/home/${local.linux_user}/.docker"
179    fi
180
181    # Write the container env to disk.
182    printf "%s" "${local.docker_env_list_base64}" | base64 -d | tee "/home/${local.linux_user}/env.txt"
183
184    # Start envbuilder.
185    docker run \
186     --rm \
187     --net=host \
188     -h ${lower(data.coder_workspace.me.name)} \
189     -v /home/${local.linux_user}/envbuilder:/workspaces \
190     -v /var/run/docker.sock:/var/run/docker.sock \
191     --env-file /home/${local.linux_user}/env.txt \
192    ${local.builder_image}
193  META
194}
195
196# Create a persistent disk to store the workspace data.
197resource "google_compute_disk" "root" {
198  name  = "coder-${data.coder_workspace.me.id}-root"
199  type  = "pd-ssd"
200  image = "debian-cloud/debian-12"
201  lifecycle {
202    ignore_changes = all
203  }
204}
205
206# Check for the presence of a prebuilt image in the cache repo
207# that we can use instead.
208resource "envbuilder_cached_image" "cached" {
209  count         = var.cache_repo == "" ? 0 : data.coder_workspace.me.start_count
210  builder_image = local.devcontainer_builder_image
211  git_url       = data.coder_parameter.repo_url.value
212  cache_repo    = var.cache_repo
213  extra_env     = local.envbuilder_env
214}
215
216# This is useful for debugging the startup script. Left here for reference.
217# resource local_file "startup_script" {
218#   content  = local.startup_script
219#   filename = "${path.module}/startup_script.sh"
220# }
221
222# Create a VM where the workspace will run.
223resource "google_compute_instance" "vm" {
224  name         = "coder-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}-root"
225  machine_type = data.coder_parameter.instance_type.value
226  # data.coder_workspace_owner.me.name == "default"  is a workaround to suppress error in the terraform plan phase while creating a new workspace.
227  desired_status = (data.coder_workspace_owner.me.name == "default" || data.coder_workspace.me.start_count == 1) ? "RUNNING" : "TERMINATED"
228
229  network_interface {
230    network = "default"
231    access_config {
232      // Ephemeral public IP
233    }
234  }
235
236  boot_disk {
237    auto_delete = false
238    source      = google_compute_disk.root.name
239  }
240
241  service_account {
242    email  = data.google_compute_default_service_account.default.email
243    scopes = ["cloud-platform"]
244  }
245
246  metadata = {
247    # The startup script runs as root with no $HOME environment set up, so instead of directly
248    # running the agent init script, create a user (with a homedir, default shell and sudo
249    # permissions) and execute the init script as that user.
250    startup-script = local.startup_script
251  }
252}
253
254# Create a Coder agent to manage the workspace.
255resource "coder_agent" "dev" {
256  count              = data.coder_workspace.me.start_count
257  arch               = "amd64"
258  auth               = "token"
259  os                 = "linux"
260  dir                = "/workspaces/${trimsuffix(basename(data.coder_parameter.repo_url.value), ".git")}"
261  connection_timeout = 0
262
263  metadata {
264    key          = "cpu"
265    display_name = "CPU Usage"
266    interval     = 5
267    timeout      = 5
268    script       = "coder stat cpu"
269  }
270  metadata {
271    key          = "memory"
272    display_name = "Memory Usage"
273    interval     = 5
274    timeout      = 5
275    script       = "coder stat mem"
276  }
277  metadata {
278    key          = "disk"
279    display_name = "Disk Usage"
280    interval     = 5
281    timeout      = 5
282    script       = "coder stat disk"
283  }
284}
285
286# See https://registry.coder.com/modules/coder/code-server
287module "code-server" {
288  count  = data.coder_workspace.me.start_count
289  source = "registry.coder.com/coder/code-server/coder"
290
291  # This ensures that the latest non-breaking version of the module gets downloaded, you can also pin the module version to prevent breaking changes in production.
292  version = "~> 1.0"
293
294  agent_id = coder_agent.main.id
295  order    = 1
296}
297
298# See https://registry.coder.com/modules/coder/jetbrains-gateway
299module "jetbrains_gateway" {
300  count  = data.coder_workspace.me.start_count
301  source = "registry.coder.com/coder/jetbrains-gateway/coder"
302
303  # JetBrains IDEs to make available for the user to select
304  jetbrains_ides = ["IU", "PY", "WS", "PS", "RD", "CL", "GO", "RM"]
305  default        = "IU"
306
307  # Default folder to open when starting a JetBrains IDE
308  folder = "/workspaces"
309
310  # This ensures that the latest non-breaking version of the module gets downloaded, you can also pin the module version to prevent breaking changes in production.
311  version = "~> 1.0"
312
313  agent_id   = coder_agent.main.id
314  agent_name = "main"
315  order      = 2
316}
317
318# Create metadata for the workspace and home disk.
319resource "coder_metadata" "workspace_info" {
320  count       = data.coder_workspace.me.start_count
321  resource_id = google_compute_instance.vm.id
322
323  item {
324    key   = "type"
325    value = google_compute_instance.vm.machine_type
326  }
327
328  item {
329    key   = "zone"
330    value = module.gcp_region.value
331  }
332}
333
334resource "coder_metadata" "home_info" {
335  resource_id = google_compute_disk.root.id
336
337  item {
338    key   = "size"
339    value = "${google_compute_disk.root.size} GiB"
340  }
341}
342