35-Day DevOps Bootcamp ยท Day 25
1 / 16
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.