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 012 hLab

Your first deploy, as code

Fix a service that cannot start on Cloud Run, build its image without a Dockerfile, and deploy it with Pulumi C#.

Sniplink runs fine on your laptop. dotnet run, curl localhost:5000, links get created, redirects work. You build an image, deploy it to Cloud Run, and the first revision never becomes ready. The log even tells you the app started: Now listening on: http://localhost:5000. So the app is listening. Just not where anyone is talking to it.

This module answers one question: what does a platform like Cloud Run expect from your container, and how do you meet that expectation in code and in infrastructure, without clicking anything in the console? By the end you will have Sniplink running on a public run.app URL, built from a Pulumi C# project in your repo, and verified by the course platform from the outside.

Cloud Run is the simplest serious way to run a container on Google Cloud. You hand it an image, it gives you an HTTPS URL. It starts instances when requests arrive, scales out when there are many, and scales to zero when there are none. You pay for the CPU and memory used while requests are being handled (in the default billing mode), plus a per-request fee, and the free tier covers far more than this course needs.

There are no VMs, nodes or load balancers for you to manage. The price for that convenience is a container contract: a short list of rules your container must follow so the platform can run it. For this module the important ones are:

  • Listen on 0.0.0.0 on the port in the PORT environment variable. Cloud Run sets PORT (8080 unless you configure another port on the service) and sends traffic there, on the container’s network interface.
  • Be stateless. Any instance can be killed or replaced at any time, and requests are spread across instances. Sniplink’s in-memory store breaks this on purpose; persistence is the topic of the next course, and in this one we keep a single small service where it does not matter for the labs.
  • Start fast. The instance must start listening within the startup timeout, or the revision is marked as failed. That is exactly the error in the incident.
  • Handle SIGTERM. When Cloud Run stops an instance it sends SIGTERM and waits a short grace period. ASP.NET Core does the basics for you; module 4 covers the details.

Cloud Run also sets a few variables you will meet again: K_SERVICE (service name), K_REVISION (revision name) and K_CONFIGURATION. Your code can read them; you should not set them yourself.

Look at the starter’s Program.cs with the contract in mind. Everything is fine until the last line.

src/Sniplink.Api/Program.cs (starter)
using System.Collections.Concurrent;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// In-memory store on purpose. Persistence is the next course.
var links = new ConcurrentDictionary<string, string>();
app.MapPost("/api/links", (CreateLink request) =>
{
// … validate the URL, generate a slug, store it
});
app.MapGet("/{slug}", (string slug) =>
links.TryGetValue(slug, out var url) ? Results.Redirect(url) : Results.NotFound());
// … GET /api/links/{slug}
// For local convenience
app.Run("http://localhost:5000");
record CreateLink(string Url);

app.Run("http://localhost:5000") does two things. It pins the port to 5000, and it binds to localhost, the loopback interface. Either one alone is enough to fail on Cloud Run.

Before fixing anything, make it fail on your machine. A bug you can reproduce in ten seconds is a bug you can fix with confidence; a bug you can only reproduce by deploying costs you a coffee break per attempt.

You do not need a Dockerfile yet. The .NET SDK can build a container image straight from your project with the PublishContainer target. Without a registry configured, it loads the image into your local Docker daemon:

Terminal window
# Build an image into the local Docker daemon
dotnet publish src/Sniplink.Api -c Release /t:PublishContainer \
-p:ContainerRepository=sniplink-api \
-p:ContainerImageTag=local
# Run it the way Cloud Run does: PORT set, traffic arriving on 8080
docker run --rm -e PORT=8080 -p 8080:8080 sniplink-api:local

The container logs will show something like this (the exact warning text varies between .NET versions, and some print none):

warn: Microsoft.AspNetCore.Server.Kestrel[0]
Overriding address(es) 'http://*:8080'. Binding to endpoints defined via the code instead.
info: Microsoft.Hosting.Lifetime[14]
Now listening on: http://localhost:5000

In a second terminal:

Terminal window
curl -i http://localhost:8080/api/links/anything

You get a connection reset or an empty reply, depending on your Docker setup. Nothing in the app logs, because no request ever reached Kestrel.

