Updated Aug 11, 2026

07 - Infrastructure as Code

📋 Jump to Takeaways

🎁 What if you could describe your entire production environment in a Go file, and a single command would make reality match it exactly?

The next subcommand being added to devctl is devctl infra provision <stack> <region>. When a developer opens a PR, this command provisions a fresh environment automatically, no one opens the AWS console.

Your team manages three environments: dev, staging, and prod. One afternoon an engineer opens the AWS console to debug a connectivity issue. They add a security group rule "just for testing." They mean to remove it. They forget.

Three weeks later, staging has different security group rules than prod. Nobody knows why. Traffic behaves differently in each environment. Two incidents last quarter traced back to config that existed in one environment but not another, a missing IAM policy, a different subnet assignment. The investigation took longer than the fix.

This is infrastructure drift. It's the default outcome of managing cloud resources by hand. The console makes it easy to change things and hard to track what changed.

Infrastructure as code solves this by making the codebase the source of truth. If it's not in the repo, it doesn't exist. Changes go through a PR. History is in git. Drift is detected automatically.

Imports in code snippets are trimmed for brevity. See the full example linked at the end for complete, compilable source.

Why Not Just Use the AWS SDK Directly?

The AWS SDK is fine for reading resources or responding to events. For provisioning, you end up writing the same logic every IaC tool already implements.

Here's what a hand-rolled imperative provisioner looks like. First, create an EC2 client from your AWS config:

import (
    "github.com/aws/aws-sdk-go-v2/config"
    "github.com/aws/aws-sdk-go-v2/service/ec2"
)

func newEC2Client(ctx context.Context, region string) (*ec2.Client, error) {
    cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(region))
    if err != nil {
        return nil, fmt.Errorf("load AWS config: %w", err)
    }
    return ec2.NewFromConfig(cfg), nil
}

config.LoadDefaultConfig reads credentials from the standard chain: environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY), ~/.aws/credentials, or the instance metadata service when running on EC2 or EKS.

Then the provisioner itself:

func ensureSecurityGroup(ctx context.Context, svc *ec2.Client, name, vpcID string) (string, error) {
    // Check if it already exists
    resp, err := svc.DescribeSecurityGroups(ctx, &ec2.DescribeSecurityGroupsInput{
        Filters: []types.Filter{
            {Name: aws.String("group-name"), Values: []string{name}},
            {Name: aws.String("vpc-id"), Values: []string{vpcID}},
        },
    })
    if err != nil {
        return "", err
    }

    if len(resp.SecurityGroups) > 0 {
        // Already exists — check if rules match and update if needed.
        // rulesMatch and desiredRules are placeholders for your actual rule comparison logic.
        existing := resp.SecurityGroups[0]
        if !rulesMatch(existing.IpPermissions, desiredRules) {
            _, err = svc.AuthorizeSecurityGroupIngress(ctx, ...)
            // ...
        }
        return *existing.GroupId, nil
    }

    // Doesn't exist — create it
    created, err := svc.CreateSecurityGroup(ctx, &ec2.CreateSecurityGroupInput{
        GroupName: aws.String(name),
        VpcId:     aws.String(vpcID),
    })
    return *created.GroupId, err
}

You write this pattern for every resource: VPCs, subnets, IAM roles, RDS instances. Each one needs check-if-exists, create-if-not, update-if-different, delete-if-removed. That's what Terraform and Pulumi already do, with dependency ordering, state tracking, plan preview, and rollback on top. Don't reinvent it.

Declarative vs Imperative

Infrastructure automation falls into two paradigms. Imperative code describes how to reach a state. Declarative code describes what the desired state is.

The imperative approach from above is explicit about order and steps. The declarative approach with Pulumi looks like this.

Pulumi's Go SDK lets you describe infrastructure as plain Go functions. Install it with:

go get github.com/pulumi/pulumi/sdk/v3/go/pulumi
go get github.com/pulumi/pulumi-aws/sdk/v6/go/aws/ec2
// Declarative: describe desired state, engine handles execution order
func infrastructure(ctx *pulumi.Context) error {
    vpc, err := ec2.NewVpc(ctx, "main", &ec2.VpcArgs{
        CidrBlock: pulumi.String("10.0.0.0/16"),
    })
    if err != nil {
        return err
    }
    _, err = ec2.NewSubnet(ctx, "web", &ec2.SubnetArgs{
        VpcId:     vpc.ID(),
        CidrBlock: pulumi.String("10.0.1.0/24"),
    })
    return err
}

Pulumi sees that the subnet references vpc.ID(), infers the dependency, and creates the VPC first. You don't manage order. You don't write check-if-exists logic. You describe the end state and run pulumi up.

State Management and Drift Detection

Every IaC tool maintains a state file: a snapshot of what it believes exists in your cloud account. When you run pulumi up, the engine reads your code (desired state), reads the state file (last-known state), and queries the cloud (actual state). Drift is the gap between state file and actual state.

