Architecture and operating assumptions

This guide uses a dedicated Jenkins controller on EC2 and a separate production EC2 host. Jenkins runs tests, builds an immutable image, pushes it to Amazon ECR, and deploys the image by SSH. Nginx on the production host is the public entry point; containers bind only to loopback ports.

For a small controller, start Jenkins on a t3.medium with at least 50 GB of gp3 storage. The production instance should be sized from application CPU, memory and traffic measurements rather than copied from the controller. Do not expose Docker or Jenkins agent ports publicly.

Developer push
    │
    ▼
GitHub repository ── webhook ──► Jenkins on EC2 :8080
                                      │
                                      ▼
                               pytest + flake8 + black
                                      │
                                      ▼
                               Docker build + image tags
                                      │
                                      ▼
                               Amazon ECR private repository
                                      │
                                      ▼
                         Production EC2 pulls immutable image
                                      │
                                      ▼
                  Flask containers on 127.0.0.1:5000 / :5001
                                      │
                                      ▼
                         Nginx reverse proxy :80 / :443

AWS baseline: network and IAM

Use separate security groups for Jenkins and the application. Restrict SSH to a bastion or your fixed administrative CIDR. Restrict Jenkins port 8080 to a VPN, a fixed administrator CIDR, or place Jenkins behind an authenticated reverse proxy. The production security group should accept only 80 and 443 from the internet; port 8080 is not a production application port.

Attach an instance profile with AmazonEC2ContainerRegistryPowerUser to the Jenkins host for this reference setup. In a stricter environment, replace it with a repository-scoped policy that grants only the ECR actions used by the pipeline. Attach ECR read permissions to the production host separately; never copy AWS access keys into Jenkins credentials when an instance profile is available.

export AWS_REGION=ap-south-1
export VPC_ID=vpc-0123456789abcdef0
export SUBNET_ID=subnet-0123456789abcdef0
export ADMIN_CIDR=203.0.113.10/32
export AMI_ID=ami-0123456789abcdef0
export KEY_NAME=platform-admin

JENKINS_SG_ID=$(aws ec2 create-security-group --group-name jenkins-ci-sg --description 'Jenkins controller' --vpc-id "$VPC_ID" --region "$AWS_REGION" --query GroupId --output text)
APP_SG_ID=$(aws ec2 create-security-group --group-name flask-prod-sg --description 'Flask production host' --vpc-id "$VPC_ID" --region "$AWS_REGION" --query GroupId --output text)

aws ec2 authorize-security-group-ingress --group-id "$JENKINS_SG_ID" --ip-permissions "[\"{\"IpProtocol\":\"tcp\",\"FromPort\":22,\"ToPort\":22,\"IpRanges\":[{\"CidrIp\":\"$ADMIN_CIDR\"}]}, {\"IpProtocol\":\"tcp\",\"FromPort\":8080,\"ToPort\":8080,\"IpRanges\":[{\"CidrIp\":\"$ADMIN_CIDR\"}]}]" --region "$AWS_REGION"
aws ec2 authorize-security-group-ingress --group-id "$APP_SG_ID" --ip-permissions "[\"{\"IpProtocol\":\"tcp\",\"FromPort\":22,\"ToPort\":22,\"IpRanges\":[{\"CidrIp\":\"$ADMIN_CIDR\"}]}, {\"IpProtocol\":\"tcp\",\"FromPort\":80,\"ToPort\":80,\"IpRanges\":[{\"CidrIp\":\"0.0.0.0/0\"}]}, {\"IpProtocol\":\"tcp\",\"FromPort\":443,\"ToPort\":443,\"IpRanges\":[{\"CidrIp\":\"0.0.0.0/0\"}]}]" --region "$AWS_REGION"

aws iam create-role --role-name JenkinsEcrRole --assume-role-policy-document file://trust-ec2.json
aws iam attach-role-policy --role-name JenkinsEcrRole --policy-arn arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryPowerUser
aws iam create-instance-profile --instance-profile-name JenkinsEcrProfile
aws iam add-role-to-instance-profile --instance-profile-name JenkinsEcrProfile --role-name JenkinsEcrRole

aws ec2 run-instances --image-id "$AMI_ID" --instance-type t3.medium --key-name "$KEY_NAME" --subnet-id "$SUBNET_ID" --security-group-ids "$JENKINS_SG_ID" --iam-instance-profile Name=JenkinsEcrProfile --block-device-mappings 'DeviceName=/dev/sda1,Ebs={VolumeSize=50,VolumeType=gp3,DeleteOnTermination=true}' --region "$AWS_REGION"

Repository layout

