Version Your Infrastructure as Code

Version your infrastructure as code with Python for DevOps automation. Learn the core concept, hands-on steps, and troubleshooting tips in this practical tutorial.

Focus: version your infrastructure as code

Sponsored

You’ve written code that deploys servers, spins up containers, or updates cloud resources — but what happens when that same script breaks three months later and no one knows what changed? You can’t diff what you can’t see, and if your infrastructure only exists in the cloud console or a forgotten script, you’re one bad --force away from a production outage. This lesson shows you how to version your infrastructure as code — treating your AWS, Kubernetes, or Docker setup like application code, tracked in Git, reviewed, and reproducible. By the end, you’ll have a Python-based workflow that turns chaos into confidence.

The problem this lesson solves

Imagine this: a teammate manually tweaks a security group in the AWS console, another runs a one-off kubectl apply to fix a pod, and your deployment script has a checklist of commands that no one fully remembers. What happens when you need to rebuild the environment for a new region? You reverse-engineer from a screenshot and hope for the best.

This is the undocumented state problem — infrastructure that exists but isn’t captured in a versioned source of truth. The consequences are real:

  • No rollback path: You can’t git revert a server configuration.
  • No audit trail: Who changed what, and when? You’ll never know.
  • Drift: Manual fixes make the running state diverge from your scripts until your scripts are fiction.
  • Onboarding pain: New engineers can’t spin up a consistent dev environment.

By the time something breaks, you’re not debugging a problem — you’re debugging a mystery. Versioning your infrastructure as code turns that mystery into a diff.

Core concept / mental model

Think of your infrastructure the same way you think of an application codebase. You wouldn’t write a Python script and never commit it to Git — you’d lose history, collaboration, and the ability to revert. Infrastructure should be no different.

Version your infrastructure as code means you define your servers, networks, containers, and cloud services in declarative or scriptable files, then store those files in a version control system (usually Git). The commands that create, update, or destroy resources become repeatable, not one-off keystrokes.

The mental model: your Git repository is the blueprint, and the live cloud state is the building. If the building doesn’t match the blueprint, you have drift. The goal is to keep them in sync using a repeatable process.

Key terms to anchor in your mind:

  • Infrastructure as Code (IaC): Managing infrastructure via machine-readable definition files, not manual processes.
  • Declarative vs. imperative: Declarative states what you want (e.g., “a t3.micro instance with this AMI”), imperative states how (e.g., step-by-step commands). Both can be versioned, but declarative is usually easier to audit.
  • Idempotency: Running the same code twice yields the same result — no accidental duplicates or destructive side effects.
  • State vs. code: The code in your repo is the desired state; the actual cloud resources are the current state. Versioning the code lets you track, review, and roll back changes to that desired state.

Pro tip: Treat every infrastructure change like a code change — create a branch, make your edit, open a pull request, and only merge when all checks pass. That’s the Shift-Left mindset applied to hardware.

How it works step by step

Versioning your infrastructure as code isn’t a single tool — it’s a discipline. Here’s the logical flow you’ll follow:

  1. Define your infrastructure in a file. Use a language or format you can commit — JSON, YAML, Python code, or a dedicated IaC tool (Terraform, CloudFormation, Pulumi).
  2. Store that file in a version-controlled repository. This is the critical step: your script or config now has history, bug fixes, and reviewers.
  3. Create a repeatable deployment process. Write a script (Python is perfect) that reads your definition and creates or updates resources idempotently.
  4. Track changes with Git. Every time you modify the infrastructure definition, commit it. Use meaningful commit messages like "add staging security group rule".
  5. Treat your code as the single source of truth. Never make manual changes to cloud resources without also updating the code. If you must, update the code first, then apply.

Cause → effect: If you commit a change to your Terraform file, you can later diff that against the live state to see what would change. If you commit a Python script that provisions EC2 instances, you can run it again on a new account and get the same result.

Hands-on walkthrough

Let’s build a minimal but practical example. We’ll use Python to create a simple configuration file, version it, and then apply it idempotently. You’ll use boto3 to interact with AWS, but the pattern works for Docker or any cloud.

Step 1: Write a Python function that declares your infrastructure

Start by defining a function that returns a dict structure for an EC2 instance. This is your “single source of truth.”

# infrastructure.py

