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.
Accounts
Section titled “Accounts”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 course-only project
Section titled “Create a course-only project”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:
export PROJECT_ID="sniplink-course-4821" # your own, globally uniqueexport 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:
dotnet --versiondocker version --format '{{.Server.Version}}'gcloud versionpulumi versiongit --versionIf 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:
# Credentials for gcloud commands themselvesgcloud auth login
# Application Default Credentials: what Pulumi, the KMS secrets provider# and the Google client libraries usegcloud auth application-default loginThe 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:
gcloud projects create "$PROJECT_ID" --name="Sniplink course"
# Lists billing accounts you can use; copy the ACCOUNT_ID columngcloud billing accounts list
export BILLING_ACCOUNT_ID="XXXXXX-XXXXXX-XXXXXX"Create your repository
Section titled “Create your repository”The starter is the starter/ folder of the course repository. Download it as a zip and unpack it into a new folder:
curl -fLO https://learn.vladtimchenko.dev/downloads/containerize-deploy/sniplink-starter.zipunzip sniplink-starter.zip -d sniplinkcd sniplinkOr copy the folder straight from GitHub with degit, which fetches the files without the course repository’s history (it needs Node.js):
npx degit curious-dev-learn/containerize-deploy-dotnet-on-gcp/starter sniplinkcd sniplinkEither 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:
git init -b maingit add .git commit -m "initial commit"gh repo create sniplink --public --source . --pushWithout 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.
The bootstrap script
Section titled “The bootstrap script”Pulumi can create almost anything in your project, but it needs three things to exist before its first run:
- A state backend: somewhere to record which real resources belong to your program. We use a Cloud Storage bucket,
gs://PROJECT_ID-pulumi-state. - 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. - 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.
#!/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 1figcloud 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-preventionfi# 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}"figcloud 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.0fi
# 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}"
EOFWhat each step does and why:
- Project check and
gcloud config set project. Fails fast if the project ID is wrong, and makes every latergcloudcommand 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 forgcloud 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
pulumiand keystate. 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 explicitcryptoKeyEncrypterDecrypterbinding 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-projecttellsgcloudwhich project to bill the Budgets API call to; with user credentials the call fails without a quota project. - Next steps. Prints the exact
pulumi logincommand 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:
export BUDGET_AMOUNT="5EUR"Run it:
chmod +x scripts/bootstrap.sh./scripts/bootstrap.shThen 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.
Log Pulumi in to your bucket
Section titled “Log Pulumi in to your bucket”Point the Pulumi CLI at your bucket instead of Pulumi Cloud:
pulumi login "gs://${PROJECT_ID}-pulumi-state"pulumi whoami -vpulumi 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/stateIn module 1 you create the Pulumi project and its dev stack with it (pulumi new runs pulumi stack init for you):
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.
Run Sniplink locally
Section titled “Run Sniplink locally”Back in your repository, run the tests and the API:
dotnet testdotnet run --project src/Sniplink.ApiThe 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:
# Create a link; note the slug in the responsecurl -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 headercurl -i http://localhost:5000/k3x9q2
# Metadata for the same slugcurl -s http://localhost:5000/api/links/k3x9q2
# Unknown slug: expect 404curl -i http://localhost:5000/does-not-existStop 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.
What this costs
Section titled “What this costs”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-west1holds a few kilobytes and costs effectively nothing. - What could cost real money. Instances that never scale to zero (
MinInstanceCountabove 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:
# Inside infra/, from module 1 on: removes every resource the stack ownspulumi destroy
# The nuclear option: removes the whole project and everything in itgcloud 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.
Before module 1 you should be able to
Section titled “Before module 1 you should be able to”- Run
dotnet --version,docker version,gcloud versionandpulumi versionwithout errors, with .NET at 10.0.x. - Name your course-only project ID, and
gcloud config get-value projectprints it. - Run
./scripts/bootstrap.shtwice 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 -vand see yourgs://PROJECT_ID-pulumi-statebackend. - Write down the
gcpkms://secrets provider URL for your project and region. - Create a link with
curlagainst the local Sniplink and get a302when you follow it. - Explain in one sentence why the bootstrap script is an acceptable exception to zero ClickOps.