Skip to content

Hands-on DevOps Labs

A practical lab path for learning how a cloud service is created manually, then reproduced with automation. Every lab follows the same loop:

  1. Understand the architecture.
  2. Create the smallest working version.
  3. Automate the same result.
  4. Verify, observe, and destroy what you created.

These examples are learning templates. Use a sandbox account, least-privilege credentials, budgets, and short-lived resources. Never commit passwords, access keys, or state files.


Lab 1: AWS EC2, Manual to Terraform

Architecture

Internet -> Security Group -> EC2 instance -> HTTP service

Manual setup with AWS CLI

Set your region first:

export AWS_REGION=us-east-1
aws configure

Find an Ubuntu AMI owned by Canonical:

AMI_ID=$(aws ec2 describe-images \
  --region "$AWS_REGION" \
  --owners 099720109477 \
  --filters 'Name=name,Values=ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*' \
            'Name=state,Values=available' \
  --query 'sort_by(Images, &CreationDate)[-1].ImageId' \
  --output text)

Create a security group and allow HTTP only:

VPC_ID=$(aws ec2 describe-vpcs --region "$AWS_REGION" \
  --filters Name=is-default,Values=true \
  --query 'Vpcs[0].VpcId' --output text)

SG_ID=$(aws ec2 create-security-group \
  --region "$AWS_REGION" \
  --group-name devops-lab-web \
  --description 'HTTP access for the DevOps lab' \
  --vpc-id "$VPC_ID" \
  --query GroupId --output text)

aws ec2 authorize-security-group-ingress \
  --region "$AWS_REGION" \
  --group-id "$SG_ID" \
  --protocol tcp --port 80 --cidr 0.0.0.0/0

Launch an instance with user data that installs a web server:

aws ec2 run-instances \
  --region "$AWS_REGION" \
  --image-id "$AMI_ID" \
  --instance-type t3.micro \
  --security-group-ids "$SG_ID" \
  --tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=devops-lab-web}]' \
  --user-data '#!/bin/bash
apt-get update -y
apt-get install -y nginx
systemctl enable --now nginx'

Verify the instance and public address:

aws ec2 describe-instances \
  --region "$AWS_REGION" \
  --filters Name=tag:Name,Values=devops-lab-web Name=instance-state-name,Values=running \
  --query 'Reservations[].Instances[].PublicIpAddress' \
  --output text

Reproduce it with Terraform

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

variable "aws_region" {
  type    = string
  default = "us-east-1"
}

provider "aws" {
  region = var.aws_region
}

data "aws_vpc" "default" {
  default = true
}

data "aws_ami" "ubuntu" {
  most_recent = true
  owners      = ["099720109477"]

  filter {
    name   = "name"
    values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
  }

  filter {
    name   = "state"
    values = ["available"]
  }
}

resource "aws_security_group" "web" {
  name        = "devops-lab-web"
  description = "HTTP access for the DevOps lab"
  vpc_id      = data.aws_vpc.default.id

  ingress {
    description = "HTTP"
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

resource "aws_instance" "web" {
  ami                         = data.aws_ami.ubuntu.id
  instance_type               = "t3.micro"
  vpc_security_group_ids     = [aws_security_group.web.id]
  associate_public_ip_address = true

  user_data = <<-USER_DATA
    #!/bin/bash
    apt-get update -y
    apt-get install -y nginx
    systemctl enable --now nginx
  USER_DATA

  tags = {
    Name      = "devops-lab-web"
    ManagedBy = "Terraform"
  }
}

output "public_ip" {
  value = aws_instance.web.public_ip
}

Run and inspect the plan before applying:

terraform init
terraform fmt -check
terraform validate
terraform plan -out=tfplan
terraform apply tfplan
terraform output public_ip

Clean up when finished:

terraform destroy

Lab 2: Static Website on S3

Manual workflow

BUCKET="devops-lab-$(date +%s)"
aws s3 mb "s3://$BUCKET" --region "$AWS_REGION"
aws s3 cp ./site/ "s3://$BUCKET/" --recursive
aws s3 sync ./site/ "s3://$BUCKET/" --delete
aws s3 ls "s3://$BUCKET/"

For production, put CloudFront and a private bucket in front of the origin instead of making the bucket public. Add versioning and lifecycle rules for recovery and cost control.

Automation checklist

  • Bucket name and region are variables.
  • Public access remains blocked by default.
  • Encryption and versioning are enabled.
  • A deployment role can write objects but cannot change account security settings.
  • CI runs terraform fmt -check, terraform validate, and terraform plan.

Lab 3: CI/CD Promotion Flow

A useful pipeline separates build, verification, and deployment:

stages:
- stage: Verify
  jobs:
  - job: Test
    steps:
    - script: terraform fmt -check
    - script: terraform validate
    - script: terraform plan -out=tfplan

- stage: Deploy
  dependsOn: Verify
  condition: succeeded()
  jobs:
  - deployment: Apply
    environment: production
    strategy:
      runOnce:
        deploy:
          steps:
          - script: terraform apply -auto-approve tfplan

Protect the production environment with approvals, branch policies, secret scanning, and a service connection that has only the permissions required by the deployment.


Verification Checklist

  • The resource has an owner, environment, and expiry tag.
  • Inbound access is limited to required ports and sources.
  • Secrets come from a secret manager or pipeline secret, never Git.
  • Logs and health metrics are available before production traffic arrives.
  • The plan was reviewed before apply.
  • Destroy or rollback was tested.
  • Cloud budgets and alerts are enabled.

Continue with the detailed AWS guide, Terraform guide, and Azure DevOps guide.