def web_server_config() -> dict:
    return {
        "name": "web-prod",
        "instance_type": "t3.micro",
        "ami": "ami-0c55b159cbfafe1f0",  # Amazon Linux 2 (adjust for your region)
        "security_group": "sg-web-prod",
        "tags": {"Environment": "production"},
    }
"""
Example usage:
>>> print(web_server_config()["instance_type"])
t3.micro
"""

Step 2: Build an idempotent apply script

Now write a script that reads that config and applies it — but only if the resource doesn’t already exist. This is the heart of IaC: running the same script twice should not create duplicate resources.

# apply_infra.py
import boto3
from infrastructure import web_server_config

def get_existing_instances(ec2):
    response = ec2.describe_instances(
        Filters=[{"Name": "tag:Name", "Values": [web_server_config()["name"]]}]
    )
    instances = [
        i for r in response["Reservations"] for i in r["Instances"]
        if i["State"]["Name"] != "terminated"
    ]
    return instances

def apply():
    cfg = web_server_config()
    ec2 = boto3.client("ec2", region_name="us-east-1")

    existing = get_existing_instances(ec2)
    if existing:
        print(f"Found {len(existing)} instance(s) with name {cfg['name']} — skipping creation.")
        return

    print("Creating new instance...")
    response = ec2.run_instances(
        ImageId=cfg["ami"],
        InstanceType=cfg["instance_type"],
        MinCount=1,
        MaxCount=1,
        TagSpecifications=[
            {
                "ResourceType": "instance",
                "Tags": [
                    {"Key": "Name", "Value": cfg["name"]},
                    {"Key": "Environment", "Value": cfg["tags"]["Environment"]},
                ],
            }
        ],
    )
    instance_id = response["Instances"][0]["InstanceId"]
    print(f"Created instance {instance_id}")

if __name__ == "__main__":
    apply()

Expected output (first run):

Creating new instance...
Created instance i-0123456789abcdef0

Second run (same script, no changes):

Found 1 instance(s) with name web-prod — skipping creation.

That’s idempotency in action — your code is now safe to run on a schedule or in a CI/CD pipeline.

Step 3: Version it in Git

Now turn this into versioned infrastructure. Initialize a repo and make your first commit:

git init my-infra
cd my-infra
# Copy your files into the folder, then:
git add infrastructure.py apply_infra.py
git commit -m "Initial version of web-prod infrastructure"

To test the power of versioning, change the instance type in infrastructure.py and commit again:

git diff HEAD~1  # see exactly what changed

Later, if the new instance type causes issues, you can git revert and re-run the script to roll back.

Pro tip: Never put AWS credentials in your repo. Use environment variables or IAM roles, and add a .gitignore for any .env files.

Compare options / when to choose what

There are multiple ways to version your infrastructure as code. Here’s a quick comparison to help you choose:

Approach Languages Declarative? Best for Drawbacks
Terraform HCL Yes Multi-cloud, entire stack management Steeper learning curve, state file management
AWS CloudFormation YAML/JSON Yes AWS-only, native integration Vendor lock-in, verbose syntax
Python + boto3 (your script) Python Imperative (but idempotent) When you want full control, need to fit into a Python codebase Requires writing your own idempotency logic
Docker Compose YAML Semi Local dev, simple container stacks Not for full cloud infra

For this lesson, we’ve chosen a simple Python approach because it keeps you close to the Python automation tools you’re already learning. But in production, many teams use Terraform for cloud-agnostic infrastructure and reserve Python scripts for glue tasks.

Variation: You can also use Pulumi, which lets you write infrastructure in Python directly — it’s declarative but uses real programming languages, giving you loops, conditionals, and type checking.

Troubleshooting & edge cases

1. “The script recreated a resource that already existed”

If you skip the existence check (as we did with get_existing_instances), you’ll end up with duplicates. Always build idempotency into your script: check by name, tag, or a unique identifier before creating.

2. “I changed the config but nothing updated”

Because our script only creates resources, it won’t apply changes to an existing instance. For update logic, you’d need to compare desired vs. actual and call modify_instance or similar. That’s where declarative tools like Terraform shine.

3. “Git says my working tree is dirty when I run the script”

If your script writes a state file (e.g., .tfstate or a cache file), make sure to add it to a .gitignore if it’s generated. Otherwise, you’ll commit machine-specific state and cause merge conflicts.

4. “Permissions error when applying changes”

