Most developers eventually reach the point where deploying an application manually starts to feel repetitive. Mine looked like this:
SSH into EC2 → pull the latest code → build or pull the Docker image → restart the containers → open the browser → hope everything still works.
It wasn't difficult. It also wasn't reliable. Every deployment depended on me remembering the correct sequence of commands, in the correct order, with no automatic verification, no quality gate, and no guarantee that what worked on my machine would work in production.
The application wasn't the problem. The deployment process was.
That realization changed how I approached this project. Instead of learning GitHub Actions, Docker, and AWS as separate topics, I treated them as parts of one system, with one goal:
A single
git pushshould be enough to take verified code from my laptop to a running application on AWS.
Today, every push to main automatically lints the code with Flake8, runs the test suite with Pytest, builds a Docker image with Buildx (reusing cached layers and dependencies), pushes the image to Amazon ECR, connects to an EC2 instance over SSH, deploys the new container, verifies it with a health check, and posts the result to Slack. No manual steps, no guessing whether it actually started.
Why Build a CI/CD Pipeline at All?
Most beginner AWS tutorials stop once the application is running: launch an EC2 instance, install Docker, run the container, visit the public IP. That's technically a deployment, but it leaves the real questions unanswered — how does new code reach the server, what stops broken code from being deployed, how does the server know which image to run, and how do you actually know a deployment succeeded?
Those questions are what CI/CD is for. The pipeline is the process that answers all of them, every time, without relying on memory.
The Project
The application itself is intentionally simple: a Flask app connected to PostgreSQL, packaged with Docker. Keeping it simple let me focus on the deployment pipeline instead of business logic — the interesting part was never the Flask code, it was everything built around it:
- Python (Flask) + PostgreSQL, Docker & Docker Compose
- GitHub Actions, with matrix builds, reusable workflows, and composite actions
- Amazon ECR for image storage, AWS EC2 for compute
- Pytest for testing, Flake8 for code quality
- Docker Buildx with layer and dependency caching
- Post-deploy health checks and Slack notifications
tanayjdev
/
flask-docker-app
Production-style Flask application with an end-to-end CI/CD pipeline using GitHub Actions, Docker, Amazon ECR and AWS infrastructure (EC2, ALB, RDS, VPC, CloudWatch).
Flask + PostgreSQL - Production-style CI/CD Pipeline on AWS
Key Highlights
- Production-style Flask application
- Dockerized deployment
- GitHub Actions CI/CD Pipeline
- Amazon Elastic Container Registry (ECR)
- Automated deployment to Amazon EC2
- AWS Infrastructure (EC2, ALB, RDS, VPC)
- Matrix builds for multiple Python versions
- Reusable GitHub Actions workflows
- Composite GitHub Actions
- Docker layer & pip dependency caching
- Slack deployment notifications
- Project evolution from local Docker → AWS Infrastructure → Full CI/CD
Project Goal
Build, containerize, and deploy a production-style Flask web application on AWS while implementing an end-to-end CI/CD pipeline using GitHub Actions and modern DevOps practices.
The project demonstrates modern DevOps practices including automated code quality checks, testing, container image builds, continuous deployment, AWS infrastructure, monitoring, deployment automation, and infrastructure best practices.
Current Architecture (v3.0)
What This Project Is
A production-style Flask web application deployed on AWS using Docker and automated through a complete CI/CD pipeline.
The application tracks page visits…
The Pipeline, End to End
Push
│
▼
Lint (Flake8)
│
▼
Unit Tests (Pytest)
│
▼
Docker Build (Buildx + cache)
│
▼
Push Image to Amazon ECR
│
▼
Deploy to EC2 over SSH
│
▼
Health Check (/health)
│
▼
Slack Notification
Each stage has exactly one responsibility, and each stage protects the next: if a stage fails, the pipeline stops immediately and broken code never reaches production. That single rule shaped every other decision in this project.
Stage 1 — Code Quality First
Linting runs before anything else, on purpose:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.10"
cache: pip
- run: pip install flake8
- run: flake8 . --count --statistics
Not because style matters more than correctness — because it's the fastest gate. Spinning up a full Docker build only to fail on a stray import is wasted time and wasted compute. Fail fast, fix early, then move on.
Stage 2 — Tests Gate the Pipeline
Passing lint doesn't mean the app works, so the next job runs the test suite — and only starts if lint succeeded:
test:
needs: lint
That one needs keyword turns a set of independent jobs into an explicit dependency graph. Inside the job:
pip install -r requirements.txt
pytest test_app.py -v
If a test fails, nothing downstream runs. No image is built, nothing is deployed, and production never sees the broken code. Separating verification from deployment this way is one of the most useful ideas in the whole pipeline.
Making It Fast: Caching
Early on, every run re-downloaded every dependency from scratch, even when nothing had changed. setup-python's built-in cache: pip handles most of it, and a dedicated actions/cache step — keyed off a hash of requirements.txt — restores previously downloaded packages instantly when the file hasn't changed. Less waiting, faster feedback after every commit.
Docker layers get the same treatment. Docker only rebuilds what changed, so the Dockerfile's instruction order matters:
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY app.py .
Dependencies rarely change; application code changes constantly. Installing dependencies before copying the source means Docker can reuse that dependency layer across nearly every build, and only rebuild the thin application layer on top. It's a small reordering that noticeably speeds up almost every run — a reminder that performance work isn't always about adding new tools, sometimes it's about arranging the ones you already have.
Building and Storing the Image
The image itself is built with Docker Buildx rather than the legacy builder, since it has better support for modern caching:
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v5
with:
context: .
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
Once built, the image needs somewhere to live before deployment. I used Amazon ECR: GitHub Actions authenticates with AWS credentials stored as repository secrets, then pushes the image automatically, tagged both latest and with the commit SHA so every deployment is traceable back to an exact commit.
Splitting image creation from image storage from image deployment keeps each piece single-purpose — GitHub Actions builds the artifact, ECR stores it, EC2 just pulls whatever's latest.
Deploying to EC2
The deploy job runs only after the image has been built and pushed successfully, and connects to EC2 over SSH using a key stored in GitHub Secrets. Because ECR is a private registry, the server has to authenticate before it can pull anything — this part is easy to gloss over, but skip it and every pull fails silently:
aws ecr get-login-password --region ap-south-1 \
| docker login --username AWS --password-stdin <ecr-registry-url>
docker compose pull
docker compose up -d --force-recreate
No manual login, no copying files, no rebuilding images on the server. EC2 authenticates, pulls whatever ECR says is latest, and recreates the containers — which means every environment always runs the exact same image, byte for byte.
A Deployment Isn't Successful Until It's Healthy
Containers starting doesn't mean the application is working. After the restart, the workflow waits briefly and then checks the app's own health endpoint:
curl -s -o /dev/null -w "%{http_code}" http://localhost:5000/health
HTTP 200 means the deployment succeeded; anything else fails the workflow immediately, logs are dumped, and the run is marked as failed. Without this, "the container started" and "the application works" get treated as the same thing — and they aren't. With it, success actually means what it says: the app is responding, not just running.
Beyond a Single Workflow: Matrix Builds, Reusable Workflows, and Composite Actions
Once the core pipeline was solid, three GitHub Actions features made it easier to maintain and extend:
-
Matrix builds run the test job across multiple Python versions in parallel (
3.9,3.10,3.11), so a change that only breaks one version gets caught before it reachesmain, instead of after. -
Reusable workflows let the testing logic live in one
workflow_call-triggered file that other workflows invoke by reference, instead of copy-pasting the same lint-and-test steps into every pipeline. -
Composite actions package a repeated sequence — checkout, Python setup, dependency install — into a single custom action (
setup-python-env), so each job calls it in one line instead of repeating four steps every time.
None of these change what the pipeline does. They change how much duplication exists in how it's defined — which matters the moment there's more than one workflow file to maintain.
Mistakes I Made Along the Way
The project wasn't smooth. A few of the failures taught me more than any of the successful runs did.
The biggest one: I initially configured the Docker health check to hit an HTTPS endpoint, even though the Flask app only served plain HTTP. The container reported unhealthy indefinitely — not because the app was broken, but because the health check itself was wrong. It's an easy trap: a red status doesn't always mean the thing you think it means, and a deployment is only as trustworthy as the check verifying it.
I also spent real time debugging GitHub Actions IAM permissions, ECR authentication, and SSH connectivity — each one forced me to actually understand how the pieces fit together instead of treating the pipeline as a black box I copy-pasted into existence.
What I'd Improve Next
The pipeline is fully automated, but there's a clear list of what production systems usually add next:
- Rolling or blue-green deployments to eliminate the brief downtime during
--force-recreate - Automatic rollback if a deployment's health check fails
- Infrastructure as Code with Terraform, instead of hand-provisioned AWS resources
- Kubernetes for orchestration once a single EC2 instance stops being enough
- Observability with Prometheus and Grafana, beyond a single
/healthcheck
Key Takeaways
- Cheap checks should run first — lint before test, test before build, build before deploy.
- A
needsdependency graph is what turns a pile of jobs into an actual pipeline. - A private registry means the deploy target has to authenticate — don't skip that step in your own writeup or your own script.
- A health check only means something if it's checking the right thing.
- Reusable workflows and composite actions aren't about new capability — they're about not repeating yourself once you have more than one pipeline.
- CI/CD isn't one tool. It's a sequence of quality gates that all have to agree before production changes.
Conclusion
This project started as a way to learn GitHub Actions. It ended up teaching me far more about how a deployment pipeline actually fits together — from code quality and testing, to container builds and image registries, to cloud deployment and the health check that proves it worked.
There's still plenty left to learn — Kubernetes, Infrastructure as Code, rollback strategies — but building this gave me a real, working mental model of how modern software delivery happens, instead of a list of tool names I could recognize but not explain.
If you're learning DevOps or cloud engineering, build a pipeline yourself instead of only reading about one. Debugging the failures taught me more than watching the successful runs ever did.
Check out the full source and workflow files on GitHub
I'm curious — what was the moment that convinced you to stop deploying manually? Or if you're still doing it by hand, what's the biggest thing stopping you from automating it?

Top comments (0)