So far we've generated scripts, Terraform configurations, Docker Compose files, and monitoring stacks. But they're sitting on your local machine or a single server. What happens when you need to rebuild? When a teammate needs access? When you want to roll back a change?
This guide covers the final piece: version-controlling your infrastructure and automating deployments. We'll use Claude Code to generate Ansible playbooks, CI/CD pipelines, and tie everything together into a GitOps workflow where pushing to a repository automatically provisions infrastructure.
Prerequisites
- Claude Code installed (see Part 1)
- A Git repository (GitHub, GitLab, or self-hosted Gitea)
- Basic familiarity with Git
- SSH access to your target servers
Installing Ansible
# Ubuntu/Debian
sudo apt update
sudo apt install -y ansible
# Or via pip for latest version
pip install ansible --user
# Verify
ansible --versionGenerating Ansible Playbooks
Ansible automates server configuration through declarative YAML playbooks. Instead of writing them from scratch, let Claude Code generate them:
Create an Ansible playbook structure for provisioning a new Ubuntu 24.04 server
Roles:
1. common - Base system configuration
- Set timezone to UTC
- Configure unattended-upgrades
- Install common packages (htop, curl, vim, git, tmux)
- Configure SSH hardening (disable root, key-only auth)
- Set up UFW with default deny, allow SSH
2. docker - Docker installation
- Install Docker CE from official repo
- Install Docker Compose plugin
- Add deploy user to docker group
- Configure Docker daemon (log rotation, default address pools)
3. monitoring - Node exporter setup
- Install node_exporter as systemd service
- Open port 9100 in UFW for Prometheus server IP only
Include:
- Inventory file with groups for [webservers] and [databases]
- Group variables for common settings
- Host variables example
- Main playbook that applies roles based on group membership
- Requirements.yml for any Galaxy dependencies
Use the 'deploy' user for all operations.Generated Directory Structure
ansible/
├── ansible.cfg
├── inventory/
│ ├── production
│ └── group_vars/
│ ├── all.yml
│ ├── webservers.yml
│ └── databases.yml
├── playbooks/
│ └── site.yml
├── roles/
│ ├── common/
│ │ ├── tasks/main.yml
│ │ ├── handlers/main.yml
│ │ ├── templates/sshd_config.j2
│ │ └── defaults/main.yml
│ ├── docker/
│ │ ├── tasks/main.yml
│ │ ├── handlers/main.yml
│ │ └── templates/daemon.json.j2
│ └── monitoring/
│ ├── tasks/main.yml
│ ├── handlers/main.yml
│ └── files/node_exporter.service
└── requirements.ymlansible.cfg
[defaults]
inventory = inventory/production
remote_user = deploy
private_key_file = ~/.ssh/id_ed25519
host_key_checking = False
retry_files_enabled = False
stdout_callback = yaml
[privilege_escalation]
become = True
become_method = sudo
become_user = root
become_ask_pass = False
[ssh_connection]
pipelining = True
control_path = /tmp/ansible-%%r@%%h:%%pInventory File
[webservers]
web1.example.com
web2.example.com
[databases]
db1.example.com
[monitoring]
monitor.example.com
[all:vars]
ansible_python_interpreter=/usr/bin/python3Group Variables
# Common variables for all hosts
timezone: UTC
deploy_user: deploy
# SSH Configuration
ssh_port: 22
ssh_permit_root_login: "no"
ssh_password_authentication: "no"
# Firewall
ufw_default_incoming: deny
ufw_default_outgoing: allow
# Common packages
common_packages:
- htop
- curl
- vim
- git
- tmux
- unzip
- jq
- ncdu
# Prometheus server IP (for node_exporter access)
prometheus_server_ip: "10.0.1.10"Main Playbook
---
- name: Apply common configuration to all hosts
hosts: all
become: true
roles:
- common
- name: Configure web servers
hosts: webservers
become: true
roles:
- docker
- monitoring
- name: Configure database servers
hosts: databases
become: true
roles:
- docker
- monitoringDocker Role Tasks
---
- name: Install Docker dependencies
apt:
name:
- apt-transport-https
- ca-certificates
- curl
- gnupg
- lsb-release
state: present
- name: Add Docker GPG key
apt_key:
url: https://download.docker.com/linux/ubuntu/gpg
state: present
- name: Add Docker repository
apt_repository:
repo: "deb [arch=amd64] https://download.docker.com/linux/ubuntu {{ ansible_distribution_release }} stable"
state: present
- name: Install Docker
apt:
name:
- docker-ce
- docker-ce-cli
- containerd.io
- docker-compose-plugin
state: present
update_cache: yes
- name: Configure Docker daemon
template:
src: daemon.json.j2
dest: /etc/docker/daemon.json
mode: '0644'
notify: Restart Docker
- name: Add deploy user to docker group
user:
name: "{{ deploy_user }}"
groups: docker
append: yes
- name: Ensure Docker is started and enabled
service:
name: docker
state: started
enabled: yesDocker Daemon Template
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
},
"default-address-pools": [
{
"base": "172.17.0.0/16",
"size": 24
}
],
"live-restore": true
}Application Deployment Playbooks
Now let's create playbooks for deploying applications:
Create an Ansible playbook for deploying a Docker Compose application with:
- Clone/pull from a Git repository
- Copy .env file from Ansible vault-encrypted variables
- Run docker compose pull && docker compose up -d
- Health check after deployment
- Rollback capability if health check fails
- Notification to Discord webhook on success/failure
Make it reusable for any Docker Compose project by parameterizing:
- Git repository URL
- Branch/tag to deploy
- Deploy directory on server
- Health check URL
- Discord webhook URLDeploy Compose Role
---
- name: Ensure deploy directory exists
file:
path: "{{ deploy_dir }}"
state: directory
owner: "{{ deploy_user }}"
group: "{{ deploy_user }}"
mode: '0755'
- name: Clone/update application repository
git:
repo: "{{ git_repo }}"
dest: "{{ deploy_dir }}"
version: "{{ git_version | default('main') }}"
force: yes
become_user: "{{ deploy_user }}"
register: git_result
- name: Save current commit for potential rollback
set_fact:
deploy_commit: "{{ git_result.after }}"
previous_commit: "{{ git_result.before }}"
- name: Template environment file
template:
src: env.j2
dest: "{{ deploy_dir }}/.env"
owner: "{{ deploy_user }}"
group: "{{ deploy_user }}"
mode: '0600'
when: env_vars is defined
- name: Pull latest Docker images
command: docker compose pull
args:
chdir: "{{ deploy_dir }}"
become_user: "{{ deploy_user }}"
register: pull_result
changed_when: "'Pull complete' in pull_result.stdout"
- name: Start application
command: docker compose up -d --remove-orphans
args:
chdir: "{{ deploy_dir }}"
become_user: "{{ deploy_user }}"
register: compose_result
- name: Wait for application to start
pause:
seconds: "{{ health_check_delay | default(10) }}"
- name: Perform health check
uri:
url: "{{ health_check_url }}"
method: GET
status_code: 200
timeout: 30
register: health_check
retries: 5
delay: 10
until: health_check.status == 200
when: health_check_url is defined
ignore_errors: yes
- name: Rollback on health check failure
block:
- name: Checkout previous commit
git:
repo: "{{ git_repo }}"
dest: "{{ deploy_dir }}"
version: "{{ previous_commit }}"
force: yes
become_user: "{{ deploy_user }}"
- name: Restart with previous version
command: docker compose up -d --remove-orphans
args:
chdir: "{{ deploy_dir }}"
become_user: "{{ deploy_user }}"
- name: Notify rollback
uri:
url: "{{ discord_webhook }}"
method: POST
body_format: json
body:
content: "🔴 **Deployment Failed** - Rolled back to {{ previous_commit[:8] }}"
when:
- health_check is defined
- health_check.failed | default(false)
- discord_webhook is defined
- name: Notify success
uri:
url: "{{ discord_webhook }}"
method: POST
body_format: json
body:
content: "🟢 **Deployment Successful** - {{ deploy_commit[:8] }} on {{ inventory_hostname }}"
when:
- discord_webhook is defined
- not (health_check.failed | default(false))Environment Template
# Ansible managed - {{ ansible_date_time.iso8601 }}
{% for key, value in env_vars.items() %}
{{ key }}={{ value }}
{% endfor %}Application Variables (Vault Encrypted)
app_name: myapp
git_repo: git@github.com:myorg/myapp.git
git_version: main
deploy_dir: /opt/myapp
health_check_url: http://localhost:8080/health
health_check_delay: 15
discord_webhook: !vault |
$ANSIBLE_VAULT;1.1;AES256
...encrypted webhook URL...
env_vars:
DATABASE_URL: !vault |
$ANSIBLE_VAULT;1.1;AES256
...encrypted...
REDIS_URL: redis://localhost:6379
NODE_ENV: productionCI/CD Pipeline Generation
Now let's wire this into automated deployments:
Create a GitHub Actions workflow for GitOps-style infrastructure deployment:
Workflow triggers:
- Push to main branch (auto-deploy to production)
- Push to develop branch (auto-deploy to staging)
- Manual workflow dispatch with environment selection
Jobs:
1. lint - Ansible-lint the playbooks
2. deploy - Run the appropriate playbook
Requirements:
- Use GitHub Environments for secrets management
- SSH key stored as secret
- Ansible vault password as secret
- Concurrency control (only one deploy at a time per environment)
- Deployment status in GitHub UI
- Slack/Discord notification on completion
Also create the GitLab CI equivalent for self-hosted users.GitHub Actions Workflow
name: Infrastructure Deployment
on:
push:
branches:
- main
- develop
paths:
- 'ansible/**'
- '.github/workflows/deploy.yml'
workflow_dispatch:
inputs:
environment:
description: 'Deployment environment'
required: true
default: 'staging'
type: choice
options:
- staging
- production
playbook:
description: 'Playbook to run'
required: true
default: 'site.yml'
type: choice
options:
- site.yml
- deploy-app.yml
env:
ANSIBLE_HOST_KEY_CHECKING: 'false'
ANSIBLE_FORCE_COLOR: 'true'
jobs:
lint:
name: Lint Ansible Playbooks
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install Ansible
run: pip install ansible ansible-lint
- name: Install Ansible collections
run: |
cd ansible
ansible-galaxy collection install -r requirements.yml
- name: Run ansible-lint
run: |
cd ansible
ansible-lint playbooks/
deploy:
name: Deploy to ${{ github.event.inputs.environment || (github.ref == 'refs/heads/main' && 'production' || 'staging') }}
needs: lint
runs-on: ubuntu-latest
environment: ${{ github.event.inputs.environment || (github.ref == 'refs/heads/main' && 'production' || 'staging') }}
concurrency:
group: deploy-${{ github.event.inputs.environment || (github.ref == 'refs/heads/main' && 'production' || 'staging') }}
cancel-in-progress: false
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install Ansible
run: pip install ansible
- name: Install Ansible collections
run: |
cd ansible
ansible-galaxy collection install -r requirements.yml
- name: Configure SSH and Vault
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
echo "${{ secrets.ANSIBLE_VAULT_PASSWORD }}" > ~/.vault_password
chmod 600 ~/.vault_password
- name: Run Ansible playbook
run: |
cd ansible
ansible-playbook \
-i inventory/${{ github.event.inputs.environment || (github.ref == 'refs/heads/main' && 'production' || 'staging') }} \
playbooks/${{ github.event.inputs.playbook || 'site.yml' }} \
--vault-password-file ~/.vault_password \
-e "deploy_commit=${{ github.sha }}"
- name: Notify Discord on success
if: success()
run: |
curl -X POST "${{ secrets.DISCORD_WEBHOOK }}" \
-H "Content-Type: application/json" \
-d '{"embeds": [{"title": "✅ Deployment Successful", "color": 5763719}]}'
- name: Notify Discord on failure
if: failure()
run: |
curl -X POST "${{ secrets.DISCORD_WEBHOOK }}" \
-H "Content-Type: application/json" \
-d '{"embeds": [{"title": "❌ Deployment Failed", "color": 15158332}]}'
- name: Cleanup
if: always()
run: rm -f ~/.ssh/id_ed25519 ~/.vault_passwordGitLab CI (for self-hosted)
stages:
- lint
- deploy
variables:
ANSIBLE_HOST_KEY_CHECKING: "false"
ANSIBLE_FORCE_COLOR: "true"
.ansible_setup: &ansible_setup
before_script:
- pip install ansible ansible-lint
- cd ansible
- ansible-galaxy collection install -r requirements.yml
- mkdir -p ~/.ssh
- echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
- chmod 600 ~/.ssh/id_ed25519
- echo "$ANSIBLE_VAULT_PASSWORD" > ~/.vault_password
- chmod 600 ~/.vault_password
lint:
stage: lint
image: python:3.11-slim
<<: *ansible_setup
script:
- ansible-lint playbooks/
rules:
- changes:
- ansible/**/*
- .gitlab-ci.yml
deploy_staging:
stage: deploy
image: python:3.11-slim
<<: *ansible_setup
script:
- ansible-playbook -i inventory/staging playbooks/site.yml --vault-password-file ~/.vault_password
environment:
name: staging
url: https://staging.example.com
rules:
- if: $CI_COMMIT_BRANCH == "develop"
after_script:
- rm -f ~/.ssh/id_ed25519 ~/.vault_password
deploy_production:
stage: deploy
image: python:3.11-slim
<<: *ansible_setup
script:
- ansible-playbook -i inventory/production playbooks/site.yml --vault-password-file ~/.vault_password
environment:
name: production
url: https://example.com
rules:
- if: $CI_COMMIT_BRANCH == "main"
after_script:
- rm -f ~/.ssh/id_ed25519 ~/.vault_passwordComplete Repository Structure
Here's the complete repository structure for GitOps infrastructure management:
infrastructure/
├── .github/
│ └── workflows/
│ ├── deploy.yml
│ ├── terraform.yml
│ └── pr-validation.yml
├── ansible/
│ ├── ansible.cfg
│ ├── requirements.yml
│ ├── inventory/
│ │ ├── production
│ │ ├── staging
│ │ └── group_vars/
│ ├── playbooks/
│ │ ├── site.yml
│ │ ├── deploy-app.yml
│ │ └── vars/
│ └── roles/
│ ├── common/
│ ├── docker/
│ ├── monitoring/
│ └── deploy-compose/
├── terraform/
│ ├── environments/
│ │ ├── production/
│ │ └── staging/
│ └── modules/
├── apps/
│ ├── nextcloud/
│ ├── monitoring/
│ └── traefik/
├── docs/
│ ├── README.md
│ ├── RUNBOOK.md
│ └── disaster-recovery.md
├── scripts/
│ ├── bootstrap.sh
│ └── backup.sh
├── .pre-commit-config.yaml
├── .gitignore
├── Makefile
└── README.mdMakefile for Common Operations
.PHONY: help lint deploy-staging deploy-production terraform-plan terraform-apply
SHELL := /bin/bash
ANSIBLE_DIR := ansible
TERRAFORM_DIR := terraform
help: ## Show this help
@grep -E '^[a-zA-Z_-]+:.*?## .*$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $1, $2}'
# Ansible targets
lint: ## Lint Ansible playbooks
cd $(ANSIBLE_DIR) && ansible-lint playbooks/
deploy-staging: ## Deploy to staging environment
cd $(ANSIBLE_DIR) && ansible-playbook -i inventory/staging playbooks/site.yml --vault-password-file ~/.vault_password
deploy-production: ## Deploy to production environment
cd $(ANSIBLE_DIR) && ansible-playbook -i inventory/production playbooks/site.yml --vault-password-file ~/.vault_password
deploy-app: ## Deploy specific app (usage: make deploy-app APP=myapp ENV=staging)
cd $(ANSIBLE_DIR) && ansible-playbook -i inventory/$(ENV) playbooks/deploy-app.yml -e "app_name=$(APP)" --vault-password-file ~/.vault_password
# Terraform targets
terraform-init: ## Initialize Terraform (usage: make terraform-init ENV=staging)
cd $(TERRAFORM_DIR)/environments/$(ENV) && terraform init
terraform-plan: ## Plan Terraform changes (usage: make terraform-plan ENV=staging)
cd $(TERRAFORM_DIR)/environments/$(ENV) && terraform plan
terraform-apply: ## Apply Terraform changes (usage: make terraform-apply ENV=staging)
cd $(TERRAFORM_DIR)/environments/$(ENV) && terraform apply
# Utility targets
vault-edit: ## Edit Ansible vault file (usage: make vault-edit FILE=playbooks/vars/myapp.yml)
cd $(ANSIBLE_DIR) && ansible-vault edit $(FILE)
vault-encrypt: ## Encrypt a string for Ansible vault
cd $(ANSIBLE_DIR) && ansible-vault encrypt_string --vault-password-file ~/.vault_password
ssh-staging: ## SSH to staging server
ssh -i ~/.ssh/id_ed25519 deploy@staging.example.com
ssh-production: ## SSH to production server
ssh -i ~/.ssh/id_ed25519 deploy@production.example.comPre-commit Hooks
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
args: ['--unsafe']
- id: check-added-large-files
- repo: https://github.com/ansible/ansible-lint
rev: v6.22.0
hooks:
- id: ansible-lint
files: ansible/
args: ['ansible/playbooks/']
- repo: https://github.com/antonbabenko/pre-commit-terraform
rev: v1.86.0
hooks:
- id: terraform_fmt
- id: terraform_validate
- id: terraform_tflint
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.1
hooks:
- id: gitleaksA Real GitOps Workflow
Here's how a typical GitOps workflow looks:
1. Make infrastructure changes locally
# Edit an Ansible role
vim ansible/roles/common/tasks/main.yml
# Test locally
make lint2. Commit and push
git add -A
git commit -m "feat: add log rotation configuration"
git push origin develop3. Automatic staging deployment
GitHub Actions triggers on push to develop:
- Runs ansible-lint
- Deploys to staging servers
- Sends Discord notification
4. Verify and promote
# Check staging
ssh deploy@staging.example.com
# Merge to main for production
git checkout main
git merge develop
git push origin main5. Automatic production deployment
GitHub Actions triggers on push to main → Deploys to production → Sends notification
Tips for Effective GitOps
- Start with staging. Never deploy directly to production without testing.
- Use branch protection. Require reviews for main branch changes.
- Keep secrets encrypted. Use Ansible Vault or external secret managers.
- Document everything. Future you will thank present you.
- Test rollbacks. Know your recovery procedure before you need it.
- Monitor deployments. Integrate with your alerting from Part 4.
Quick Reference: GitOps Prompts
| Need | Prompt Pattern |
|---|---|
| New role | "Create Ansible role for [service] that [requirements]" |
| Playbook | "Generate playbook to [task] on [hosts] with [conditions]" |
| CI/CD | "Create [GitHub Actions/GitLab CI] workflow for [trigger] to [action]" |
| Secrets | "Add Ansible Vault encrypted variables for [service]" |
| Rollback | "Add rollback capability to deployment if [condition]" |
What's Next
You now have a complete GitOps workflow: version-controlled infrastructure, automated deployments, and proper CI/CD pipelines. In Part 6, we'll cover Database Deployment & Management—deploying PostgreSQL, MySQL/MariaDB, and Redis with Claude Code.
Continue to Part 6