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 server — DigitalOcean droplet, running Jenkins in Docker
- Ansible server — DigitalOcean droplet, the control node
- EC2 instances (×2) — the managed nodes being configured
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) |
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
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 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.
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.
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.