Skip to content
serhiichuk.dev
Back to Blog

Cloud Workstations: building reusable development environments in cloud — Part 2

8 min read
Google CloudCloud WorkstationsPulumiDevOpsDockerDeveloper Experience
Cloud Workstations: building reusable development environments in cloud — Part 2 cover

How to customize your Cloud Workstations environment and improve developer productivity.

In the first part we reviewed the pros and cons of local vs cloud development environments and checked how to create a minimal yet fully functional setup of the Google Cloud Workstations using Pulumi.

Cloud Workstations: building reusable development environments in cloudSetting up Cloud Workstations using Pulumi, TypeScript and Bun

Now it’s time to make that development environment yours and see how one can customize base images and ensure developers have all the tools they need.

Defining a customized base image

GCP provides a set of base images available out of the box that provide a set of pre-configured IDEs — named Code OSS and IDEs from the JetBrains toolbox (IDEA, PyCharm, WebStorm, etc.).

Preconfigured IDEs | Cloud Workstations | Google Cloud DocumentationPreconfigured IDEs for use with Cloud Workstations

You can check out the complete list of the IDEs and their respective docker images here.

Code OSS provides a ready-to-use web UI while JetBrains IDEs expose a remote development environment gateway to which you can connect from your local copy of the IDE. Both approaches have their pros and cons but why don’t we just combine them together?

To create our customized base image for the workstation instance we will use Code OSS and JetBrains WebStorm base images together to provide an easy-to-start IDE with terminal and file explorer in your web browser with Code OSS and also expose JetBrains gateway to connect your local IDE.

Available extension points

The core part of the workstations base images setup is distinction between system and user space. While workstations work with a containerized environment, your IDE is actually running inside a container with a volume mounted in it to e.g. your /home directory. It means that when you install some tooling or perform container configuration, you should account for that as well.

Depending on the tooling you are going to install and whether you want to maintain the ability to update it and configure it from within your user space, you may want to move some tooling installation scripts from the Docker image setup to user space scripts.

