Skip to content

Signed in as

Sign in to Curious Dev Learn

Sign in to verify labs, save your progress and get a certificate. Lessons stay open without an account.

Module 0045 min

Set up your workspace

Create a course-only Google Cloud project, install the tools, run the bootstrap script and start Sniplink locally.

This lesson gets you from nothing to a Google Cloud project that Pulumi can manage, plus Sniplink running on your laptop. It is the only lesson where you configure infrastructure with gcloud directly, and by the end you will see why that is still not ClickOps: it is one script, in git, that you can read and run twice.

You need two accounts.

Google Cloud. Sign up at cloud.google.com with a personal Google account. New accounts get a free trial with credit (at the time of writing 300 USD for 90 days) on top of the always-free tier. Let me be direct about the part people dislike: the trial asks for a payment card. Google uses it to verify identity and says it will not charge you during the trial unless you upgrade to a paid account yourself. When the trial ends, resources stop until you upgrade.

The trial covers everything in this course. If you follow the clean-up steps, the whole course should cost cents, and most of it fits in the free tier. The section “What this costs” below is specific.

GitHub. Any free account. You will create your own repository from the course starter, and in module 6 Cloud Build will build from it. Your repository will be public for the module 6 lab, because the platform checks a commit in it.

Create a new Google Cloud project used only for this course. Not your side project, not the one from a tutorial two years ago. A fresh one.

  • Isolation. Every IAM binding and every resource in it exists because of this course. When something is wrong, there is nothing else to suspect.
  • Clean-up is one command. When you are done, deleting the project removes everything in it, including the things you forgot.
  • Verified tier later. The optional Verified tier will ask you to grant the platform read-only access to your project. That is only comfortable when the project contains nothing but course work.

Project IDs are globally unique, lowercase, 6-30 characters. Something like sniplink-course-4821 works. I use this pair of export lines for the rest of the lesson; set them once in your shell:

Terminal window
export PROJECT_ID="sniplink-course-4821" # your own, globally unique
export REGION="europe-west1"

You create the project in the tools section below, right after installing gcloud.

Install these. Versions matter less than you might fear, but use .NET 10 because the course targets net10.0.

Tool Why Check
.NET 10 SDK build, test and run Sniplink dotnet --version (10.0.x)
Docker Desktop or Docker Engine build and run containers locally (module 2 on; useful from module 1) docker version
Google Cloud CLI (gcloud) bootstrap, logs, auth for Docker and Pulumi gcloud version
Pulumi CLI infrastructure as code pulumi version
Git your repository git --version
An editor Rider, Visual Studio or VS Code with C# Dev Kit

The GitHub CLI (gh) is optional and saves a few steps below.

Run the checks together:

Terminal window
dotnet --version
docker version --format '{{.Server.Version}}'
gcloud version
pulumi version
git --version

If docker version prints a client version but fails on the server part, the Docker daemon is not running. Start Docker Desktop, or sudo systemctl start docker on Linux.

Now log gcloud in. There are two separate logins and you need both:

Terminal window
# Credentials for gcloud commands themselves
gcloud auth login
# Application Default Credentials: what Pulumi, the KMS secrets provider
# and the Google client libraries use
gcloud auth application-default login

The first one is for you typing gcloud .... The second one writes a credentials file that programs pick up. Pulumi does not use your gcloud login; it uses Application Default Credentials. Forgetting the second command is the most common reason pulumi login gs://... fails with a permission error.

Create the project and find your billing account ID:

Terminal window
gcloud projects create "$PROJECT_ID" --name="Sniplink course"
# Lists billing accounts you can use; copy the ACCOUNT_ID column
gcloud billing accounts list
export BILLING_ACCOUNT_ID="XXXXXX-XXXXXX-XXXXXX"

The starter is the starter/ folder of the course repository. Download it as a zip and unpack it into a new folder:

Terminal window
curl -fLO https://learn.vladtimchenko.dev/downloads/containerize-deploy/sniplink-starter.zip
unzip sniplink-starter.zip -d sniplink
cd sniplink

Or copy the folder straight from GitHub with degit, which fetches the files without the course repository’s history (it needs Node.js):

Terminal window
npx degit curious-dev-learn/containerize-deploy-dotnet-on-gcp/starter sniplink
cd sniplink

Either way you get plain files, not a clone. Turn them into your own repository with a first commit, then publish it on GitHub. With gh:

Terminal window
git init -b main
git add .
git commit -m "initial commit"
gh repo create sniplink --public --source . --push

Without gh, create an empty public repository named sniplink on github.com (no README or license, so the first push does not conflict), then run git remote add origin https://github.com/YOUR_USER/sniplink.git and git push -u origin main. That click is on GitHub, not on your cloud infrastructure, so it does not count against the rule.

