Part 5 of 10

    Infrastructure as Code — From Zero to GitOps

    Version-control your infrastructure and automate deployments. Generate Ansible playbooks, CI/CD pipelines, and tie everything into a GitOps workflow.

    Ansible
    GitHub Actions
    GitOps

    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.

    1

    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

    Install Ansible
    # Ubuntu/Debian
    sudo apt update
    sudo apt install -y ansible
    
    # Or via pip for latest version
    pip install ansible --user
    
    # Verify
    ansible --version
    2

    Generating Ansible Playbooks

    Ansible automates server configuration through declarative YAML playbooks. Instead of writing them from scratch, let Claude Code generate them:

    Prompt to Claude Code
    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

    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.yml

    ansible.cfg

    ansible.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:%%p

    Inventory File

    inventory/production
    [webservers]
    web1.example.com
    web2.example.com
    
    [databases]
    db1.example.com
    
    [monitoring]
    monitor.example.com
    
    [all:vars]
    ansible_python_interpreter=/usr/bin/python3

    Group Variables

    inventory/group_vars/all.yml
    # 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

    playbooks/site.yml
    ---
    - 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
        - monitoring

    Docker Role Tasks

    roles/docker/tasks/main.yml
    ---
    - 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: yes

    Docker Daemon Template

    roles/docker/templates/daemon.json.j2
    {
      "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
    }
    3

    Application Deployment Playbooks

    Now let's create playbooks for deploying applications:

    Prompt to Claude Code
    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 URL

    Deploy Compose Role

    roles/deploy-compose/tasks/main.yml
    ---
    - 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

    roles/deploy-compose/templates/env.j2
    # Ansible managed - {{ ansible_date_time.iso8601 }}
    {% for key, value in env_vars.items() %}
    {{ key }}={{ value }}
    {% endfor %}

    Application Variables (Vault Encrypted)

    playbooks/vars/myapp.yml
    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: production
    4

    CI/CD Pipeline Generation

    Now let's wire this into automated deployments:

    Prompt to Claude Code
    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

    .github/workflows/deploy.yml
    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_password

    GitLab CI (for self-hosted)

    .gitlab-ci.yml
    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_password
    5

    Complete Repository Structure

    Here's the complete repository structure for GitOps infrastructure management:

    Repository structure
    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.md

    Makefile for Common Operations

    Makefile
    .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.com

    Pre-commit Hooks

    .pre-commit-config.yaml
    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: gitleaks
    6

    A Real GitOps Workflow

    Here's how a typical GitOps workflow looks:

    1. Make infrastructure changes locally

    Local development
    # Edit an Ansible role
    vim ansible/roles/common/tasks/main.yml
    
    # Test locally
    make lint

    2. Commit and push

    Git workflow
    git add -A
    git commit -m "feat: add log rotation configuration"
    git push origin develop

    3. Automatic staging deployment

    GitHub Actions triggers on push to develop:

    • Runs ansible-lint
    • Deploys to staging servers
    • Sends Discord notification

    4. Verify and promote

    Promote to production
    # Check staging
    ssh deploy@staging.example.com
    
    # Merge to main for production
    git checkout main
    git merge develop
    git push origin main

    5. Automatic production deployment

    GitHub Actions triggers on push to main → Deploys to production → Sends notification

    7

    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

    NeedPrompt 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