On startup, base images run files under /etc/workstation-startup.d/* in lexicographical order to initialize the workstation environment. There is a special script in that folder called 030_customize_environment.sh that executes /home/user/.workstation/customize_environment as user.

You can unwrap the complete entrypoint setup by examining /google/scripts/entrypoint.sh in the base images.

Base image Dockerfile

The absolutely minimal customized setup with Code OSS and WebStorm looks like this:

FROM us-central1-docker.pkg.dev/cloud-workstations-images/predefined/code-oss:latest AS code-oss-image

FROM us-central1-docker.pkg.dev/cloud-workstations-images/predefined/webstorm:latest AS runtime

COPY --from=code-oss-image /opt/code-oss /opt/code-oss
COPY --from=code-oss-image /etc/workstation-startup.d/110_start-code-oss.sh /etc/workstation-startup.d/110_start-code-oss.sh

Now you can sprinkle it with some standard tooling, e.g. add mysql and redis clients, maybe gh and lefthook CLIs — these are the ones that we just want to bundle into the image.

RUN apt update \
    && DEBIAN_FRONTEND=noninteractive apt install apt-utils software-properties-common unzip -fyq \
    && apt upgrade -fyq \
    # Install MySQL client \
    && DEBIAN_FRONTEND=noninteractive apt install mariadb-client -yq \
    # Install Redis Client \
    && curl -fsSL https://packages.redis.io/gpg | gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg \
    && chmod 644 /usr/share/keyrings/redis-archive-keyring.gpg \
    && echo "deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main" | tee /etc/apt/sources.list.d/redis.list \
    && apt update \
    && DEBIAN_FRONTEND=noninteractive apt install redis -yq \
    # Install GitHub CLI \
    && mkdir -p -m 755 /etc/apt/keyrings \
    && out=$(mktemp) && wget -nv -O$out https://cli.github.com/packages/githubcli-archive-keyring.gpg \
    && cat $out | tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null \
    && chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \
    && mkdir -p -m 755 /etc/apt/sources.list.d \
    && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
    && apt update \
    && DEBIAN_FRONTEND=noninteractive apt install gh -yq \
    # Install lefthook \
    && curl -1sLf 'https://dl.cloudsmith.io/public/evilmartians/lefthook/setup.deb.sh' | sudo -E bash \
    && DEBIAN_FRONTEND=noninteractive apt install lefthook -yq

The next step is to add some pre-configured IDE extensions. This is probably specific to your team’s needs. JetBrains plugins can be installed as follows:

# Install .env - https://plugins.jetbrains.com/plugin/9525--env-files
RUN bash /installer-scripts/plugin-installer.sh \
    -d /opt/WebStorm/plugins/ \
    9525 \
    # Install .ignore - https://plugins.jetbrains.com/plugin/7495--ignore
    && bash /installer-scripts/plugin-installer.sh \
    -d /opt/WebStorm/plugins/ \
    7495 \
    # Install google cloud code - https://plugins.jetbrains.com/plugin/8079-google-cloud-code
    && bash /installer-scripts/plugin-installer.sh \
    -d /opt/WebStorm/plugins/ \
    8079 \
    # Install Gemini Code Assist - https://plugins.jetbrains.com/plugin/24198-gemini-code-assist
    && bash /installer-scripts/plugin-installer.sh \
    -d /opt/WebStorm/plugins/ \
    24198 \
    # Install JetBrains AI Assistant - https://plugins.jetbrains.com/plugin/22282-jetbrains-ai-assistant
    && bash /installer-scripts/plugin-installer.sh \
    -d /opt/WebStorm/plugins/ \
    22282 \
    # Install JetBrains Junie - https://plugins.jetbrains.com/plugin/26104-jetbrains-junie
    && bash /installer-scripts/plugin-installer.sh \
    -d /opt/WebStorm/plugins/ \
    26104

The logic is simple here: you open up the plugin page (e.g. https://plugins.jetbrains.com/plugin/26104-jetbrains-junie), copy the ID that goes after /plugin, and install it with the plugin-installer.sh script.

For Code OSS we can either download extensions manually during the build phase or prepare a user-space script and use /opt/code-oss/bin/codeoss-cloudworkstations --install-extension. Let’s prepare a user-space script 120_install_vs_code_extensions.sh:

#!/usr/bin/env bash

sudo -u user /opt/code-oss/bin/codeoss-cloudworkstations \
  --install-extension ms-azuretools.vscode-containers \
  --install-extension pulumi.pulumi-vscode-tools  \
  --install-extension GoogleCloudTools.cloudcode \
  --install-extension Google.geminicodeassist \
  --install-extension cweijan.vscode-database-client2 \
  --install-extension cweijan.dbclient-jdbc \
  --install-extension RooVeterinaryInc.roo-cline \
  --install-extension redhat.vscode-yaml \
  --install-extension dbaeumer.vscode-eslint \
  --install-extension orta.vscode-jest \
  --install-extension gamunu.vscode-yarn

And then copy the script with:

COPY 120_install_vs_code_extensions.sh /etc/workstation-startup.d/
RUN chmod +x /etc/workstation-startup.d/120_install_vs_code_extensions.sh

customize_environment.sh

Another alternative solution is to create a customize_environment script in the user’s home folder. Let’s add pulumi and nvm to the setup. Those tools are usually installed into the user’s home.

Let’s ensure the home directory will be available first and create 011_customize_user.sh:

#!/usr/bin/env bash

sudo mkdir -p /home/user/.workstation/
sudo mkdir -p /home/user/.local/bin/
sudo cp /tmp/customize_environment.sh /home/user/.workstation/customize_environment

sudo chown -R user /home/user/
sudo chmod +x /home/user/.workstation/customize_environment

Now let’s create customize_environment.sh where we install Pulumi, NVM, some LTS node and Gemini CLI.

#!/usr/bin/env bash

set -x;
export DEBIAN_FRONTEND=noninteractive
export PULUMI_VERSION="3.206.0"
export NVM_VERSION="0.40.3"

curl -fsSL https://get.pulumi.com | bash -s -- --version "${PULUMI_VERSION}"

# This is required for some tools that source from local bin but ignore bashrc
sudo ln -s ~/.pulumi/bin/pulumi ~/.local/bin

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
export NVM_DIR="$([ -z "${XDG_CONFIG_HOME-}" ] && printf %s "${HOME}/.nvm" || printf %s "${XDG_CONFIG_HOME}/nvm")"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # This loads nvm

nvm install --lts
npm install -g corepack @google/gemini-cli

Now we need to add those two scripts to the base image with:

COPY 011_customize_user.sh /etc/workstation-startup.d/
COPY customize_environment.sh /tmp/customize_environment.sh
RUN chmod +x /etc/workstation-startup.d/011_customize_user.sh \
    && chmod +x /tmp/customize_environment.sh \

We’re all set. We have explored three ways to customize a Cloud Workstation base image:

  • by extending the base image and installing tooling into the image itself
  • by providing workstation startup scripts
  • by creating a customize_environment script in the user’s home folder

You can find a complete example of the WebStorm + Code OSS base image in the xSAVIKx/gcp-cloud-workstations-howto repository.

gcp-cloud-workstations-howto/customized/base_images/webstorm at mainExample infrastructure setup for GCP Cloud Workstations using Pulumi

Letting Pulumi do the job

Now that we’re all set from the base image perspective, we need to fine-tune our Pulumi infrastructure setup.

You can jump to a complete setup and check it out here.

Artifact Registry setup

We will be using Google Artifact Registry to store our customized image as this simplifies authentication for the Workstations, keeps the image physically closer to the virtual machine, as well as provides fine-grained control over the private images.

First, we need to enable Artifact Registry and legacy Container Registry services using the enableServices method we created before or just by extending the requiredServices array with:

"artifactregistry.googleapis.com",
"container.googleapis.com",

Now here’s how you can define a Docker registry using Artifact Registry service:

function defineArtifactRegistry() {
  const artifactRegistry = new gcp.artifactregistry.Repository(
    "dockerRegistry",
    {
      location: region,
      repositoryId: "containers",
      description: "Private containers registry",
      format: "DOCKER",
      dockerConfig: {
        // usually better set to `true`, but for the lab we're setting it to false
        // to ease re-creation of the same containers.
        immutableTags: false,
      },
    },
    { provider: gcpProvider, dependsOn: services },
  );
  return { artifactRegistry };
}

Finally, we’ll create a helper method to grab an authentication token to the registry as it’s private and secure by default:

async function accessToken() {
  const auth = new GoogleAuth({
    scopes: ["https://www.googleapis.com/auth/cloud-platform"],
  });
  return (await auth.getAccessToken()) || undefined;
}

Creating the Docker image

In order to run docker build from Pulumi, we’ll need to add Pulumi Docker provider and define Image resource.

First, adding a provider with bun add '@pulumi/docker' and now defining an image as follows.

const webstormImage = new docker.Image("webstormImage", {
  build: {
    context: `${__dirname}/base_images/webstorm`,
    dockerfile: `${__dirname}/base_images/webstorm/Dockerfile`,
    platform: "linux/amd64",
  },
  imageName: pulumi.interpolate`${artifactRegistry.registryUri}/webstorm:latest`,
  registry: {
    server: artifactRegistry.registryUri,
    username: "oauth2accesstoken",
    password: await accessToken(),
  },
  skipPush: false,
});

We’re using Dockerfile we created before and Artifact Registry as a destination for our image. And also authenticating our code to access the registry using a short-term OAuth2 access token.

Preparing the Workstation configuration

With the image ready, you can add container.image property to the WorkstationConfig to override the base image. We will also configure the GCE host with a specified machine type and home disk. And to finalize the setup we’ll configure automatic VM idle and run time — this will allow us to save some costs when the machine is not actively used.

const wsCustomizedConfig = new gcp.workstations.WorkstationConfig(
  "wsCustomizedConfig",
  {
    workstationConfigId: "customized-config",
    workstationClusterId: wsCluster.workstationClusterId,
    location: region,
    container: {
      image: webstormImage.repoDigest,
    },
    idleTimeout: "3600s",
    runningTimeout: "43200s",
    host: { gceInstance: { machineType: "e2-standard-4" } },
    persistentDirectories: [
      {
        mountPath: "/home",
        gcePd: {
          diskType: "pd-standard",
          sizeGb: 200,
          reclaimPolicy: "DELETE",
        },
      },
    ],
  },
  { provider: gcpProvider, dependsOn: services },
);

Summary

We are all set. Running pulumi up and in a bit of time we have our own Code OSS + WebStorm setup with a fully customized base image that your team can reuse easily without having to think twice about which version of the tool they had and if there’s anything else they missed.

We have reviewed three options for customizing Cloud Workstations base images. We prepared a custom base image with Code OSS and WebStorm support and updated our Pulumi setup to automatically build Docker image and update our workstations configuration.

In the next part we will cover the networking and security part of the setup.

Cleanup

If you want to delete the resources Pulumi created, just run pulumi down and it will take care of the cleanup.

Useful resources