The first half of the explanation is the port. The base image already told ASP.NET Core to listen on 8080 via ASPNETCORE_HTTP_PORTS, and your hard-coded URL overrode it. More on that in a moment.

The other half is the word localhost. Inside a container, localhost means the container’s own loopback interface, not your laptop’s. When Docker forwards -p 8080:8080, the traffic arrives on the container’s network interface (eth0), not on loopback. A server bound to 127.0.0.1 simply never sees it. You can prove this is a separate problem from the port: run docker run --rm -e PORT=5000 -p 8080:5000 sniplink-api:local and the request still fails, even though the port now matches. Cloud Run works the same way. Its traffic comes in from outside the container, so the server has to listen on all interfaces, which is what 0.0.0.0 means.

The fix is to stop guessing and read the contract. Replace the planted line:

src/Sniplink.Api/Program.cs (excerpt)
// Cloud Run tells us which port to use. Locally, default to the same 8080.
var port = Environment.GetEnvironmentVariable("PORT") ?? "8080";
app.Run($"http://0.0.0.0:{port}");

Rebuild the image and run the same docker run command. The log now says Now listening on: http://0.0.0.0:8080 and curl returns a 404 for the unknown slug, which is the correct answer. If you run with -e PORT=5000 -p 8080:5000, it also works, because the code follows PORT instead of assuming it.

Here is the honest part. If you delete app.Run("http://localhost:5000") and write app.Run(), the app also works on Cloud Run. Since .NET 8, the official ASP.NET Core images set ASPNETCORE_HTTP_PORTS=8080, and Kestrel binds to all interfaces on that port when no URL is configured in code. Cloud Run’s default port is also 8080. Two defaults happen to agree.

I still read PORT explicitly, for three reasons:

  • The port is configurable on the service. The container port is part of the Cloud Run service definition. If you or a teammate set it to 9000 in infrastructure, Cloud Run sets PORT=9000 and sends traffic there. ASPNETCORE_HTTP_PORTS does not know about that change; your code reading PORT does.
  • PORT is what Cloud Run promises. It is part of the documented contract. ASPNETCORE_HTTP_PORTS is a property of a base image you will replace in module 2, and of whatever image someone builds after you.
  • It makes the dependency visible. A line that reads PORT tells the next reader “this app expects the platform to choose its port”. A missing line tells them nothing.

You could also do the same thing with builder.WebHost.UseUrls(...) or by mapping PORT into ASPNETCORE_URLS in configuration. Any of these is fine; what matters is one explicit source of truth.

Next, give the service a cheap endpoint that answers “is this process up and serving HTTP”. You write this one yourself, not a library, because deciding what “healthy” means is part of the skill. For now the answer is simple: if the request reached the handler, the process is alive.

src/Sniplink.Api/Program.cs (excerpt)
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));

Resist the urge to check dependencies here. A health endpoint that calls a database turns a slow database into “all instances unhealthy”. Module 4 splits this into /health/live and /health/ready and wires them into Cloud Run probes. Today, /health is what the lab checks first after the identity token.

Module 2 is about writing a proper Dockerfile, so why skip it now? Because a Dockerfile is a second thing to learn and debug, and this module is about the deploy path: registry, service, identity, URL. The SDK container support produces a reasonable image with zero files: it picks the mcr.microsoft.com/dotnet/aspnet base image matching your target framework, publishes your app into it, and runs it as the non-root app user. In module 2 you will open that image and see what it chose for you, including the parts you will want to change.

The image goes to Artifact Registry, Google’s registry for container images and packages. Images are addressed as REGION-docker.pkg.dev/PROJECT_ID/REPOSITORY/IMAGE:TAG. In this course that is europe-west1-docker.pkg.dev/PROJECT_ID/sniplink/api:TAG.

Set your variables once for this lesson:

Terminal window
export PROJECT_ID=your-course-project-id
export REGION=europe-west1

Docker and the .NET SDK need credentials to push to Artifact Registry. gcloud can act as a Docker credential helper for a registry host, so no passwords or keys end up in files:

Terminal window
# Adds a credHelpers entry for the regional host to ~/.docker/config.json
gcloud auth configure-docker ${REGION}-docker.pkg.dev

The SDK container tooling reads the same Docker config, so it uses gcloud credentials too. To push, you give PublishContainer three properties:

  • ContainerRegistry: the registry host, REGION-docker.pkg.dev.
  • ContainerRepository: the path under the host, PROJECT_ID/sniplink/api.
  • ContainerImageTag: the tag. I use the module number plus the short git commit SHA (m01-3f9c2ab), so every image points at the code that produced it and you can tell at a glance which module built it.
Terminal window
TAG=m01-$(git rev-parse --short HEAD)
dotnet publish src/Sniplink.Api -c Release /t:PublishContainer \
-p:ContainerRegistry=${REGION}-docker.pkg.dev \
-p:ContainerRepository=${PROJECT_ID}/sniplink/api \
-p:ContainerImageTag=${TAG} \
-p:ContainerRuntimeIdentifier=linux-x64 # Cloud Run runs x86-64; matters on Apple silicon

Do not run this yet. It will fail, because the sniplink repository does not exist. Creating it is the first job of your infrastructure code.

Everything from here on is a Pulumi C# program in infra/. You already logged in to your GCS state backend in module 0. The starter has no Pulumi project yet, so create one. pulumi new also creates the dev stack, encrypted with the KMS key pulumi/state that the bootstrap script made:

Terminal window
mkdir infra && cd infra
pulumi new csharp --name sniplink --stack dev \
--secrets-provider="gcpkms://projects/${PROJECT_ID}/locations/${REGION}/keyRings/pulumi/cryptoKeys/state"
dotnet add package Pulumi.Gcp

dev is the only stack Sniplink uses in this course: you deploy it from your laptop in modules 1 to 5, and Cloud Build deploys the same stack from module 6 on. In a team you would add a prod stack next to it; for one student and one service, a second stack would only double the clean-up.

Two small files describe the project and the stack. Pulumi.yaml names the project; the name also becomes the namespace for your own config keys, which is why the image setting is called sniplink:image.

infra/Pulumi.yaml
name: sniplink
runtime: dotnet
description: Sniplink on Google Cloud Run

Pulumi.dev.yaml holds the settings for the dev stack. The first two lines were written by pulumi new and point at the KMS key from module 0. The gcp: keys are read by the provider itself, so every resource lands in your project and region without repeating them.

infra/Pulumi.dev.yaml (excerpt)
secretsprovider: gcpkms://projects/PROJECT_ID/locations/europe-west1/keyRings/pulumi/cryptoKeys/state
# … encryptedkey: written by pulumi new, leave as is
config:
gcp:project: PROJECT_ID
gcp:region: europe-west1
# … sniplink:image is added after the first push, see below

Set the values with the CLI rather than by hand, so typos fail loudly:

Terminal window
pulumi config set gcp:project ${PROJECT_ID}
pulumi config set gcp:region ${REGION}

Now the three resources, one at a time.

A Docker-format repository in your region. The repository ID sniplink becomes part of every image path.

infra/Program.cs (excerpt)
var repo = new Gcp.ArtifactRegistry.Repository("sniplink", new()
{
RepositoryId = "sniplink",
Location = region,
Format = "DOCKER",
Description = "Sniplink container images",
});

A Cloud Run v2 service named sniplink-api running your image. A few values deserve a sentence each:

  • DeletionProtection = false: recent provider versions protect Cloud Run services from deletion by default. In a course project you want pulumi destroy to work; in production I would leave protection on.
  • Ingress = "INGRESS_TRAFFIC_ALL": accept traffic from the internet. It is the default; writing it down makes the choice reviewable.
  • ContainerPort = 8080: the port Cloud Run sends traffic to and puts into PORT. Change it here and your code follows, because it reads PORT.
  • No service account is set, so the service runs as the project’s default compute service account. That is a known problem, and fixing it is the whole of module 3.