The repository must be public. In module 6 the platform asks GitHub’s public API whether the commit your service runs exists in it, and it cannot see a private repository. The branch is main because the module 6 trigger builds pushes to main.

The starter contains the API, a test project, an empty place for infra/ (you fill it in module 1) and scripts/bootstrap.sh.

Pulumi can create almost anything in your project, but it needs three things to exist before its first run:

  1. A state backend: somewhere to record which real resources belong to your program. We use a Cloud Storage bucket, gs://PROJECT_ID-pulumi-state.
  2. A secrets provider: a key to encrypt secret config values (you store one in module 3). We use a Cloud KMS key, pulumi/state, so no passphrase lives on your laptop or in CI.
  3. A project with billing linked and the APIs enabled, because Pulumi calls those APIs.

On top of that I add a budget with alerts, because a course where you create cloud resources should warn you before it surprises you.

Here is the complete script. Read it before you run it; it is short on purpose.

scripts/bootstrap.sh
#!/usr/bin/env bash
# scripts/bootstrap.sh
#
# One-time bootstrap for the "Containerize & Deploy" course project.
# This is the only infrastructure in the course that is not managed by Pulumi:
# it creates what Pulumi itself needs (state bucket, KMS key) plus billing
# guard rails. Safe to run more than once: every step checks before it creates.
#
# Usage:
# export PROJECT_ID="your-course-project-id"
# export BILLING_ACCOUNT_ID="XXXXXX-XXXXXX-XXXXXX"
# export REGION="europe-west1" # optional, default europe-west1
# export BUDGET_AMOUNT="5USD" # optional, must use your billing account's currency
# ./scripts/bootstrap.sh
set -euo pipefail
: "${PROJECT_ID:?Set PROJECT_ID to your course project ID}"
: "${BILLING_ACCOUNT_ID:?Set BILLING_ACCOUNT_ID (see: gcloud billing accounts list)}"
REGION="${REGION:-europe-west1}"
BUDGET_AMOUNT="${BUDGET_AMOUNT:-5USD}"
STATE_BUCKET="gs://${PROJECT_ID}-pulumi-state"
KMS_KEYRING="pulumi"
KMS_KEY="state"
BUDGET_NAME="${PROJECT_ID}-course-budget"
APIS=(
run.googleapis.com
artifactregistry.googleapis.com
secretmanager.googleapis.com
cloudbuild.googleapis.com
cloudkms.googleapis.com
iam.googleapis.com
iamcredentials.googleapis.com
cloudresourcemanager.googleapis.com
storage.googleapis.com
cloudbilling.googleapis.com
billingbudgets.googleapis.com # required by 'gcloud billing budgets create'
)
log() { printf '\n==> %s\n' "$*"; }
# 1. Project ------------------------------------------------------------------
log "Using project ${PROJECT_ID}"
if ! gcloud projects describe "${PROJECT_ID}" --format='value(projectId)' >/dev/null 2>&1; then
echo "Project ${PROJECT_ID} does not exist or you have no access to it." >&2
echo "Create it first: gcloud projects create ${PROJECT_ID}" >&2
exit 1
fi
gcloud config set project "${PROJECT_ID}" --quiet
# 2. Billing ------------------------------------------------------------------
log "Linking billing account ${BILLING_ACCOUNT_ID}"
CURRENT_BILLING="$(gcloud billing projects describe "${PROJECT_ID}" \
--format='value(billingAccountName)')"
if [[ "${CURRENT_BILLING}" == "billingAccounts/${BILLING_ACCOUNT_ID}" ]]; then
echo "Already linked."
else
gcloud billing projects link "${PROJECT_ID}" \
--billing-account="${BILLING_ACCOUNT_ID}"
fi
# 3. APIs (enabling an already enabled API is a no-op) ------------------------
log "Enabling APIs"
gcloud services enable "${APIS[@]}" --project="${PROJECT_ID}"
# 4. Pulumi state bucket ------------------------------------------------------
log "State bucket ${STATE_BUCKET}"
if gcloud storage buckets describe "${STATE_BUCKET}" >/dev/null 2>&1; then
echo "Bucket exists."
else
gcloud storage buckets create "${STATE_BUCKET}" \
--project="${PROJECT_ID}" \
--location="${REGION}" \
--uniform-bucket-level-access \
--public-access-prevention
fi
# Versioning lets you recover an older state file if a run corrupts it.
gcloud storage buckets update "${STATE_BUCKET}" --versioning
# 5. KMS key ring and key for Pulumi secrets ----------------------------------
log "KMS key ring '${KMS_KEYRING}' and key '${KMS_KEY}' in ${REGION}"
if gcloud kms keyrings describe "${KMS_KEYRING}" \
--location="${REGION}" --project="${PROJECT_ID}" >/dev/null 2>&1; then
echo "Key ring exists."
else
gcloud kms keyrings create "${KMS_KEYRING}" \
--location="${REGION}" --project="${PROJECT_ID}"
fi
if gcloud kms keys describe "${KMS_KEY}" \
--keyring="${KMS_KEYRING}" --location="${REGION}" \
--project="${PROJECT_ID}" >/dev/null 2>&1; then
echo "Key exists."
else
gcloud kms keys create "${KMS_KEY}" \
--keyring="${KMS_KEYRING}" \
--location="${REGION}" \
--purpose=encryption \
--project="${PROJECT_ID}"
fi
# Grant yourself encrypt/decrypt on this one key, explicitly, so Pulumi
# can use it with your Application Default Credentials. Adding an existing
# binding is a no-op.
ACCOUNT="$(gcloud config get-value account 2>/dev/null)"
if [[ "${ACCOUNT}" == *.gserviceaccount.com ]]; then
MEMBER="serviceAccount:${ACCOUNT}"
else
MEMBER="user:${ACCOUNT}"
fi
gcloud kms keys add-iam-policy-binding "${KMS_KEY}" \
--keyring="${KMS_KEYRING}" \
--location="${REGION}" \
--project="${PROJECT_ID}" \
--member="${MEMBER}" \
--role="roles/cloudkms.cryptoKeyEncrypterDecrypter" \
--format=none
# 6. Budget with alerts at 50 %, 90 %, 100 % ---------------------------------
log "Budget '${BUDGET_NAME}' (${BUDGET_AMOUNT})"
EXISTING_BUDGET="$(gcloud billing budgets list \
--billing-account="${BILLING_ACCOUNT_ID}" \
--billing-project="${PROJECT_ID}" \
--filter="displayName=${BUDGET_NAME}" \
--format='value(name)')"
if [[ -n "${EXISTING_BUDGET}" ]]; then
echo "Budget exists: ${EXISTING_BUDGET}"
else
gcloud billing budgets create \
--billing-account="${BILLING_ACCOUNT_ID}" \
--billing-project="${PROJECT_ID}" \
--display-name="${BUDGET_NAME}" \
--budget-amount="${BUDGET_AMOUNT}" \
--filter-projects="projects/${PROJECT_ID}" \
--threshold-rule=percent=0.5 \
--threshold-rule=percent=0.9 \
--threshold-rule=percent=1.0
fi
# 7. Next steps ---------------------------------------------------------------
SECRETS_PROVIDER="gcpkms://projects/${PROJECT_ID}/locations/${REGION}/keyRings/${KMS_KEYRING}/cryptoKeys/${KMS_KEY}"
log "Bootstrap complete"
cat <<EOF
Log Pulumi in to your state bucket:
pulumi login ${STATE_BUCKET}
Secrets provider for every stack you create in this course:
${SECRETS_PROVIDER}
Example (module 1):
pulumi stack init dev --secrets-provider="${SECRETS_PROVIDER}"
EOF

