Skip to main content
As stacks grow, you start to notice the same expressions appearing in multiple places — a naming prefix built from environment and app name, a common tag map applied to every resource, a version string derived from an input. Every time that expression changes, you have to find and update every occurrence. This is the classic DRY problem, and local blocks are ubx’s answer to it. A local computes a value once, gives it a name, and makes it available anywhere in the stack as local.name. Unlike inputs — which come from outside the stack — locals are computed from values that already exist: other inputs, other locals, or any expression the compiler can resolve at compile time. They’re not runtime abstractions; they’re a compile-time convenience that disappears entirely in the generated code, replaced by the values they computed.

What you’ll learn

  • How local blocks compute and name reusable values
  • How to reference locals throughout your stack
  • When to use a local instead of repeating an expression

Why this matters

Locals are especially powerful for shared tag maps. Define your standard tags once in a local block and reference local.tags in every unit block — changing the tag schema in one place updates every resource in the stack.

The source code

unit "aws_s3_bucket_v2" "assets" {
  bucket = "${local.prefix}-assets"
  tags   = local.tags
}

unit "aws_s3_bucket_v2" "logs" {
  bucket = "${local.prefix}-logs"
  tags   = local.tags
}

How it works

1

Locals are evaluated in the IR pass

During the AST → IRProgram pass, local blocks are resolved into IRLocal nodes. Their values are expressions — strings, objects, references to inputs — all Resolved<T> since they don’t depend on resource outputs.
2

References are inlined at compile time

When local.prefix appears in a unit block attribute, the compiler substitutes the local’s expression inline. No runtime overhead — locals are a compile-time convenience, not a runtime abstraction.
3

Generated code uses computed values directly

The generated TypeScript/Python/Go contains the resolved values, not the local names. Locals exist only in .iac source — they have no representation in the compiled output.

What ubx generates

// Generated by ubx — do not edit
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";

const config = new pulumi.Config();
const env = config.get("env") || "dev";
const app = config.get("app") || "myapp";

const prefix = `${app}-${env}`;
const tags = { env, app, managed_by: "ubx" };

const assets = new aws.s3.BucketV2("assets", {
    bucket: `${prefix}-assets`,
    tags,
});

const logs = new aws.s3.BucketV2("logs", {
    bucket: `${prefix}-logs`,
    tags,
});

Common mistakes

Locals cannot reference resource outputs — local.endpoint = unit.rds.db.endpoint is a type error. Locals are always Resolved<T>. If you need a computed value that depends on a resource output, reference it directly inline in the block that needs it — Pending<T> propagates automatically.
There’s no limit to how many locals you can define, and locals can reference other locals. Build up complex values incrementally — a local.env_prefix derived from inputs, a local.tags that includes local.env_prefix — without nesting everything into a single hard-to-read expression.

Run it

ubx validate
ubx plan   # requires AWS credentials

What you learned

local blocks compute named values that can be reused across the stack
Locals are Resolved<T> — they cannot reference resource outputs
Separating locals into locals.iac keeps your computed values easy to find and update

Next steps

Multiple resources with deps

Reference one resource from another

local block reference

Full local block syntax