A serverless reset button: browser-only OAuth to the GCP Compute API
Our infrastructure status page is a static site on GitLab Pages: a table of
VMs generated from Terraform state, no backend, no server-side code. It was
useful for looking, but every time a box wedged, the loop was: open the page,
copy the instance name, switch to a terminal, gcloud compute instances reset ….
I wanted a Reset button right there in the table.
The constraint: static hosting means there is nowhere to run code that holds a credential. So the credential has to come from the person clicking, in their browser, at click time. That turns out to be entirely doable with Google Identity Services and the Compute API — and the interesting part is doing it safely.
The idea
When you click, the browser runs the Google OAuth flow, gets a short-lived
access token for the signed-in user, and calls the Compute API’s
instances.reset
directly with fetch. No token is ever stored; it lives only in that closure,
for that one call.
// Initialise the Google Identity Services token client once.
const tokenClient = google.accounts.oauth2.initTokenClient({
client_id: config.clientId,
scope: "https://www.googleapis.com/auth/cloud-platform",
callback: "", // set per-request below
});
async function resetInstance({ project, zone, instance }, button) {
button.disabled = true;
// 1. Get an access token for the signed-in user (opens the consent popup).
const token = await new Promise((resolve, reject) => {
tokenClient.callback = (resp) => (resp.error ? reject(resp) : resolve(resp));
tokenClient.requestAccessToken({ prompt: "consent" });
});
// 2. Call the Compute API directly from the browser.
const url = `https://compute.googleapis.com/compute/v1/projects/${project}` +
`/zones/${zone}/instances/${instance}/reset`;
const resp = await fetch(url, {
method: "POST",
headers: { Authorization: `Bearer ${token.access_token}` },
});
if (!resp.ok) throw new Error(`API ${resp.status}: ${(await resp.json()).error.message}`);
alert(`Reset command sent to ${instance}.`);
button.disabled = false;
}
That’s the whole mechanism. The static page loads the GIS script, initialises the
client with your OAuth client ID, and renders one button per row wired to the
row’s {project, zone, instance}.
The part that makes it safe: IAM, not the token scope
The access token above is broad — cloud-platform. On its own that’s alarming.
What actually constrains it is IAM on the Google Cloud side: the token can
only do what the signed-in identity is allowed to do. So you grant a tightly
scoped custom role to exactly the people who should be able to reset boxes, and
nobody else’s token will work — the API returns 403.
resource "google_project_iam_custom_role" "instance_rebooter" {
role_id = "instanceRebooterRole"
title = "Instance Reboot and View"
permissions = [
"compute.instances.reset",
"compute.instances.get",
"compute.instances.list",
]
}
resource "google_project_iam_member" "rebooter_group" {
project = var.project_id
role = google_project_iam_custom_role.instance_rebooter.id
member = "group:rebooters@example.com" # a Google Group, not individuals
}
Three permissions, bound to a Google Group. Reset, get, list — nothing else. Adding or removing someone is a group-membership change, not a Terraform apply.
The other half is the OAuth client configuration in the Cloud console: the authorized JavaScript origin must be your Pages domain, so a token for this client can only be minted from a page served there.
Why this is neat — and why I did not ship it in the reusable template
This is genuinely satisfying: a real, authenticated, least-privilege cloud mutation from a static page with no backend, no secrets in the repo, and no long-lived credentials anywhere. The token is per-click and short-lived; the blast radius is exactly three permissions for a named group.
But when I later factored the CI and Pages tooling into reusable templates for other projects, I deliberately left this out of the shared template. A few reasons:
- It needs a per-deployment OAuth client (client ID, authorized origins) configured out-of-band — not something a template can carry.
- It’s a live mutation surface on a public-ish page. Safe because of IAM, but “a button that reboots production if you’re in the group” is a thing you want each team to opt into consciously, not inherit silently.
- The console already does this, with an audit trail and its own auth. A bespoke button is worth it for a bespoke status page, not as a default everyone gets.
So it stayed a one-off on the status page it was built for, and the reusable template just links each row to the GCP console instead. That felt like the right line: ship the convenience where it was designed and understood, keep it out of the thing other people copy blindly.
The lesson I kept: “static site” doesn’t mean “read-only.” With browser OAuth and server-side IAM, a static page can safely drive real infrastructure — as long as the authorization lives in the cloud, not in the page.