For this project I took an existing Jenkins CI/CD pipeline that deployed a Java Maven application to a manually-created EC2 instance and replaced that manual step entirely with Terraform. By the end, the pipeline handled everything: building the application, building and pushing the Docker image, provisioning the server, and deploying the containers — no AWS console interaction required.

The starting point was a pipeline I had already built. It built a JAR, built a Docker image, pushed it to a private Docker Hub repo, and used the SSH Agent plugin to copy a Docker Compose file and an execution script to a remote EC2 instance before running the containers. It worked. But the EC2 instance had been created manually, and the server's public IP was hardcoded directly in the Jenkinsfile. Every time you needed a new server, you'd go to AWS, create it, grab the IP, paste it in, and run the pipeline. This project automated that last piece.

The Four Steps

Before touching any code, the work broke down into four distinct steps:

  1. Create an SSH key pair for the EC2 instance and give it to Jenkins
  2. Install Terraform inside the Jenkins container
  3. Add Terraform configuration files to the application repository
  4. Update the Jenkinsfile to provision and deploy in sequence

Each one had to be done in order. You can't write the Jenkinsfile stages until Terraform is installed. You can't run Terraform without credentials. You can't deploy without a server. The dependencies were linear.

Step 1: The Key Pair Problem

Whenever you provision an EC2 instance you need to associate an SSH key pair with it so you can connect to it afterward. In previous Terraform work I had handled this by defining an aws_key_pair resource in the config — the public key from my local machine gets uploaded to AWS, and the private key stays with me.

That approach doesn't translate cleanly to a pipeline. The pipeline runs inside a Jenkins container. Getting a public key out of that container and into a Terraform resource block isn't impossible, but it adds complexity that isn't necessary. The simpler path: create the key pair manually in the AWS console, download the .pem private key, and hand it to Jenkins as a credential.

Inside the Jenkins multibranch pipeline project, I created a new SSH credential called server-ssh-key. Username is ec2-user — the default user on Amazon Linux EC2 instances, which is what you use when connecting via SSH. The private key is the contents of the .pem file pasted directly in. Jenkins stores it securely, and the pipeline can reference it by credential ID when it needs to SSH into the server.

The Terraform config then just references the key pair by name instead of creating it:

key_name = "myapp-key-pair"

AWS already has the key. Terraform points to it. Jenkins holds the private half.

Step 2: Installing Terraform in the Jenkins Container

The pipeline stages execute inside the Jenkins container. For terraform init and terraform apply to work in a pipeline step, the Terraform binary needs to be inside that container — not just on the host machine.

Jenkins has Terraform plugins available. I installed it directly instead. Plugins are often limiting in what commands and flags they expose; having the binary available means full CLI access, which matters when you're scripting complex provisioning logic.

The process: SSH into the DigitalOcean droplet hosting Jenkins, enter the container as root, and install from HashiCorp's official Debian repository.

docker exec -u 0 -it <container_id> bash

Since you're logged in as root inside the container, sudo is unnecessary — remove it from any commands copied from the documentation. Once the installation completes:

terraform -v

Terraform available inside the Jenkins container. Step two done.

Step 3: Terraform Configuration in the Application Repository

The Terraform configuration files live inside the application repository, inside a terraform/ folder. This is the right place for them — everything the application needs to run, including its infrastructure, should be version-controlled alongside the code. When Jenkins checks out the repository to run a build, it gets the Terraform config too.

The folder ended up with three files:

terraform/
├── main.tf
├── variables.tf
└── entry-script.sh

main.tf

The core infrastructure: a VPC, a subnet, an internet gateway, a default route table for public access, a default security group, and the EC2 instance itself. The security group allows SSH from a personal IP address and port 8080 for the application. The instance is Amazon Linux, t3.micro, using the latest AMI fetched via a data source. One notable setting on the instance resource is user_data_replace_on_change = true — if the entry-script.sh changes, Terraform will replace the instance rather than leaving the old initialization script in place.

One output block matters a lot and becomes important later:

output "ec2-public_ip" {
  value = aws_instance.myapp-server.public_ip
}

variables.tf

Variables were extracted from main.tf into their own file — standard Terraform practice. Most of them got default values so the pipeline doesn't have to supply everything explicitly: VPC and subnet CIDR blocks, t3.micro instance type, us-east-2 region, and dev as the default env_prefix. Both my_ip and jenkins_ip have hardcoded defaults — personal machine IP and Jenkins server IP respectively — so SSH access from both is baked in without needing to pass them at runtime.

The public_key_location variable from earlier work was removed entirely since we're no longer creating the key pair from Terraform.

entry-script.sh

