---
title: "Cloud Workstations: building reusable development environments in cloud — Part 2"
description: "How to customize your Cloud Workstations environment and improve developer productivity: custom base images with Code OSS and WebStorm, built by Pulumi."
canonical_url: "https://serhiichuk.dev/blog/cloud-workstations-building-reusable-development-environments-in-cloud-part-2/"
published: "2025-11-17T00:00:00.000Z"
tags:
  - "Google Cloud"
  - "Cloud Workstations"
  - "Pulumi"
  - "DevOps"
  - "Docker"
  - "Developer Experience"
---

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 cloud](/blog/cloud-workstations-building-reusable-development-environments-in-cloud/)** —
*Setting 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 Documentation](https://docs.cloud.google.com/workstations/docs/preconfigured-ides)** —
*Preconfigured IDEs for use with Cloud Workstations*

You can check out the complete list of the IDEs and their respective docker images
[here](https://docs.cloud.google.com/workstations/docs/preconfigured-base-images).

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](https://docs.cloud.google.com/workstations/docs/develop-code-using-local-jetbrains-ides).

### 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:

```dockerfile
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.

```dockerfile
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:

```dockerfile
# 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](https://docs.cloud.google.com/workstations/docs/customize-container-images#container-image-that-pre-installs-ide-extensions-in-code-oss-for-cloud-workstations-for-java-development)
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`:

```bash
#!/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:

```dockerfile
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`:

```bash
#!/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.

```bash
#!/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:

```dockerfile
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 main](https://github.com/xSAVIKx/gcp-cloud-workstations-howto/tree/main/customized/base_images/webstorm)** —
*Example 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](https://github.com/xSAVIKx/gcp-cloud-workstations-howto/blob/main/customized/index.ts).

### Artifact Registry setup

We will be using [Google Artifact Registry](https://docs.cloud.google.com/artifact-registry/docs) 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:

```typescript
"artifactregistry.googleapis.com",
"container.googleapis.com",
```

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

```typescript
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:

```typescript
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.

```typescript
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.

```typescript
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

- [GCP Cloud Workstations How To repository](https://github.com/xSAVIKx/gcp-cloud-workstations-howto/tree/main/customized)
- [Cloud Workstations home page](https://cloud.google.com/workstations)
- [Pulumi GCP provider Workstations page](https://www.pulumi.com/registry/packages/gcp/api-docs/workstations/workstation/)
- [Pulumi Docker provider](https://www.pulumi.com/registry/packages/docker/)
- [Artifact Registry](https://docs.cloud.google.com/artifact-registry/docs/overview)
