Skip to content

GCP Hello World

A complete, runnable example: a Debian VM on GCP that serves Hello World! on port 8000, verified by an HttpTest.

Source: examples/gcp-hello/


What it creates

GCP resource Name Purpose
google_compute_instance hello-world e2-micro Debian VM, us-central1-a
google_compute_firewall allow-http-8000 Opens TCP 8000 from anywhere

The VM's startup script installs a tiny Python HTTP server as a systemd service:

from http.server import HTTPServer, BaseHTTPRequestHandler

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        self.send_response(200)
        self.send_header("Content-Type", "text/plain")
        self.end_headers()
        self.wfile.write(b"Hello World!\n")

HTTPServer(("", 8000), Handler).serve_forever()

File layout

examples/gcp-hello/
├── config.py                ← ProjectConfig (stack definition)
├── infra/
│   ├── main.tf              ← Terraform: VM, firewall, outputs
│   └── terraform.tfvars.j2  ← Jinja2 template for .tfvars
└── pyproject.toml           ← path dependency on parent small-config

config.py

The example uses with Project(...) and puts the smoke test on TerraformComponent(..., tests=[...]) with a SelfOutputRef so the check stays next to the infra stack (see Tests on components).

from small_config import HttpTest, Project, SelfOutputRef, TerraformComponent, active_env, sc

default_env = "dev"

project_id = "omega-buckeye-451005-b6"
ssh_pub_key = sc.secret("ssh_pub_key")

with Project("gcp-hello", active_env()) as PROJECT:
    TerraformComponent(
        name="infra",
        vars={
            "project_id": project_id,
            "ssh_pub_key": ssh_pub_key,
        },
        tests=[
            HttpTest(
                name="hello http",
                url=SelfOutputRef("ip", transform=lambda ip: f"http://{ip}:8000"),
                expect_status=200,
                expect_body_contains="Hello World",
            ),
        ],
    )

Equivalent options: project-level HttpTest in the Project block, with TerraformComponent(...) as infra: infra.tests = [...], or HttpTest(..., component=infra, url=infra.output("ip", ...)).

infra/terraform.tfvars.j2

This Jinja2 template is rendered to .dist/infra/terraform.tfvars before Terraform runs. The {{ ... }} variables come from vars= in TerraformComponent:

project_id  = "{{ project_id }}"
ssh_pub_key = "{{ ssh_pub_key }}"

The ssh_pub_key value comes from SC_SECRET_SSH_PUB_KEY — it's resolved at deploy time and never stored in plain text in config.py.

infra/main.tf

Key parts:

variable "project_id" {}
variable "ssh_pub_key" {}

resource "google_compute_instance" "hello_world" {
  name         = "hello-world"
  machine_type = "e2-micro"
  ...
  metadata = {
    ssh-keys       = "debian:${var.ssh_pub_key}"
    startup-script = local.startup_script
  }
}

output "ip" {
  value = google_compute_instance.hello_world.network_interface[0].access_config[0].nat_ip
}

The ip output is what infra.output("ip") reads back into HttpTest.


Running the example

Prerequisites

  • Python 3.14+ and uv
  • terraform CLI installed
  • gcloud authenticated: gcloud auth application-default login
  • Access to a GCP project

Setup

cd examples/gcp-hello
uv sync
export SC_SECRET_SSH_PUB_KEY="$(cat ~/.ssh/id_ed25519.pub)"

Edit project_id in config.py to match your GCP project.

Dry run (no infrastructure created)

uv run sc plan --dry-run

This renders infra/terraform.tfvars.j2 → .dist/infra/terraform.tfvars and shows the Terraform commands that would run.

Deploy + test

uv run sc build

Expected output:

[sc] component 'infra' (single)
[sc] run: terraform init
[sc] run: terraform apply -auto-approve
...
Apply complete! Resources: 3 added, 0 changed, 0 destroyed.
Outputs:
  ip = "34.123.1.164"
...
          Tests
┏━━━━━━━━━━━━┳━━━━━━━━┓
┃ Name       ┃ Result ┃
┡━━━━━━━━━━━━╇━━━━━━━━┩
│ hello http │ PASS   │
└────────────┴────────┘

The VM takes ~25 seconds to create; the HTTP server may need another 10–20 seconds to start. HttpTest retries up to 6 times (5-second delay each) to handle this.

Read the IP later

uv run sc output infra ip
# 34.123.1.164

Tear down

uv run sc destroy

Verify in GCP:

gcloud compute instances list --project=<your-project> --filter="name=hello-world"
# Listed 0 items.

Extending the example

Add a second environment:

def project(self, env: str) -> Project:
    project_id = "my-prod-project" if env == "production" else "my-dev-project"
    ...
uv run sc build --env production

State goes to sc.state.production.json, separate from sc.state.dev.json.

Add an Ansible configuration step:

from small_config import AnsibleComponent

configure = AnsibleComponent(
    name="configure",
    path="./ansible",  # required here: directory name differs from name="configure"
    vars={"host": infra.output("ip")},
    depends_on=[infra],
)

return Project(..., components=[infra, configure], ...)

If your playbook directory were named configure/, you could omit path (defaults to ./configure).

Add another smoke test:

HttpTest(
    name="metrics endpoint",
    url=infra.output("ip", transform=lambda ip: f"http://{ip}:9090/metrics"),
    expect_status=200,
)