Keep application, test, deployment and proxy concerns visible in the repository. The deployment script below is deliberately committed and reviewed; only secrets and host-specific values remain outside the repository.

flask-ci-cd/
├── app/
│   ├── __init__.py
│   └── routes.py
├── tests/
│   └── test_routes.py
├── deploy/
│   └── deploy.sh
├── nginx/
│   └── default.conf
├── app.py
├── requirements.txt
├── Dockerfile
├── .dockerignore
├── Jenkinsfile
└── docker-compose.prod.yml

Provision Jenkins, Docker and the AWS CLI

Run this on an Ubuntu 22.04 or 24.04 Jenkins controller. It installs Java 17 as requested; confirm the Java baseline supported by the Jenkins LTS version you select, because current Jenkins releases may prefer a newer Java runtime. After changing Docker group membership, restart Jenkins so the service receives the new group list.

Membership of the docker group is effectively privileged access to the Docker daemon. Use a dedicated controller, restrict who can configure jobs, and prefer ephemeral agents for untrusted repositories.

#!/usr/bin/env bash
set -euo pipefail

sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg lsb-release fontconfig openjdk-17-jre nginx
java -version

sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo systemctl enable --now docker

curl -fsSL https://pkg.jenkins.io/debian-stable/jenkins.io-2026.key | sudo tee /etc/apt/keyrings/jenkins-keyring.asc > /dev/null
echo "deb [signed-by=/etc/apt/keyrings/jenkins-keyring.asc] https://pkg.jenkins.io/debian-stable binary/" | sudo tee /etc/apt/sources.list.d/jenkins.list > /dev/null
sudo apt-get update
sudo apt-get install -y jenkins
sudo usermod -aG docker jenkins
sudo systemctl restart jenkins

curl -sS https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip -o /tmp/awscliv2.zip
unzip -q /tmp/awscliv2.zip -d /tmp
sudo /tmp/aws/install --update
aws --version
sudo cat /var/lib/jenkins/secrets/initialAdminPassword

Jenkins and GitHub configuration

Install Pipeline, Git, GitHub, GitHub Integration, Docker Pipeline, Credentials Binding, AWS Steps, SSH Agent, JUnit and Workspace Cleanup. Create a Multibranch Pipeline or Pipeline from SCM job, and configure a GitHub App or fine-grained repository credential with the minimum repository permissions required.

In GitHub, create a webhook at Settings → Webhooks with payload URL `https://jenkins.example.com/github-webhook/`, content type `application/json`, a long random secret, and the Push event. Configure the same value as a Jenkins secret text credential named `github-webhook-secret` if your webhook validation setup uses it. Do not expose an unauthenticated controller directly to the internet merely to receive webhooks; use a reverse proxy, allowlist, VPN or a GitHub App-based trigger path.

# On the Jenkins host, install the required plugins non-interactively before first use:
sudo jenkins-plugin-cli --plugins 'workflow-aggregator git github github-branch-source docker-workflow credentials-binding pipeline-aws ssh-agent junit ws-cleanup'
sudo systemctl restart jenkins

# Create the ECR repository once. Image tags are immutable deployment inputs.
aws ecr create-repository --repository-name flask-production --image-scanning-configuration scanOnPush=true --image-tag-mutability IMMUTABLE --region "$AWS_REGION"

Flask application: app factory and health endpoints

The health endpoint must remain cheap, deterministic and unauthenticated at the network boundary. Keep readiness checks separate from heavyweight dependency checks if a failing external dependency should not restart an otherwise healthy process.

# app/__init__.py
from flask import Flask

def create_app(test_config=None):
    app = Flask(__name__)
    app.config.from_mapping(JSON_SORT_KEYS=False)
    if test_config:
        app.config.update(test_config)

    from .routes import api
    app.register_blueprint(api)
    return app

# app/routes.py
from flask import Blueprint, jsonify

api = Blueprint("api", __name__)

@api.get("/health")
def health():
    return jsonify(status="ok"), 200

@api.get("/api/v1/data")
def data():
    return jsonify(items=[{"id": 1, "name": "production-ready"}]), 200

# app.py
from app import create_app
app = create_app()

Dependencies and tests

Pin application and test dependencies. In a production repository, generate and commit a hash-locked dependency file from a controlled build environment as part of dependency maintenance.

# requirements.txt
Flask==3.1.0
gunicorn==23.0.0
pytest==8.3.5
flake8==7.1.2
black==25.1.0

# tests/test_routes.py
from app import create_app

def client():
    app = create_app({"TESTING": True})
    return app.test_client()

def test_health_returns_ok():
    response = client().get("/health")
    assert response.status_code == 200
    assert response.get_json() == {"status": "ok"}