The user_data script that runs on the instance during initialization. It installs Docker via yum (Amazon Linux), starts the Docker daemon, adds ec2-user to the Docker group so Docker commands don't require sudo, and installs Docker Compose by downloading the binary directly from GitHub releases:

#!/bin/bash
sudo yum update -y && sudo yum install -y docker
sudo systemctl start docker
sudo usermod -aG docker ec2-user

# install docker-compose
sudo curl -SL "https://github.com/docker/compose/releases/download/v5.1.2/docker-compose-linux-x86_64" \
  -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose

This runs after the instance is created. That timing matters, and it causes a problem addressed in the Jenkinsfile.

Step 4: Updating the Jenkinsfile

This is where everything connects. Four distinct problems had to be solved in the Jenkinsfile before the pipeline could run cleanly. The file also loads a Jenkins shared library from GitLab at the top, which is where the buildJar(), buildImage(), dockerLogin(), and dockerPush() functions are defined — keeping the Jenkinsfile itself clean and reusable.

Running Terraform from the Right Directory

Terraform commands have to execute from inside the directory where the configuration files live. The Jenkinsfile handles this with Jenkins' dir() block:

stage('provision server') {
    environment {
        AWS_ACCESS_KEY_ID     = credentials('jenkins_aws_access_key_id')
        AWS_SECRET_ACCESS_KEY = credentials('jenkins-aws_secret_access_key')
        TF_VAR_env_prefix     = 'test'
    }
    steps {
        script {
            dir('terraform') {
                sh "terraform init"
                sh "terraform apply --auto-approve"
                EC2_PUBLIC_IP = sh(
                    script: "terraform output ec2-public_ip",
                    returnStdout: true
                ).trim()
            }
        }
    }
}

--auto-approve is required — there's no interactive confirmation in an automated pipeline.

Authenticating Terraform with AWS

Terraform's AWS provider needs credentials to create resources. They shouldn't be hardcoded in main.tf. The solution: set them as environment variables scoped to the provision server stage, pulling from credentials already stored in Jenkins. The TF_VAR_ prefix is how you pass values into Terraform variables from outside the config — TF_VAR_env_prefix = 'test' overrides the env_prefix variable at runtime, tagging all provisioned resources with test instead of the default dev. Any Terraform variable can be overridden this way — region, instance type, availability zone, whatever needs to vary per environment or pipeline run.

Getting the Dynamic IP Address

The hardcoded IP address in the deploy stage was the whole reason for this project. After Terraform provisions the instance, its public IP needs to be passed to the deploy stage dynamically. The output block in main.tf exposes it; terraform output retrieves it and returnStdout: true captures the result into a Jenkinsfile variable — all inside the same dir('terraform') block so it runs from the right directory:

EC2_PUBLIC_IP = sh(
    script: "terraform output ec2-public_ip",
    returnStdout: true
).trim()

.trim() strips any trailing whitespace. From that point forward, ${EC2_PUBLIC_IP} is available in any subsequent stage. The same pattern works for any other output you define — VPC IDs, subnet IDs, load balancer DNS names, anything Terraform knows about the resources it created.

The Initialization Timing Problem

Terraform considers its job done as soon as AWS marks the instance active. But the entry-script.sh commands — installing Docker, installing Docker Compose, adding the user to the Docker group — run during the initialization phase after that. If the deploy stage executes immediately, Docker isn't installed yet and the pipeline fails.

The fix is straightforward:

stage('deploy') {
    environment {
        DOCKER_CREDS = credentials('docker-hub-creds')
    }
    steps {
        script {
            echo "waiting for EC2 server to initialize"
            sleep(time: 90, unit: "SECONDS")

            echo 'deploying docker image to EC2...'
            echo "${EC2_PUBLIC_IP}"

            def shellCmd = "bash ./server-cmds.sh ${IMAGE_NAME} ${DOCKER_CREDS_USR} ${DOCKER_CREDS_PSW}"
            def ec2Instance = "ec2-user@${EC2_PUBLIC_IP}"

            sshagent(['server-ssh-key']) {
                sh "scp -o StrictHostKeyChecking=no server-cmds.sh ${ec2Instance}:/home/ec2-user"
                sh "scp -o StrictHostKeyChecking=no docker-compose.yaml ${ec2Instance}:/home/ec2-user"
                sh "ssh -o StrictHostKeyChecking=no ${ec2Instance} ${shellCmd}"
            }
        }
    }
}

90 seconds gives the initialization process time to complete before any remote commands run. It's not elegant — on subsequent runs where the instance already exists, that 90 seconds is wasted. A better version would check initialization state conditionally and only sleep on first provision. But for a working pipeline it solves the problem cleanly.

Part 3: The Two Problems That Remained

With the pipeline running, two things still broke on the first real execution.

Jenkins Can't SSH into the EC2 Instance