That’s not a code bug — it’s an IAM role problem. Ensure your boto3 session has ec2:RunInstances and ec2:DescribeInstances permissions. Use aws sts get-caller-identity to verify who you are.

5. “My team still edits the console directly”

This is a process problem. Set up a deployment bot or a CI pipeline that runs your apply script on every merge. Then make it clear: any manual change will be overwritten at the next run.

What you learned & what's next

You’ve learned why the undocumented state problem hurts every team, and how version your infrastructure as code solves it by making your infrastructure reviewable, reproducible, and reversible. You built a Python script that defines and idempotently applies an EC2 instance, committed it to Git, and diffed changes. You know when to use a Python imperative script vs. a declarative tool like Terraform, and you’ve seen the troubleshooting tips that will save you hours.

Key takeaways you should carry forward:

  • Infrastructure as code means your cloud setup is stored in files, not just in a console.
  • Versioning those files with Git gives you history, rollback, and team collaboration.
  • Idempotency is the secret sauce — running the same script twice should never cause duplicate or destructive changes.
  • Choose a tool based on your cloud ecosystem and complexity; Python scripts are great for lightweight automation.
  • Never store credentials in your repo; use environment variables or IAM roles.

Now that you can version a single resource, the next lesson will show you how to manage secrets and environment configuration — because infrastructure isn’t just servers, it’s also the variables and keys that make them run.

Ready to go beyond one EC2 instance? In the next step, you’ll learn how to keep your configs encrypted and inject them into your versioned infrastructure safely.

Practice recap

Create a new Python script that defines a simple Docker container using docker-py (or just a YAML config) and version it in a fresh Git repo. Write an idempotent apply function that checks if the container already exists before creating it, then commit and practice reverting with git revert. Go one step further: add a CI-friendly command that runs your apply script in a pipeline.

Common mistakes

  • Storing AWS credentials in the script — always use environment variables, IAM roles, or AWS Vault; a committed key is a security incident.
  • Running the apply script without an existence check — you’ll create duplicate resources every time; every resource must have a unique identifier you can query.
  • Ignoring drift by manually editing the cloud console — your versioned code becomes stale, and the next pipeline run will overwrite or break your manual changes.
  • Committing generated state files (like Terraform’s .tfstate) to the repo — they contain sensitive data and cause merge conflicts.
  • Forgetting to version the infrastructure definition itself — committing only the apply script but not the config files means you still can’t see infrastructure history.

Variations

  1. Terraform is a declarative, cloud-agnostic IaC tool that tracks state in .tfstate files and handles updates/destroy automatically — great for multi-cloud stacks.
  2. AWS CloudFormation is native to AWS, uses YAML/JSON templates, and provides integrated change sets and drift detection — best if you’re all-in on AWS.
  3. Pulumi lets you write infrastructure in real Python (or TypeScript) with loops and conditionals, and it’s declarative under the hood — ideal for Python-heavy teams.

Real-world use cases

  • A startup’s Python script provisions a staging EC2 instance, and the script is stored in Git so every developer can spin up the same environment with one command.
  • A DevOps engineer uses a versioned Terraform configuration stored in Git to manage a multi-cloud Kubernetes cluster, enabling peer-reviewed changes and rollback to approved infrastructure.
  • A platform team treats a Docker Compose file as infrastructure-as-code in a repository, letting each engineer version container configurations for local dev and CI, with Git history to diagnose regressions.

Key takeaways

  • Version your infrastructure as code by storing resource definitions in Git — this gives you history, audits, and rollback capability.
  • Use Python with boto3 for an idempotent, imperative approach when you want full control and minimal tooling overhead.
  • Always implement existence checks before creating a resource to avoid duplicates and make your script safe to rerun.
  • Choose a declarative tool like Terraform for complex, multi-cloud stacks, and reserve Python scripts for simple, task-specific automation.
  • Never commit credentials, and always treat your versioned code as the single source of truth — manual console changes cause drift.
  • Connect your infrastructure code to a CI/CD pipeline so applying changes is automated and reviewable, not a one-off command.

Sponsored

Sponsored

Discussion

Questions, corrections, and tips help everyone reading this page.

0 comments

Add a comment

Shown publicly with your comment.

Be constructive · max 4,000 characters

No comments yet — start the thread.

Related tutorials, quizzes, and articles for this topic.