infra/Program.cs (excerpt)
var service = new Gcp.CloudRunV2.Service("sniplink-api", new()
{
Name = "sniplink-api",
Location = region,
DeletionProtection = false,
Ingress = "INGRESS_TRAFFIC_ALL",
Template = new Gcp.CloudRunV2.Inputs.ServiceTemplateArgs
{
Containers =
{
new Gcp.CloudRunV2.Inputs.ServiceTemplateContainerArgs
{
Image = image,
Ports = new Gcp.CloudRunV2.Inputs.ServiceTemplateContainerPortsArgs
{
ContainerPort = 8080,
},
},
},
},
});

By default a Cloud Run service is authenticated: every request needs a Google identity token from a principal with roles/run.invoker, otherwise it gets a 403. That is the right default for internal services. Sniplink is a public URL shortener, and the course platform has to reach it without credentials, so you grant roles/run.invoker to allUsers, the special member meaning “anyone on the internet”.

Some organisations block this. If your project lives under a company organisation, the domain restricted sharing policy (iam.allowedPolicyMemberDomains) usually forbids allUsers bindings and pulumi up fails with a policy error. That is one more reason module 0 suggested a personal course-only project. Do not try to work around a company policy in a company project.

infra/Program.cs (excerpt)
new Gcp.CloudRunV2.ServiceIamMember("sniplink-api-public", new()
{
Name = service.Name,
Location = service.Location,
Role = "roles/run.invoker",
Member = "allUsers",
});

There is a chicken-and-egg problem hidden in these three resources. The repository must exist before you can push an image. The image must exist before the service can start, because Cloud Run pulls it when it creates the first revision. And the image is built outside Pulumi. So one pulumi up cannot do it all in the right order.

There are a few ways out:

  • pulumi up --target with the repository’s URN first, then a full pulumi up after the push. It works, but you have to look up URNs, and partial updates are a habit I would rather you did not form.
  • Build the image inside Pulumi with a Docker provider. That puts a slow, local build into every pulumi up and hides the build from the CI pipeline you write in module 6.
  • Make the service depend on config. Create the service only when sniplink:image is set. The first pulumi up creates the repository; you push; you set the image; the second pulumi up creates the service.

I use the third. Each step is a normal, full pulumi up, the program describes the real dependency (“no image, no service”) in code, and the same shape carries over to CI, where the pipeline pushes an image and then passes its reference to pulumi up as config. The Terraform version does the same with count.

Here is the complete program. It reads stack config, creates the repository, and creates the service plus public access only when an image is configured. It exports the image path to push to and, once the service exists, its URL. It also passes your student id to the container as LABKIT_STUDENT_ID; the LabKit section below explains why and where the value comes from.

infra/Program.cs
using System.Collections.Generic;
using Pulumi;
using Gcp = Pulumi.Gcp;
return await Deployment.RunAsync(() =>
{
var gcpConfig = new Config("gcp");
var project = gcpConfig.Require("project");
var region = gcpConfig.Require("region");
var config = new Config("sniplink");
var image = config.Get("image"); // null until the first image is pushed
var studentId = config.Require("studentId");
var repo = new Gcp.ArtifactRegistry.Repository("sniplink", new()
{
RepositoryId = "sniplink",
Location = region,
Format = "DOCKER",
Description = "Sniplink container images",
});
var outputs = new Dictionary<string, object?>
{
["imageRepository"] = Output.Format(
$"{region}-docker.pkg.dev/{project}/{repo.RepositoryId}/api"),
};
if (string.IsNullOrWhiteSpace(image))
{
Log.Info("sniplink:image is not set: created the repository only. Push an image, set it, run pulumi up again.");
return outputs;
}
var service = new Gcp.CloudRunV2.Service("sniplink-api", new()
{
Name = "sniplink-api",
Location = region,
DeletionProtection = false,
Ingress = "INGRESS_TRAFFIC_ALL",
Template = new Gcp.CloudRunV2.Inputs.ServiceTemplateArgs
{
Containers =
{
new Gcp.CloudRunV2.Inputs.ServiceTemplateContainerArgs
{
Image = image,
Ports = new Gcp.CloudRunV2.Inputs.ServiceTemplateContainerPortsArgs
{
ContainerPort = 8080,
},
Envs =
{
new Gcp.CloudRunV2.Inputs.ServiceTemplateContainerEnvArgs
{
Name = "LABKIT_STUDENT_ID",
Value = studentId,
},
},
},
},
},
}, new CustomResourceOptions { DependsOn = { repo } });
new Gcp.CloudRunV2.ServiceIamMember("sniplink-api-public", new()
{
Name = service.Name,
Location = service.Location,
Role = "roles/run.invoker",
Member = "allUsers",
});
outputs["url"] = service.Uri;
return outputs;
});