What each step does and why:

  • Project check and gcloud config set project. Fails fast if the project ID is wrong, and makes every later gcloud command target the course project.
  • Billing link. Most APIs refuse to enable without a billing account. The script links only if the project is not already linked to that account.
  • APIs. Cloud Run, Artifact Registry, Secret Manager, Cloud Build, Cloud KMS, IAM, IAM Credentials (used for keyless auth and ID tokens), Cloud Resource Manager (Pulumi reads project metadata through it), plus Cloud Storage, Cloud Billing and billingbudgets.googleapis.com. The last one is required for gcloud billing budgets create; without it the budget step fails with a “service disabled” error. Enabling an API that is already enabled does nothing, so this step is naturally idempotent.
  • State bucket. Created in your region with uniform bucket-level access, so permissions come only from IAM and never from per-object ACLs, and public access prevention, so nobody can make your state public by accident. Versioning is on because the state file is the one thing you really do not want to lose: if a crashed run leaves it broken, you restore the previous version.
  • KMS key ring pulumi and key state. Same region as the bucket. Key rings and keys cannot be deleted in Cloud KMS (only key versions can be destroyed), which is why the script checks before creating. The explicit cryptoKeyEncrypterDecrypter binding on this one key makes sure your own account can encrypt and decrypt with it, whatever basic role you hold.
  • Budget. A budget of BUDGET_AMOUNT (default 5 USD) scoped to this project only, with alert thresholds at 50 %, 90 % and 100 %. --billing-project tells gcloud which project to bill the Budgets API call to; with user credentials the call fails without a quota project.
  • Next steps. Prints the exact pulumi login command and the secrets provider URL you will use in module 1.

If your billing account is in euros, set the amount in that currency. The currency must match the billing account or the command fails:

Terminal window
export BUDGET_AMOUNT="5EUR"

Run it:

Terminal window
chmod +x scripts/bootstrap.sh
./scripts/bootstrap.sh

Then run it a second time. Every step should report that the thing already exists, and nothing should fail. That is the property that makes this script acceptable as the course’s one CLI exception: it is reviewable in git, reproducible on a new project, and safe to re-run.

Point the Pulumi CLI at your bucket instead of Pulumi Cloud:

Terminal window
pulumi login "gs://${PROJECT_ID}-pulumi-state"
pulumi whoami -v

pulumi whoami -v should show the gs:// backend URL. From now on every stack you create stores its state in that bucket. The login is per machine; in module 6 Cloud Build does the same pulumi login with its own identity.

Keep the secrets provider URL the script printed. It has this shape:

gcpkms://projects/PROJECT_ID/locations/REGION/keyRings/pulumi/cryptoKeys/state

In module 1 you create the Pulumi project and its dev stack with it (pulumi new runs pulumi stack init for you):

Terminal window
pulumi new csharp --name sniplink --stack dev \
--secrets-provider="gcpkms://projects/${PROJECT_ID}/locations/${REGION}/keyRings/pulumi/cryptoKeys/state"

Do not run that yet; module 1 does it inside a new infra/ folder. The dev stack is the only stack Sniplink uses in this course, from your laptop in modules 1 to 5 and from Cloud Build in module 6. If you create a stack without --secrets-provider, Pulumi falls back to a passphrase and asks for PULUMI_CONFIG_PASSPHRASE on every run. That works, but it is one more secret to keep and hand to CI, which is exactly what we avoid.

Back in your repository, run the tests and the API:

Terminal window
dotnet test
dotnet run --project src/Sniplink.Api

The console log shows where the app listens. In the starter that is http://localhost:5000. Keep that detail in mind; it matters in module 1.

In a second terminal, create a short link, follow it and read its metadata:

Terminal window
# Create a link; note the slug in the response
curl -i -X POST http://localhost:5000/api/links \
-H "Content-Type: application/json" \
-d '{ "url": "https://learn.microsoft.com/dotnet/" }'
# Replace k3x9q2 with your slug; expect 302 and a Location header
curl -i http://localhost:5000/k3x9q2
# Metadata for the same slug
curl -s http://localhost:5000/api/links/k3x9q2
# Unknown slug: expect 404
curl -i http://localhost:5000/does-not-exist

Stop the app with Ctrl+C and start it again: your links are gone. Sniplink stores links in memory on purpose. Persistence is a topic of its own and belongs to a later course; here it keeps the service stateless, which is what Cloud Run wants anyway.

Prices change, so treat the numbers as orders of magnitude and check the pricing pages linked below. As of writing:

  • Free tier covers most of it. Cloud Run has a monthly free allowance of requests, vCPU-seconds and memory that a course service never exceeds while scaled to zero. Secret Manager includes a few free active secret versions and access operations per month. Cloud Build includes a monthly allowance of free build minutes.
  • Costs cents. The KMS key version is billed per active version per month (around 0.06 USD), plus a tiny amount per 10,000 operations. Artifact Registry storage above the free 0.5 GB is billed per GB-month, and .NET images add up across modules, so delete old images. The state bucket in europe-west1 holds a few kilobytes and costs effectively nothing.
  • What could cost real money. Instances that never scale to zero (MinInstanceCount above 0, or CPU always allocated), a load test left running against an uncapped service, or resources you created by hand and forgot. The course rules address all three.

How to stop everything:

Terminal window
# Inside infra/, from module 1 on: removes every resource the stack owns
pulumi destroy
# The nuclear option: removes the whole project and everything in it
gcloud projects delete "$PROJECT_ID"

pulumi destroy is the normal clean-up after a lab, and each module says when to run it. Deleting the project is for the end of the course or for when you are not sure what is running. A deleted project can be restored for about 30 days, after which it is gone for good. Billing stops when you delete it, including for the KMS key version.

  • Run dotnet --version, docker version, gcloud version and pulumi version without errors, with .NET at 10.0.x.
  • Name your course-only project ID, and gcloud config get-value project prints it.
  • Run ./scripts/bootstrap.sh twice in a row, with the second run creating nothing new.
  • See your budget with three thresholds: gcloud billing budgets list --billing-account="$BILLING_ACCOUNT_ID".
  • Run pulumi whoami -v and see your gs://PROJECT_ID-pulumi-state backend.
  • Write down the gcpkms:// secrets provider URL for your project and region.
  • Create a link with curl against the local Sniplink and get a 302 when you follow it.
  • Explain in one sentence why the bootstrap script is an acceptable exception to zero ClickOps.