This project brought two tools together that I'd been using separately: Jenkins for CI/CD pipelines and Ansible for server configuration. The goal was to trigger an Ansible playbook from a Jenkins pipeline — not by installing Ansible on the Jenkins server, but by having Jenkins reach out to a dedicated Ansible control node and execute the playbook remotely.

By the end, a single pipeline run was copying all necessary files to the Ansible server, preparing it, and configuring two EC2 instances with Docker and Docker Compose — without touching any of them manually.

What I had going in: Jenkins pipelines, Ansible playbooks, EC2 instances, dynamic inventory. What this project added: the SSH Pipeline Steps plugin, the sshagent and sshCommand patterns for remote execution from Jenkins, credential handling for multiple servers in a single pipeline, and a Groovy string interpolation security issue I'd hit before and resolved properly this time.

The Architecture

Previous projects installed tools directly in the Jenkins container — kubectl, terraform, the Docker CLI. Ansible is different. The common practice is a dedicated Ansible control node: a separate server where Ansible is installed, and from which it connects to and configures managed nodes.

So the setup here is three separate machines:

Jenkins doesn't run Ansible directly. It copies the necessary files to the Ansible server, then triggers the playbook execution there remotely. The Ansible server does the actual work.

Setting Up the Ansible Server

A fresh Ubuntu droplet on DigitalOcean. SSH in and install:

apt update
apt install ansible -y
apt install python3-boto3

boto3 is required because the playbook uses a dynamic inventory — instead of hardcoding EC2 IP addresses, the AWS EC2 inventory plugin fetches them from AWS at runtime. That plugin needs AWS credentials and the boto3 Python library to authenticate.

AWS credentials go in the default location:

mkdir ~/.aws
nano ~/.aws/credentials
# paste your access key and secret

That's all the manual setup needed on the Ansible server. Everything else — copying the playbook, inventory, config, and SSH key — gets handled by the pipeline.

The Ansible Files

Three files live in an ansible/ directory in the Java Maven app repo so Jenkins has access to them and can copy them to the Ansible server.

Dynamic Inventory

Instead of a static hosts file, the inventory uses the AWS EC2 plugin to fetch instances dynamically:

# inventory_aws_ec2.yaml
---
plugin: aws_ec2
regions:
  - us-east-1
keyed_groups:
  - key: tags
    prefix: "tag"
  - key: instance_type
    prefix: instance_type

Any EC2 instance in us-east-1 gets picked up automatically. No IP addresses to maintain.

Ansible Configuration

# ansible.cfg
[defaults]
host_key_checking = False
inventory = inventory_aws_ec2.yaml

enable_plugins = aws_ec2

remote_user = ec2-user
private_key_file = ~/ssh-key.pem

host_key_checking = False skips the interactive host key confirmation — necessary for automated execution. private_key_file = ~/ssh-key.pem points to where the pipeline will copy the EC2 SSH key. The file doesn't exist on the Ansible server yet when this config is written — the pipeline puts it there.

The Playbook

Installs Docker and Docker Compose on all EC2 instances in the inventory:

# my-playbook.yaml
- name: Install Docker
  hosts: all
  become: yes
  tasks:
    - name: Install Docker
      yum:
        name: docker
        update_cache: yes
        state: present
    - name: Start docker daemon
      systemd:
        name: docker
        state: started

- name: Install Docker-compose
  hosts: all
  tasks:
    - name: Create docker-compose directory
      file:
        path: ~/.docker/cli-plugins
        state: directory
    - name: Get architecture of remote machine
      shell: uname -m
      register: remote_arch
    - name: Install docker-compose
      get_url:
        url: "https://github.com/docker/compose/releases/latest/download/docker-compose-linux-{{ remote_arch.stdout }}"
        dest: ~/.docker/cli-plugins/docker-compose
        mode: +x

The architecture detection step is worth noting — uname -m runs on the remote EC2 instance and its output gets registered as remote_arch. That value then gets substituted into the Docker Compose download URL so the right binary gets fetched for whatever architecture the instance is running on. No hardcoding x86_64 and hoping for the best.

The EC2 Instances