The Terraform tab is there so you can read the same design in the tool most job ads ask for. If you want to run it, keep it in its own folder, infra-tf/, next to infra/, with a terraform.tfvars that sets project, region and student_id; the Terraform tabs in later modules extend the files in that folder. Backend configuration is left out; the course runs on the Pulumi version.

The course platform verifies your lab by calling your service URL. To be sure the URL is a real Cloud Run service in your project, and not a copy of someone else’s responses, it needs proof that only Google can produce. That plumbing is the same for every student and is not what this module teaches, so it lives in a small open-source package, CuriousDev.LabKit. The rule from module 0 applies: LabKit automates plumbing, never the skill. Your /health endpoint, your port binding and your infrastructure are yours.

Terminal window
dotnet add src/Sniplink.Api package CuriousDev.LabKit --version "1.*"

1.* takes the newest 1.x release and never jumps to a new major version that could change the contract.

Here is the complete Program.cs after this module:

src/Sniplink.Api/Program.cs
using System.Collections.Concurrent;
using CuriousDev.LabKit;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddLabKit(); // reads its env vars, registers evidence providers
var app = builder.Build();
// In-memory store on purpose. Persistence is the next course.
var links = new ConcurrentDictionary<string, string>();
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
// … POST /api/links, GET /{slug}, GET /api/links/{slug} from the starter, unchanged
app.MapLabKit(); // maps /_lab/* endpoints
// Cloud Run tells us which port to use. Locally, default to the same 8080.
var port = Environment.GetEnvironmentVariable("PORT") ?? "8080";
app.Run($"http://0.0.0.0:{port}");
record CreateLink(string Url);

MapLabKit adds a few endpoints under /_lab/. In this module only one matters: POST /_lab/verify. The platform sends a body with a lab ID and a random nonce. LabKit then asks the metadata server, an internal endpoint available only inside Google Cloud compute environments, for a Google-signed ID token whose audience carries your student id (STUDENT_ID below; the next section shows where it comes from):

GET http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity
?audience=https://learn.vladtimchenko.dev/s/STUDENT_ID&format=full
Metadata-Flavor: Google

It returns the token together with the nonce it was given, K_SERVICE, K_REVISION, your student id as studentId, its own version as labkitVersion and an evidence object (empty in this module, filled in later ones). The audience is https://learn.vladtimchenko.dev/s/ followed by the value of LABKIT_STUDENT_ID, so the student id sits inside the part Google signs. If the variable is empty, LabKit falls back to plain https://learn.vladtimchenko.dev, and the platform rejects that token with a hint to set it.

The endpoint only works on Cloud Run. Run the app on your laptop and POST /_lab/verify answers 503 with the Problem Details code not_on_cloud_run, because there is no metadata server to ask. That is expected, not a bug.

The token proves that a Cloud Run service in some project answered. It does not prove that the project is yours: a run.app URL is public, and anyone can paste someone else’s URL into a lab page. So the platform needs one more fact, one that only the owner of the project can put there. That fact is your student id, set as the environment variable LABKIT_STUDENT_ID on the service. Anyone can read a URL; only someone who can deploy to your project can change its environment variables.

Your student id is cd_ followed by 10 characters. Sign in and you find it in the lab widget at the bottom of this page, and on your dashboard at learn.vladtimchenko.dev/en/me/. It is not a secret: it only links a deployment to your account, and your own service returns it to anyone who calls /_lab/verify. So it goes into plain stack config, committed with the rest of Pulumi.dev.yaml. Replace STUDENT_ID with your id:

Terminal window
cd infra
pulumi config set sniplink:studentId STUDENT_ID
cd ..

The program above reads it with config.Require("studentId"), so a stack without it fails at preview time instead of deploying a service that cannot pass a lab, and passes it to the container. In Terraform it is the variable student_id from terraform.tfvars.

infra/Program.cs (excerpt)
Envs =
{
new Gcp.CloudRunV2.Inputs.ServiceTemplateContainerEnvArgs
{
Name = "LABKIT_STUDENT_ID",
Value = studentId,
},
},

The platform checks the id twice. The studentId in the response must equal yours, and the token’s audience must end with your id. The second part matters because /_lab/verify is public: without it, someone could forward your service’s fresh token from their own service, next to their own id. With your id inside the signed audience, your service can only mint tokens that count for you. Only after both match does the platform bind the project to your account.

An ID token is a JWT: a header, a JSON payload and a signature made with Google’s private key. The payload for your service looks roughly like this:

{
"iss": "https://accounts.google.com",
"aud": "https://learn.vladtimchenko.dev/s/STUDENT_ID",
"azp": "112233445566778899000",
"sub": "112233445566778899000",
"email": "123456789012-compute@developer.gserviceaccount.com",
"email_verified": true,
"iat": 1773052800,
"exp": 1773056400
}
  • iss and the signature: Google issued it. The platform checks the signature against Google’s public certificates.
  • aud: who the token is for (the course platform, and within it your student id). Only the platform should accept it, and only for you.
  • email and sub: the service account the code runs as. Right now that is the default compute account, whose email starts with your project number, so the project is identified without you typing anything.
  • iat and exp: when it was issued and when it expires, one hour later. The platform rejects tokens older than five minutes.

With format=full some environments add extra claims about the instance. LabKit passes them through, but the platform does not depend on them.

You cannot produce this token on your laptop. Without a service account key (and this course never creates one), the only way to get a Google-signed token for your project’s service account is to be code running as that account on Google’s infrastructure, or a principal that holds the specific IAM permission to mint tokens for it. Combine that with the rest of the check: the platform fetched the response itself, live, from a *.run.app URL, within the last few seconds, with a nonce it had just generated. That chain says “a Cloud Run service in this project answered just now”.

To be precise about the limits: the token proves which service account and project answered, and the URL proves it was Cloud Run. It does not prove which code is in the image. That is why later labs add checks of behaviour, not just identity. Once your student id matches, the platform also binds the project to your account on the first successful check, so a project cannot count for two students: a project already bound to another account fails the token check.

The token contains a service account email, a numeric ID and timestamps. None of that is secret; the project number is already visible in your public URL. The token cannot be used to call Google APIs, which want OAuth access tokens, not ID tokens. Its audience is the course platform plus your student id, so any other service that checks aud correctly rejects it, the platform accepts it only for you, and it expires within an hour. LabKit never returns environment variables, configuration or secrets, from any endpoint. That rule holds for the whole course.

Now put it together. From the repository root:

  1. Create the repository. With no image configured, Pulumi creates only Artifact Registry.

    Terminal window
    cd infra
    pulumi up
    cd ..
  2. Build and push the image with the fixed Program.cs and LabKit in it.

    Terminal window
    TAG=m01-$(git rev-parse --short HEAD)
    dotnet publish src/Sniplink.Api -c Release /t:PublishContainer \
    -p:ContainerRegistry=${REGION}-docker.pkg.dev \
    -p:ContainerRepository=${PROJECT_ID}/sniplink/api \
    -p:ContainerImageTag=${TAG} \
    -p:ContainerRuntimeIdentifier=linux-x64
  3. Point the stack at the image and deploy the service.

    Terminal window
    cd infra
    pulumi config set sniplink:image ${REGION}-docker.pkg.dev/${PROJECT_ID}/sniplink/api:${TAG}
    pulumi up
  4. Try it.

    Terminal window
    URL=$(pulumi stack output url)
    curl -i ${URL}/health
    curl -i -X POST ${URL}/api/links -H "Content-Type: application/json" -d '{"url":"https://example.com"}'
    curl -i ${URL}/SLUG_FROM_PREVIOUS_RESPONSE

