Capstone
Day 25: IaC + CI/CD Capstone
Free end-to-end lab path with Terraform, Ansible and CI/CD
Recommended path: Terraform provisions a local Docker target, Ansible configures nginx on it, and Jenkins or GitHub Actions self-hosted runner drives the full workflow. Azure is included only as an optional extension.
Workflow
Plan โ Apply โ Configure โ Verify
60 min
Theory 15 min
Lab 40 min
You will build
A real pipeline that plans, applies, configures, and verifies a running nginx service.
Recommended lab path
Docker + Terraform + Ansible + Jenkins or GitHub self-hosted runner.
Optional cloud path
Azure remains optional. The same workflow applies to either target.
Agenda
60-minute capstone agenda
0-10 min: architecture
10-20 min: tool boundaries
20-45 min: build and run
45-55 min: drift + CI/CD
55-60 min: quiz
Core ideas
Terraform owns infrastructure lifecycle.
Ansible owns operating system and app configuration.
CI/CD owns sequencing, approvals, schedules, and evidence.
Common mistakes
Do not spend the session on cloud account setup.
Do not blur `plan` and `apply` into one uncontrolled step.
Do not present Terraform provisioners as a substitute for Ansible.
Targets
Delivery target options
Recommended free track
Terraform Docker provider provisions a local network and SSH-enabled container.
Ansible connects to `127.0.0.1:2222` and installs nginx.
App is visible on `http://127.0.0.1:8080`.
Works end to end with local Jenkins or GitHub self-hosted runner.
Optional cloud extension
Azure Resource Group + Storage Account + VM.
Same Ansible role, same pipeline structure.
Best for teams that already have cloud access.
The workflow stays the same when the target changes.
The free track is enough to practice the full workflow from provision to verification.
Architecture
End-to-end workflow
Git
Store Terraform, Ansible, templates, Jenkinsfile, and workflow YAML.
CI
Run validation and `terraform plan` on pull request.
Terraform
Create Docker network, build image, run container, emit SSH and app endpoints.
Ansible
Configure nginx, render template, place Vault secret, restart only on change.
Verify
Hit `/health` and homepage, then report success or drift.
Developer push/PR
-> terraform fmt/validate
-> terraform plan
-> review and merge
-> terraform apply
-> terraform output -> inventory
-> ansible-playbook site.yml
-> curl http://127.0.0.1:8080/health
-> notify / archive logs
Lab Requirements
Lab Setup Requirements
Works cleanly
Local Docker daemon on the student laptop.
Local Jenkins server, or GitHub Actions self-hosted runner on the same machine.
Terraform state stored locally for the lab, or in a Git-backed artifact path if the runner is fixed.
Runner requirement
GitHub hosted runners are fine for validation demos.
They are not the right choice for persistent local Docker infrastructure across runs.
If you want real drift detection on local infra, the scheduler must run on the same machine or a stable runner.
The runner must be able to reach the infrastructure it is planning, applying, and verifying.
Terraform
Provision a free lab target with the Docker provider
Provisioned resources
`docker_image` built from a local Dockerfile
`docker_network` for isolation
`docker_container` exposing SSH on `2222` and web on `8080`
Terraform outputs for Ansible
SSH host: `127.0.0.1`
SSH port: `2222`
App URL: `http://127.0.0.1:8080`
terraform {
required_providers {
docker = {
source = "kreuzwerker/docker"
version = "~> 3.0"
}
}
}
resource "docker_network" "capstone" {
name = "day25-net"
}
resource "docker_image" "target" {
name = "day25-ssh-target:latest"
build { context = "../target-host" }
}
resource "docker_container" "web" {
name = "day25-web"
image = docker_image.target.image_id
networks_advanced { name = docker_network.capstone.name }
ports { internal = 22 external = 2222 }
ports { internal = 80 external = 8080 }
}
Target Host
Use a disposable SSH-enabled container as the Ansible target
Why this works well
Students get a real remote-style target without paying for a VM.
Ansible behavior stays realistic: SSH, package install, templates, handlers.
The environment is disposable and easy to reset.
Container requirements
OpenSSH server
Python for Ansible modules
A non-root user with an injected public key
# target-host/Dockerfile
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y openssh-server python3 sudo && rm -rf /var/lib/apt/lists/*
RUN useradd -m -s /bin/bash devops && echo 'devops ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers
RUN mkdir /var/run/sshd /home/devops/.ssh && chmod 700 /home/devops/.ssh
COPY id_rsa.pub /home/devops/.ssh/authorized_keys
RUN chown -R devops:devops /home/devops/.ssh && chmod 600 /home/devops/.ssh/authorized_keys
CMD ["/usr/sbin/sshd", "-D"]
Ansible
Configure nginx with a role, template, and Vault secret
Role
`roles/nginx/tasks/main.yml` installs packages and copies config.
Template
`nginx.conf.j2` renders an environment banner and a health endpoint.
Vault
`group_vars/web/vault.yml` stores a secret shown in the deployed page or written to a file.
# inventory.ini
[web]
web1 ansible_host=127.0.0.1 ansible_port=2222 ansible_user=devops ansible_ssh_private_key_file=ansible/id_rsa
# site.yml
- hosts: web
become: true
roles:
- nginx
# roles/nginx/tasks/main.yml
- name: Install nginx
apt:
name: nginx
state: present
update_cache: true
- name: Render nginx config
template:
src: nginx.conf.j2
dest: /etc/nginx/sites-available/default
notify: restart nginx
Validation Strategy
Validate manually before you automate
Step 1: Local validation
Create files, run Terraform manually, run Ansible manually, and verify the app locally.
Step 2: GitHub Actions
After the manual path works, automate the same sequence on a self-hosted runner.
Step 3: Drift detection
Add a scheduled `terraform plan -detailed-exitcode` only after the automated path is stable.
Teach and test the workflow in the same order students will build it: manual first, automation second.
Manual Validation
Step-by-step manual validation runbook
Steps 1-4
1. Create the folders
Make `terraform/`, `ansible/`, `target-host/`, and `scripts/` with the required subfolders.
2. Create the files
Add Terraform files, Ansible files, the Dockerfile, the inventory script, and the Vault password file.
3. Generate SSH access files
Create an SSH key pair and copy the public key into `target-host/id_rsa.pub`.
4. Prepare the workstation
Start Docker and confirm `docker info` works before running Terraform.
Steps 5-8
5. Initialize and preview Terraform
Run `terraform -chdir=terraform init` and `terraform -chdir=terraform plan`.
6. Create the target
Run `terraform -chdir=terraform apply -auto-approve` to start the SSH-enabled container.
7. Configure with Ansible
Run `./scripts/render_inventory.sh` and then `ansible-playbook -i ansible/inventory.ini ansible/site.yml --vault-password-file ansible/.vault_pass`.
8. Verify the result
Open `http://127.0.0.1:8080` and run `curl -fsS http://127.0.0.1:8080/health`.
Expected checkpoints
After step 2: The repo has the required files in the correct folders.
After step 5: Terraform shows a clean execution plan.
After step 6: The container is reachable on SSH port `2222` and web port `8080`.
After step 8: The homepage and `/health` both respond successfully.
If a step fails
Scaffold failure: check file paths and names before running commands.
Terraform failure: check Docker daemon and provider setup.
SSH failure: check key path, user, and mapped port `2222`.
Ansible failure: check Python, sudo, and nginx package installation.
# Step 1: create folders
mkdir -p terraform ansible/group_vars/web ansible/roles/nginx/{tasks,templates,handlers} target-host scripts
# Step 2: create files
touch terraform/{main.tf,variables.tf,outputs.tf}
touch ansible/{inventory.ini,site.yml,.vault_pass}
touch ansible/group_vars/web/vault.yml
touch ansible/roles/nginx/tasks/main.yml
touch ansible/roles/nginx/templates/nginx.conf.j2
touch ansible/roles/nginx/handlers/main.yml
touch target-host/Dockerfile
touch scripts/render_inventory.sh
# Step 3: generate SSH files
ssh-keygen -t rsa -b 4096 -f ansible/id_rsa -N ""
cp ansible/id_rsa.pub target-host/id_rsa.pub
# Step 4: prepare workstation
docker info
# Step 5: initialize and preview infra
terraform -chdir=terraform init
terraform -chdir=terraform plan
# Step 6: create target
terraform -chdir=terraform apply -auto-approve
# Step 7: build inventory and configure
./scripts/render_inventory.sh
ansible-playbook -i ansible/inventory.ini ansible/site.yml --vault-password-file ansible/.vault_pass
# Step 8: verify
curl -fsS http://127.0.0.1:8080
curl -fsS http://127.0.0.1:8080/health
Paste into `terraform/main.tf`
terraform {
required_providers {
docker = {
source = "kreuzwerker/docker"
version = "~> 3.0"
}
}
}
resource "docker_network" "capstone" {
name = "day25-net"
}
resource "docker_image" "target" {
name = "day25-ssh-target:latest"
build { context = "../target-host" }
}
resource "docker_container" "web" {
name = "day25-web"
image = docker_image.target.image_id
networks_advanced { name = docker_network.capstone.name }
ports { internal = 22 external = 2222 }
ports { internal = 80 external = 8080 }
}
Paste into the support files
# ansible/site.yml
- hosts: web
become: true
roles:
- nginx
# scripts/render_inventory.sh
cat > ansible/inventory.ini <<'EOF'
[web]
web1 ansible_host=127.0.0.1 ansible_port=2222 ansible_user=devops ansible_ssh_private_key_file=ansible/id_rsa
EOF
# ansible/.vault_pass
devops123
Do not automate anything until this manual path works exactly as expected.
GitHub Actions
Step-by-step GitHub Actions setup
Steps 1-4
1. Create the workflow folder
Run `mkdir -p .github/workflows` in the repo root.
2. Create the workflow file
Add `.github/workflows/day25-capstone.yml`.
3. Register a self-hosted runner
Attach the runner to the same machine that has Docker, Terraform, Ansible, and the repo.
4. Add repository secrets
Create `ANSIBLE_VAULT_PASSWORD` in GitHub repository settings.
Steps 5-8
5. Paste the workflow YAML
Define `pull_request`, `push`, and `schedule` triggers.
6. Test the PR path
Open a pull request and confirm `fmt`, `validate`, and `plan` run on the self-hosted runner.
7. Test the main branch path
Merge to `main` and confirm `apply`, inventory generation, Ansible, and health checks run.
8. Test the drift schedule
Confirm Monday `8:00` scheduled `plan -detailed-exitcode` is enabled.
Workflow file
mkdir -p .github/workflows
touch .github/workflows/day25-capstone.yml
# GitHub -> Settings -> Secrets and variables -> Actions
# Add secret: ANSIBLE_VAULT_PASSWORD
Expected results
Pull request: Terraform formatting, validation, and plan succeed.
Main branch: Target is created, configured, and verified.
Schedule: Drift detection reports no changes or highlights drift.
name: day25-capstone
on:
pull_request:
push:
branches: [main]
schedule:
- cron: "0 8 * * 1"
jobs:
plan:
if: github.event_name == 'pull_request' || github.event_name == 'schedule'
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- run: terraform -chdir=terraform init
- run: terraform -chdir=terraform fmt -check
- run: terraform -chdir=terraform validate
- run: terraform -chdir=terraform plan -detailed-exitcode -no-color
apply-configure:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- run: terraform -chdir=terraform init
- run: terraform -chdir=terraform apply -auto-approve
- run: ./scripts/render_inventory.sh
- run: printf "%s" "${{ secrets.ANSIBLE_VAULT_PASSWORD }}" > ansible/.vault_pass
- run: ansible-playbook -i ansible/inventory.ini ansible/site.yml --vault-password-file ansible/.vault_pass
- run: curl -fsS http://127.0.0.1:8080/health
Create and push the workflow
mkdir -p .github/workflows
touch .github/workflows/day25-capstone.yml
git checkout -b add-day25-actions
git add .github/workflows/day25-capstone.yml
git commit -m "Add Day 25 GitHub Actions workflow"
git push -u origin add-day25-actions
Runner setup commands
# In GitHub:
# Settings -> Actions -> Runners -> New self-hosted runner
# On the same machine that runs Docker:
mkdir actions-runner && cd actions-runner
./config.sh --url --token
./run.sh
Use a self-hosted runner because the workflow must reach the same Docker environment used by the lab.
Drift Detection
Make drift visible and actionable
Drift example
Manually stop or replace the container outside Terraform.
Run scheduled `terraform plan -detailed-exitcode`.
Show that exit code `2` means drift or pending changes.
Alert conditions
Exit code `1`: pipeline error
Exit code `2`: infrastructure no longer matches code
Health check failure after apply: configuration regression
terraform plan -detailed-exitcode
# 0 = no changes
# 1 = error
# 2 = drift or unapplied change
curl -fsS http://127.0.0.1:8080/health
Jenkins
Jenkins as an optional enterprise alternative
When to use it
Use Jenkins if the team already runs Jenkins internally.
Keep the same stage order used in the manual run and GitHub Actions flow.
Store the Vault password in Jenkins credentials.
Stage order
Validate
Plan
Apply
Render inventory
Ansible configure
Health check
pipeline {
agent any
triggers { cron('0 8 * * 1') }
stages {
stage('Validate') { steps { sh 'terraform -chdir=terraform fmt -check && terraform -chdir=terraform init && terraform -chdir=terraform validate' } }
stage('Plan') { when { anyOf { changeRequest(); triggeredBy 'TimerTrigger' } } steps { sh 'terraform -chdir=terraform plan -detailed-exitcode -no-color' } }
stage('Apply') { when { branch 'main' } steps { sh 'terraform -chdir=terraform apply -auto-approve' } }
stage('Configure and Verify') { when { branch 'main' } steps { sh './scripts/render_inventory.sh && ansible-playbook -i ansible/inventory.ini ansible/site.yml --vault-password-file ansible/.vault_pass && curl -fsS http://127.0.0.1:8080/health' } }
}
}
Troubleshooting
Common failure points
SSH failure
Container image missing `sshd`, wrong key path, or incorrect mapped port.
Ansible failure
Target missing Python, sudo rights, or nginx repository metadata.
Pipeline failure
Runner is not on the right host, so it cannot see Docker state or local files.
How to debug
Every failure tells you which layer is responsible: infrastructure, configuration, or orchestration.
Recovery path
Keep a known-good version of the lab so you can recover quickly after a failed experiment.
Azure Version
Using the same workflow on Azure
Terraform changes
Resource Group
Storage Account
VM, NIC, Public IP
What stays exactly the same
PR plan, merge apply, and scheduled drift detection
Ansible role structure and Vault handling
Post-deploy verification and rollback mindset
Cloud changes the target, not the pattern.
Terraform still provisions.
Ansible still configures.
Jenkins or GitHub Actions still orchestrates.
Drift detection still protects the environment.
Knowledge Check
Knowledge Check
Questions
1) Why is the Docker-based lab a better default than Azure for this batch?
2) What belongs in Ansible that should not be done in Terraform?
3) Why must the runner be able to reach the target infrastructure?
4) What does exit code `2` from `terraform plan -detailed-exitcode` mean?
5) What are the minimum verification checks after apply and configure?
Final recap
Terraform provisions the target.
Ansible configures the target.
CI/CD sequences plan, apply, configure, verify, and drift detection.
A free local lab can still teach the enterprise pattern honestly.