def test_data_returns_contract():
    response = client().get("/api/v1/data")
    assert response.status_code == 200
    assert response.get_json()["items"][0]["name"] == "production-ready"

Production container image

The image runs as an unprivileged user and has a Docker health check. Do not put `.env` files, private keys, test output or the Git directory in the build context.

# Dockerfile
FROM python:3.12-slim AS runtime
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /srv/app
RUN addgroup --system app && adduser --system --ingroup app app
COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip && pip install --no-cache-dir -r requirements.txt
COPY app ./app
COPY app.py ./
RUN chown -R app:app /srv/app
USER app
EXPOSE 5000
HEALTHCHECK --interval=30s --timeout=3s --start-period=15s --retries=3 CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:5000/health', timeout=2)"
CMD ["gunicorn", "--workers=2", "--threads=4", "--bind=0.0.0.0:5000", "--access-logfile=-", "--error-logfile=-", "app:app"]

# .dockerignore
.git
.venv
__pycache__/
.pytest_cache/
*.pyc
.env
tests/
Jenkinsfile

Deployment script: blue/green loopback switch

Stopping a container and starting its replacement on the same port creates a request gap. This script starts the new image on the inactive loopback port, verifies its health, updates an Nginx include atomically, reloads Nginx, and only then removes the old container. Retain the previous immutable image tag in the `previous` file for rollback.

#!/usr/bin/env bash
# deploy/deploy.sh
set -euo pipefail
IMAGE_URI=${1:?image URI required}
APP_DIR=/opt/flask-production
ACTIVE_FILE=$APP_DIR/active-port
PREVIOUS_FILE=$APP_DIR/previous-image
CURRENT_PORT=$(cat "$ACTIVE_FILE" 2>/dev/null || echo 5001)
if [ "$CURRENT_PORT" = 5000 ]; then NEXT_PORT=5001; else NEXT_PORT=5000; fi
NEW_NAME=flask-$NEXT_PORT
OLD_NAME=flask-$CURRENT_PORT

sudo mkdir -p "$APP_DIR" /etc/nginx/conf.d
echo "$IMAGE_URI" | sudo tee "$PREVIOUS_FILE".candidate > /dev/null
sudo docker pull "$IMAGE_URI"
sudo docker rm -f "$NEW_NAME" 2>/dev/null || true
sudo docker run -d --name "$NEW_NAME" --restart unless-stopped --read-only --tmpfs /tmp:rw,noexec,nosuid,size=64m -p 127.0.0.1:$NEXT_PORT:5000 "$IMAGE_URI"
for attempt in $(seq 1 20); do
  if curl --fail --silent --max-time 2 "http://127.0.0.1:$NEXT_PORT/health" >/dev/null; then break; fi
  sleep 2
  [ "$attempt" = 20 ] && { sudo docker logs "$NEW_NAME"; sudo docker rm -f "$NEW_NAME"; exit 1; }
done
CURRENT_IMAGE=$(sudo docker inspect -f '{{.Config.Image}}' "$OLD_NAME" 2>/dev/null || true)
[ -n "$CURRENT_IMAGE" ] && echo "$CURRENT_IMAGE" | sudo tee "$PREVIOUS_FILE" > /dev/null
printf 'server 127.0.0.1:%s;\n' "$NEXT_PORT" | sudo tee /etc/nginx/conf.d/flask-upstream.conf.new > /dev/null
sudo mv /etc/nginx/conf.d/flask-upstream.conf.new /etc/nginx/conf.d/flask-upstream.conf
sudo nginx -t && sudo systemctl reload nginx
echo "$NEXT_PORT" | sudo tee "$ACTIVE_FILE" > /dev/null
sudo docker rm -f "$OLD_NAME" 2>/dev/null || true
sudo docker image prune -f

Declarative Jenkins pipeline

This pipeline expects the Jenkins controller to have its ECR instance profile and an SSH private-key credential named `flask-production-ssh`. It publishes both the Jenkins build number and the commit SHA, then deploys the SHA tag. The production host needs only ECR pull permission and the deployment script installed at `/opt/flask-production/deploy.sh`.

