Google Compute Engine (Devcontainer)
Provision a Devcontainer on Google Compute Engine instances as Coder workspaces
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:
-
Navigate to the CGP console, and select your Cloud project (if you have more than one project associated with your account)
-
Provide a service account name (this name is used to generate the service account ID)
-
Click Create and continue, and choose the following IAM roles to grant to the service account:
- Compute Admin
- Service Account User
Click Continue.
-
Click on the created key, and navigate to the Keys tab.
-
Click Add key > Create new key.
-
Generate a JSON private key, which will be what you provide to Coder during the setup process.
Architecture
This template provisions the following resources:
- Envbuilder cached image (conditional, persistent) using
terraform-provider-envbuilder
- GCP VM (persistent) with a running Docker daemon
- GCP Disk (persistent, mounted to root)
- Envbuilder container inside the GCP VM
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