For every later change, the loop is steps 2 and 3: new commit, new tag, pulumi config set, pulumi up. Commit Pulumi.dev.yaml along with the code; the image tag in it is now part of your history.

When a revision fails, the answer is almost always in the logs. Cloud Run sends everything your app writes to stdout and stderr to Cloud Logging, together with platform messages like the one in the incident.

From the terminal:

Terminal window
gcloud run services logs read sniplink-api --region ${REGION} --limit 50

In the console, open Logging > Logs Explorer and use this query. Reading the console is fine; the rule is that you never change anything there.

resource.type="cloud_run_revision"
resource.labels.service_name="sniplink-api"

Try it once on purpose: put the planted line back on a branch, build with a new tag, pulumi up, and read the log. You will see Now listening on: http://localhost:5000 followed by the startup failure from the incident. Pulumi reports the failed revision, and the previous healthy revision keeps serving traffic, because Cloud Run only moves traffic to a revision that started. Then revert and deploy again.

Goal: Sniplink runs on Cloud Run in your course project, deployed from your Pulumi program, and answers the platform from the outside.

  1. Program.cs reads PORT and binds to 0.0.0.0; the planted line is gone.
  2. GET /health returns 200, written by you.
  3. LabKit is added with AddLabKit and MapLabKit.
  4. The service has LABKIT_STUDENT_ID set to your student id, from the stack config sniplink:studentId.
  5. The image is built with dotnet publish /t:PublishContainer and stored in the sniplink Artifact Registry repository.
  6. The repository, the sniplink-api service and the public invoker binding are created by pulumi up, not in the console.
  7. Paste the url stack output below.

The first check may take up to about 20 seconds. The service scales to zero when nobody calls it, so the platform’s first request usually starts a new instance (a cold start). The platform waits for it; you do not need to warm the service up or press Verify twice.

Check your lab

Loading your lab…

Sign in to verify this lab and save your progress. Everything above works without an account.

  • Your Cloud Run service URL (pulumi stack output url)

5 checks run against the service you deployed.

What the checks do:

  • token: calls POST /_lab/verify with a fresh nonce, then checks the ID token’s signature, the audience (which must end with your student id), its age, the echoed nonce and the returned studentId. Only when the student id matches is your project bound to your account; a project already bound to another account fails here.
  • health: GET /health must return 200.
  • create-link and redirect: the platform creates a link with POST /api/links, then requests GET / plus the returned slug without following redirects, and expects a 302 to exactly the URL it sent. This is your app’s behaviour, checked end to end.
  • service-account: the token contains a service account email. Module 3 will tighten this check to reject the default account.

None of the checks in this lab are self-reported: each one is either signed by Google or observed by the platform over HTTP. The lab does not check that your infrastructure is in Pulumi; that is on your honour, and the Verified tier will check it later.

Keep the stack. Module 2 builds a better image for the same repository and deploys it to the same service, so there is nothing to tear down. Cloud Run scales to zero when idle, and a few small images in Artifact Registry cost a few cents per month at most.

If you are pausing the course for a while, destroy everything and recreate it later with the same two-step deploy:

Terminal window
cd infra
pulumi destroy

This deletes the service, the public binding and the repository with all its images. The state bucket and KMS key from module 0 stay.

  • Cloud Run’s container contract: listen on 0.0.0.0:$PORT, be stateless, start fast, handle SIGTERM, and why localhost inside a container is a trap.
  • Why reading PORT explicitly beats relying on ASPNETCORE_HTTP_PORTS, even though both default to 8080.
  • How to build and push an image without a Dockerfile using dotnet publish /t:PublishContainer and gcloud as a credential helper.
  • How to describe an Artifact Registry repository, a Cloud Run v2 service and public access in Pulumi C#, and how to order a deploy when the image is built outside Pulumi.
  • What a Google ID token contains, why it proves where your code runs without exposing anything sensitive, and why your student id in LABKIT_STUDENT_ID proves the project is yours.