The security group allowed SSH from a personal IP address. Jenkins has its own IP and was never added to the allowed list — so when the deploy stage tried to connect, it was blocked.

The fix: add a jenkins_ip variable to variables.tf and expand the security group's SSH ingress rule to accept both IPs:

ingress {
  from_port   = 22
  to_port     = 22
  protocol    = "tcp"
  cidr_blocks = [var.my_ip, var.jenkins_ip]
}

If the Jenkins server IP is static, set it as the default value and leave it. If it's dynamic, it can be overridden at pipeline runtime via TF_VAR_jenkins_ip the same way environment variables are set.

Docker Login Has to Run on the EC2 Instance, Not Jenkins

The deploy stage runs docker-compose on the EC2 instance, which needs to pull images from a private Docker Hub repository. The Docker login that was already in the pipeline authenticated the Jenkins server — for pushing images. The EC2 instance is a different machine and has never authenticated with Docker Hub.

The fix happens in two places.

In server-cmds.sh, Docker login runs before the compose command. Rather than referencing positional parameters inline, the script exports them as named variables first for clarity:

#!/usr/bin/env bash

export IMAGE=$1
export DOCKER_USER=$2
export DOCKER_PWD=$3
echo $DOCKER_PWD | docker login -u $DOCKER_USER --password-stdin
docker-compose -f docker-compose.yaml up --detach
echo "success"

The docker-compose.yaml references ${IMAGE} directly as an environment variable — no hardcoded image name anywhere in the compose file.

In the Jenkinsfile, Docker Hub credentials are scoped to the deploy stage's own environment {} block:

DOCKER_CREDS = credentials('docker-hub-creds')

When Jenkins parses a username/password credential type, it automatically creates two derived environment variables:

Name the credential ABC and you get ABC_USR and ABC_PSW. These get passed as arguments when calling the script:

def shellCmd = "bash ./server-cmds.sh ${IMAGE_NAME} ${DOCKER_CREDS_USR} ${DOCKER_CREDS_PSW}"

One Last Thing: .pem File Permissions

Before connecting to the instance manually to verify the result, the .pem file permissions need to be locked down:

chmod 400 myapp-key-pair.pem

SSH will refuse to use a key file that's too permissive. AWS enforces this as a security requirement. It's easy to forget if you downloaded the file and haven't connected with it before.

The Pipeline Running End to End

With all of it in place, the full pipeline execution went like this:

  1. Build — JAR compiled, Docker image built, Docker Hub login succeeded, image pushed to the private repo
  2. Provision Server — terraform init ran and initialized the AWS provider; terraform apply created the VPC, subnet, internet gateway, route table, security group (with both personal IP and Jenkins IP), and EC2 instance
  3. Deploy — Pipeline waited 90 seconds for initialization, then copied server-cmds.sh and docker-compose.yaml to the instance via SCP, SSH'd in, ran Docker login against Docker Hub, then docker-compose up --detach pulled both images and started the containers
  4. Verification — SSH'd into the instance, ran docker ps, confirmed both the Java Maven app container and the Postgres container were running

No manual AWS console steps. No hardcoded IP. The pipeline provisioned and deployed to a server that didn't exist when the build started.

What I Learned

IaC belongs in the application repository. Keeping Terraform configuration alongside the application code means the CI/CD pipeline gets both in the same checkout. Infrastructure and application are versioned together, deployed together, and treated as a single unit. Separating them adds coordination overhead and creates drift.

Plugins vs. direct installation. Terraform plugins for Jenkins exist, but installing the binary directly inside the container is more flexible. Plugins abstract away the CLI, which means you can only do what the plugin exposes. With the binary, you have full access to every flag, every command, and every edge case.

terraform output is the bridge between provisioning and deployment. The dynamic IP problem looks hard until you realize Terraform already knows the answer — you just need to ask it. Defining outputs in your Terraform config and retrieving them with returnStdout: true turns any resource attribute into a Jenkinsfile environment variable. The same pattern works for anything: subnet IDs, security group IDs, load balancer DNS names.

The initialization timing issue is a class of problem, not a one-off. Any time you provision infrastructure and immediately run commands against it in the same pipeline, there's a window between "infrastructure created" and "infrastructure ready." sleep is the blunt solution. The right long-term answer is a conditional check or a readiness poll — but knowing the problem exists is the first step.

Credentials flow through layers. AWS credentials go into Terraform via environment variables. Docker credentials travel from Jenkins into the remote EC2 instance as script arguments, where server-cmds.sh exports them as named variables before passing them to docker login. Jenkins' auto-splitting of username/password credentials into _USR and _PSW variables is a clean pattern for passing secrets without concatenating them yourself.

GitLab Repository →