pipeline {
  agent any
  options { timestamps(); disableConcurrentBuilds(); buildDiscarder(logRotator(numToKeepStr: '30')) }
  environment {
    AWS_REGION = 'ap-south-1'
    ECR_REPOSITORY = 'flask-production'
    AWS_ACCOUNT_ID = '123456789012'
    ECR_REGISTRY = "${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com"
    PRODUCTION_HOST = 'app.example.com'
  }
  stages {
    stage('Checkout') { steps { checkout scm; script { env.GIT_SHA = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim() } } }
    stage('Lint & Test') { steps { sh '''python3 -m venv .venv
. .venv/bin/activate
pip install --upgrade pip
pip install -r requirements.txt
black --check app tests app.py
flake8 app tests app.py
pytest -q --junitxml=reports/junit.xml''' }; junit 'reports/junit.xml' } }
    stage('ECR Login & Docker Build') { steps { sh '''aws ecr get-login-password --region "$AWS_REGION" | docker login --username AWS --password-stdin "$ECR_REGISTRY"
docker build --pull -t "$ECR_REGISTRY/$ECR_REPOSITORY:$BUILD_NUMBER" .
docker tag "$ECR_REGISTRY/$ECR_REPOSITORY:$BUILD_NUMBER" "$ECR_REGISTRY/$ECR_REPOSITORY:$GIT_SHA"''' } }
    stage('Push to ECR') { steps { sh '''docker push "$ECR_REGISTRY/$ECR_REPOSITORY:$BUILD_NUMBER"
docker push "$ECR_REGISTRY/$ECR_REPOSITORY:$GIT_SHA"''' } }
    stage('Deploy to Production EC2') { steps { sshagent(credentials: ['flask-production-ssh']) { sh '''ssh -o StrictHostKeyChecking=yes ubuntu@"$PRODUCTION_HOST" "aws ecr get-login-password --region $AWS_REGION | sudo docker login --username AWS --password-stdin $ECR_REGISTRY && sudo /opt/flask-production/deploy.sh $ECR_REGISTRY/$ECR_REPOSITORY:$GIT_SHA"''' } } }
    stage('Health Check Verification') { steps { sh 'curl --fail --retry 6 --retry-delay 3 --connect-timeout 3 https://$PRODUCTION_HOST/health' } }
  }
  post {
    failure { emailext subject: "FAILED: ${JOB_NAME} #${BUILD_NUMBER}", body: "${BUILD_URL}", to: 'platform-alerts@example.com' }
    always { cleanWs(deleteDirs: true, notFailBuild: true) }
  }
}

Nginx reverse proxy and TLS

Install this configuration on the production host. Nginx accepts public traffic while Flask remains reachable only from localhost. Before issuing a certificate, ensure the DNS A record resolves to the host and port 80 is publicly reachable.

# /etc/nginx/conf.d/flask-upstream.conf
server 127.0.0.1:5000;

# /etc/nginx/sites-available/flask
upstream flask_app { include /etc/nginx/conf.d/flask-upstream.conf; keepalive 16; }
server {
  listen 80;
  server_name app.example.com;
  client_max_body_size 10m;
  location / {
    proxy_pass http://flask_app;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_connect_timeout 5s;
    proxy_read_timeout 30s;
  }
}

sudo ln -sfn /etc/nginx/sites-available/flask /etc/nginx/sites-enabled/flask
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx
sudo apt-get update && sudo apt-get install -y certbot python3-certbot-nginx
sudo certbot --nginx -d app.example.com --redirect --agree-tos -m ops@example.com --no-eff-email
sudo systemctl status certbot.timer

Verification, rollback and troubleshooting

Validate the public route after every deployment and keep Jenkins console logs with the build record. A health endpoint returning 200 only proves process availability; add application-specific smoke checks for critical user workflows before treating a release as complete.

  • Verify the active route: `curl -fsS https://app.example.com/health` and `curl -fsS https://app.example.com/api/v1/data`.
  • Inspect the running release: `sudo docker ps`, `sudo docker logs --tail 200 flask-5000`, and `sudo nginx -T`.
  • If Jenkins reports Docker permission denied, run `sudo usermod -aG docker jenkins`, restart Jenkins, and verify with `sudo -u jenkins docker ps`. Do not make the Docker socket world-writable.
  • If a GitHub webhook returns 403, verify the exact `/github-webhook/` path, proxy forwarding, GitHub delivery response, job trigger configuration and webhook secret validation.
  • If ECR login fails later in a long-lived host session, re-run `aws ecr get-login-password`; ECR authorization tokens expire after 12 hours.
# Roll back to the last known image after a failed release
PREVIOUS=$(sudo cat /opt/flask-production/previous-image)
[ -n "$PREVIOUS" ] || { echo 'No previous image recorded'; exit 1; }
sudo /opt/flask-production/deploy.sh "$PREVIOUS"
curl --fail --silent https://app.example.com/health

# Verify Jenkins and production state
systemctl status jenkins --no-pager
sudo journalctl -u jenkins -n 100 --no-pager
sudo docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}'
sudo docker logs --tail 200 flask-5000
sudo docker logs --tail 200 flask-5001
← Back to all articles