Two Amazon Linux 2 instances launched in EC2. The only important configuration step: create a new key pair called ansible-jenkins and download the .pem file. Ansible needs this key to SSH into the instances and configure them. Everything else on the instances — Docker, Docker Compose — will be handled by the playbook. No manual SSH required.

The Jenkins Credentials

Three credentials needed in Jenkins before writing the pipeline:

Credential ID Type Purpose
ansible-server-key SSH username with private key Connect to the Ansible DigitalOcean droplet
ec2-server-key SSH username with private key The .pem file for the EC2 instances (copied to Ansible server by pipeline)
OpenSSH Key Format Note

Recent versions of OpenSSH generate keys in the new OpenSSH format (-----BEGIN OPENSSH PRIVATE KEY-----). Jenkins doesn't support this format. If your key starts with that header, convert it to PEM first:

ssh-keygen -p -m PEM -f ~/.ssh/your-key

The Jenkinsfile

Two stages. First: copy all necessary files to the Ansible server. Second: execute the playbook remotely.

pipeline {
    agent any
    environment {
        ANSIBLE_SERVER = "67.205.186.70"
    }
    stages {
        stage("copy files to ansible server") {
            steps {
                script {
                    echo "copying all necessary files to ansible control node"
                    sshagent(['ansible-server-key']) {
                        sh "scp -o StrictHostKeyChecking=no ansible/* root@${ANSIBLE_SERVER}:/root"

                        withCredentials([sshUserPrivateKey(credentialsId: 'ec2-server-key', keyFileVariable: 'keyfile', usernameVariable: 'user')]) {
                            sh 'scp $keyfile root@$ANSIBLE_SERVER:/root/ssh-key.pem'
                        }
                    }
                }
            }
        }
        stage("execute ansible playbook") {
            steps {
                script {
                    echo "calling ansible playbook to configure ec2 instances"
                    def remote = [:]
                    remote.name = "ansible-server"
                    remote.host = ANSIBLE_SERVER
                    remote.allowAnyHosts = true

                    withCredentials([sshUserPrivateKey(credentialsId: 'ansible-server-key', keyFileVariable: 'keyfile', usernameVariable: 'user')]) {
                        remote.user = user
                        remote.identityFile = keyfile
                        sshScript remote: remote, script: "prepare-ansible-server.sh"
                        sshCommand remote: remote, command: "ansible-playbook my-playbook.yaml"
                    }
                }
            }
        }
    }
}

Stage 1 — Copy Files to Ansible Server

sshagent(['ansible-server-key']) opens an SSH agent session using the Ansible server credential. Inside that block, scp copies everything in the ansible/ directory to the Ansible server's root home directory.

The EC2 private key needs separate handling. It's stored in Jenkins as ec2-server-key and needs to land on the Ansible server as ~/ssh-key.pem — exactly where ansible.cfg says to look for it. A nested withCredentials block extracts the key file path and copies it across with a second scp command.

Note the single quotes on the second scp line. This is the Groovy string interpolation security fix — using double quotes with $variable would expose the secret on the command line through Groovy's interpolation. Single quotes tell Groovy to leave variable substitution to the shell, keeping the key contents out of the command line history.

Stage 2 — Execute Ansible Playbook

This stage uses the SSH Pipeline Steps plugin, which isn't installed by default. It enables remote command execution from Jenkins.

A remote object is built as a Groovy map with the Ansible server's connection details:

def remote = [:]
remote.name = "ansible-server"
remote.host = ANSIBLE_SERVER
remote.allowAnyHosts = true

withCredentials then populates remote.user and remote.identityFile from the stored credential. With the remote object fully configured, two operations run:

sshScript remote: remote, script: "prepare-ansible-server.sh"
sshCommand remote: remote, command: "ansible-playbook my-playbook.yaml"

sshScript executes the preparation shell script on the Ansible server. sshCommand runs the playbook. The playbook picks up its configuration from ansible.cfg, finds the EC2 instances via the dynamic inventory, and connects to them using the .pem file that stage 1 already copied into place.

The Preparation Script

#!/usr/bin/env bash

apt update
apt install ansible -y
apt install python3-boto3