Drift detection compares each resource's desired configuration (from your code) against its actual configuration (queried from the cloud). For every attribute, if the actual value differs from the declared value, the resource is flagged as drifted. Pulumi implements this with pulumi refresh, which queries real cloud resources and updates the state file to match. Terraform calls the same operation terraform refresh. Both tools run this automatically before planning so the diff you see reflects reality, not a stale snapshot. If someone added that security group rule in the console, refresh catches it.

State backends (S3, GCS, Pulumi Cloud) provide locking so concurrent operations don't corrupt state. Local state files cause collaboration problems. Always use remote state in production.

Pulumi Automation API

Terraform uses HCL. Pulumi uses general-purpose programming languages including Go. The Automation API lets you drive Pulumi programmatically from Go code, no CLI subprocess needed. You create stacks, set config, and run operations as library calls.

import (
    "github.com/pulumi/pulumi-aws/sdk/v6/go/aws/s3"
    "github.com/pulumi/pulumi/sdk/v3/go/auto"
    "github.com/pulumi/pulumi/sdk/v3/go/auto/optup"
    "github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)

func deployFunc(ctx *pulumi.Context) error {
    bucket, err := s3.NewBucket(ctx, "my-bucket", &s3.BucketArgs{
        Website: &s3.BucketWebsiteArgs{
            IndexDocument: pulumi.String("index.html"),
        },
    })
    if err != nil {
        return err
    }
    ctx.Export("bucketName", bucket.Bucket)
    return nil
}

Secrets are encrypted in state automatically:

```go
stack.SetConfig(ctx, "dbPassword", auto.ConfigValue{
    Value:  "supersecret",
    Secret: true,
})

Pulumi's Output Type

This is the part that confuses everyone the first time.

When you create a resource, its ID, ARN, and other attributes aren't known until Pulumi actually creates the resource during apply. You can't use them as plain strings. Pulumi wraps them in Output[T], a container that holds a value that will exist later.

The wrong approach: trying to extract the value immediately.

vpc, _ := ec2.NewVpc(ctx, "vpc", &ec2.VpcArgs{
    CidrBlock: pulumi.String("10.0.0.0/16"),
})

// ❌ WRONG: vpc.ID() is a pulumi.IDOutput, not a string
// This does NOT compile — you can't pass an Output where a plain string is expected
var vpcID string = vpc.ID() // compile error

The correct approach: pass outputs as inputs to other resources, or use .ApplyT to transform the value inside the output.

vpc, _ := ec2.NewVpc(ctx, "vpc", &ec2.VpcArgs{
    CidrBlock: pulumi.String("10.0.0.0/16"),
})

// ✅ CORRECT: pass vpc.ID() directly as an input — Pulumi resolves it at apply time
subnet, _ := ec2.NewSubnet(ctx, "subnet", &ec2.SubnetArgs{
    VpcId:     vpc.ID(),                          // IDOutput, not string
    CidrBlock: pulumi.String("10.0.1.0/24"),
})

// To derive a new value from an output, use ApplyT
vpcArn := vpc.Arn.ApplyT(func(arn string) string {
    return "processed: " + arn
}).(pulumi.StringOutput)

The pattern to remember: don't try to read the value out. Pass the output where the input is needed and let Pulumi wire the dependency graph. If you need to transform it, use .ApplyT. Most confusion with Pulumi comes from trying to treat outputs like plain values.

Resource Dependencies and Ordering

Pulumi infers dependencies from output-to-input references. When you pass vpc.ID() as the VpcId for a subnet, Pulumi knows to create the VPC first. The engine builds a DAG and parallelizes what it can.

func infrastructure(ctx *pulumi.Context) error {
    vpc, err := ec2.NewVpc(ctx, "vpc", &ec2.VpcArgs{
        CidrBlock: pulumi.String("10.0.0.0/16"),
    })
    if err != nil {
        return err
    }

    subnet, err := ec2.NewSubnet(ctx, "subnet", &ec2.SubnetArgs{
        VpcId:     vpc.ID(),
        CidrBlock: pulumi.String("10.0.1.0/24"),
    })
    if err != nil {
        return err
    }

    // Instance depends on subnet through data flow
    _, err = ec2.NewInstance(ctx, "web", &ec2.InstanceArgs{
        SubnetId:     subnet.ID(),
        InstanceType: pulumi.String("t3.micro"),
        Ami:          pulumi.String("ami-0c55b159cbfafe1f0"),
    })
    return err
}

For ordering that doesn't follow from data flow, use pulumi.DependsOn:

ec2.NewInstance(ctx, "web", &ec2.InstanceArgs{...},
    pulumi.DependsOn([]pulumi.Resource{subnet}))

Use this when one resource must exist before another, but the second one doesn't reference the first's outputs directly.

Error Recovery

Pulumi tracks state during operations. If a deployment fails midway, the state records which resources were created. On the next run it picks up from there rather than creating duplicates.

func deployWithRecovery(ctx context.Context, stack auto.Stack) error {
    result, err := stack.Up(ctx, optup.ProgressStreams(os.Stdout))
    if err != nil {
        fmt.Printf("Deploy failed: %v\n", err)
        // Output: Deploy failed: updating urn:pulumi:dev::infra::aws:s3:Bucket::my-bucket: access denied

        // Refresh state to reconcile with what actually exists
        _, refreshErr := stack.Refresh(ctx)
        if refreshErr != nil {
            return fmt.Errorf("refresh after failure: %w (original: %v)", refreshErr, err)
        }
        return fmt.Errorf("deploy failed, state refreshed: %w", err)
    }

    fmt.Printf("Deploy succeeded: %s\n", result.Summary.Message)
    // Output: Deploy succeeded: 3 resources created
    return nil
}

Always call stack.Refresh(ctx) after a failure. It syncs the state file with what actually exists in your cloud account before the next attempt. Skipping this leads to "resource already exists" errors on retry.

Putting It Together: a Self-Service Provisioner

The Automation API is what you use to embed IaC inside a platform tool. Instead of asking engineers to run pulumi up locally, you write a service that provisions environments on demand. A developer opens a PR, your CI calls stack.Up, the environment exists, no console access required.

Here's what the provisioner loop looks like:

func provisionEnvironment(ctx context.Context, name, region string) error {
    stack, err := auto.UpsertStackInlineSource(ctx, name, "platform", func(pctx *pulumi.Context) error {
        // defineEnvironment creates the VPC, subnets, and security groups
        // for one isolated environment — same pattern as deployFunc earlier
        vpc, err := ec2.NewVpc(pctx, "vpc", &ec2.VpcArgs{
            CidrBlock: pulumi.String("10.0.0.0/16"),
            Tags:      pulumi.StringMap{"env": pulumi.String(name)},
        })
        if err != nil {
            return err
        }
        pctx.Export("vpcId", vpc.ID())
        return nil
    })
    if err != nil {
        return fmt.Errorf("upsert stack: %w", err)
    }

    stack.SetAllConfig(ctx, auto.ConfigMap{
        "aws:region": {Value: region},
    })

    // Preview first — log what will change
    preview, err := stack.Preview(ctx)
    if err != nil {
        return fmt.Errorf("preview: %w", err)
    }
    totalChanges := 0
    for _, count := range preview.ChangeSummary {
        totalChanges += count
    }
    fmt.Printf("Environment %s: %d resource changes planned\n", name, totalChanges)

    // Apply with built-in refresh-on-failure
    if err := deployWithRecovery(ctx, stack); err != nil {
        return fmt.Errorf("provision %s: %w", name, err)
    }

    fmt.Printf("Environment %s ready\n", name)
    return nil
}

This is the architecture that replaced the manual console sessions. Engineers declare the environment they need. The tool ensures it exists and matches the declaration. The console access that caused the original drift problem is now irrelevant. Even if someone makes a change there, the next deploy brings everything back to the declared state.

Wire provisionEnvironment into devctl as the infra provision subcommand:

var infraProvisionCmd = &cobra.Command{
    Use:   "provision <stack> <region>",
    Short: "Provision a cloud environment via Pulumi",
    Args:  cobra.ExactArgs(2),
    RunE: func(cmd *cobra.Command, args []string) error {
        return provisionEnvironment(cmd.Context(), args[0], args[1])
    },
}

func init() {
    infraCmd.AddCommand(infraProvisionCmd)
    rootCmd.AddCommand(infraCmd)
}

Run it with:

devctl infra provision pr-123 us-east-1
# Output: Environment pr-123 ready

Pulumi Stack Deployer

A self-contained Go program that uses the Pulumi Automation API to create a stack, set config, preview changes, and deploy infrastructure, all as library calls with no CLI subprocess.

Input: A stack name, project name, and AWS region. Output: Provisioned S3 bucket with website hosting and streaming deploy output.

Full source: examples/pulumi-stack-deployer

Key Takeaways

  • Infrastructure drift is the default outcome of manual cloud management. IaC makes the codebase the source of truth, so drift is detected and correctable.
  • Hand-rolling an imperative provisioner with the AWS SDK means reimplementing check-if-exists, create-if-not, and update-if-different for every resource type. Use an IaC tool instead.
  • Declarative IaC describes desired state. The engine computes the diff and handles dependency ordering.
  • State files track what exists. Always use a remote backend with locking in production. Local state files cause coordination problems on teams.
  • Pulumi's Output[T] holds values not known until apply time. Don't try to extract them as strings. Pass them as inputs to other resources, or transform with .ApplyT.
  • Always call stack.Refresh(ctx) after a failed deployment before retrying. It syncs the state file with reality.
  • The Automation API lets you embed the full IaC lifecycle in a Go service, no CLI subprocess needed.

🎁 The environment is provisioned. Now the deploy needs to reach the config repo. Next, devctl gitops push clones the config repo, updates the image tag, and pushes a branch for review, all without a git binary.

💻 Examples

Complete examples for this lesson. Copy and run locally.

📝 Ready to test your knowledge?

Answer the quiz below to mark this lesson complete.

Spot something off? Report an issue
© 2026 ByteLearn.dev. Free courses for developers. · Privacy