This runs before every playbook execution. If Ansible is already installed nothing changes, but it ensures the server is always in a known state regardless of whether it was provisioned fresh or already configured. Idempotent by design.

Putting It Together

The pipeline execution order:

Jenkins checkout
  → scp ansible/* → Ansible server ~/
  → scp ssh-key.pem → Ansible server ~/ssh-key.pem
  → sshScript: prepare-ansible-server.sh
  → sshCommand: ansible-playbook my-playbook.yaml
      → aws_ec2 plugin fetches EC2 IPs from AWS
      → Ansible SSHes to EC2 instances using ssh-key.pem
      → Installs Docker and starts daemon
      → Installs Docker Compose

The console output shows the playbook recap at the end — both EC2 instances with changed status confirming Docker and Docker Compose were installed. Neither instance was touched manually.

What I Learned

Jenkins doesn't need to run every tool directly

The pattern here — Jenkins coordinates, a dedicated server executes — is cleaner than cramming every tool into the Jenkins container. Ansible has its own dependencies, its own credential model, its own way of connecting to managed nodes. Keeping it on a separate server respects that. Jenkins just needs to know how to talk to it.

The remote object pattern is reusable

The Groovy map approach for defining a remote server — host, user, identity file, allowAnyHosts — is the same regardless of what command you're running on it. Once you have that pattern, triggering anything on any remote server from a pipeline is straightforward.

Single quotes vs double quotes in Groovy is not optional

This is the same Groovy string interpolation warning I'd hit in previous pipelines and fixed without fully understanding why. Using double quotes with $variable inside an sh step causes Groovy to resolve the variable before passing the string to the shell — meaning secrets get expanded into the command text and can appear in logs or command history. Single quotes leave the $variable for the shell to resolve, where it stays out of Groovy's hands. For anything involving credentials, it's always single quotes.

Dynamic inventory decouples the playbook from infrastructure

Hard-coding IP addresses in a hosts file means updating the playbook every time an EC2 instance is replaced or scaled. The aws_ec2 plugin eliminates that entirely — any instance in the region gets picked up automatically. The playbook doesn't know or care what the IPs are.

Production Considerations

Hardcoded Ansible server IP. ANSIBLE_SERVER is set as an environment variable in the Jenkinsfile, which is better than repeating it inline, but it's still a hardcoded value. In a real setup this would come from a parameter or be stored in Jenkins as a credential.

AWS credentials on the Ansible server are manual. The pipeline automates everything except placing the ~/.aws/credentials file on the Ansible server. That step is still manual. You could automate it by passing the credentials from Jenkins via withCredentials and writing the file in the sshScript step.

prepare-ansible-server.sh runs on every build. For a stable, long-lived Ansible server this is unnecessary overhead. For ephemeral infrastructure where the Ansible server might be recreated, it's the right call. The tradeoff depends on how you manage the server.

No tagging on EC2 instances. The dynamic inventory picks up all instances in the region. In a real environment you'd filter by tag so the playbook only targets the intended servers — not everything running in your account.

Reflection

What this project was: The first one where Jenkins acted as a coordinator rather than the executor. Every previous pipeline ran tools directly on the Jenkins server. This one Jenkins coordinated two other systems — the Ansible control node and the EC2 managed nodes — without doing any of the configuration work itself. That's a different mental model and a more accurate picture of how pipelines work at scale.

What worked: Having all three Ansible files in the application repo alongside the Jenkinsfile. Everything the pipeline needs is version-controlled in one place. The Ansible server is essentially stateless — the pipeline puts everything it needs there before running.

What I'd do differently: Add EC2 instance tags from the start and filter the dynamic inventory by tag. Running the playbook against every instance in the region works fine in a demo account, but is one misfire away from configuring something it shouldn't in a shared environment.

Cost: DigitalOcean droplet for Ansible server + two EC2 instances. All deleted after the project.

What's Next

With Ansible and Jenkins integrated, the next step is Prometheus and Grafana — monitoring and observability for the infrastructure and applications built throughout these projects. Rather than reacting to failures after the fact the way the Python monitoring script did, the goal is proactive visibility into what's happening across the cluster in real time.

GitLab Repository →