This tutorial walks through building a simple Python Flask application, containerizing it with Docker, and wiring up a full CI/CD pipeline using Semaphore that automatically tests and deploys the app to Heroku on every push.
Attribution: This article is based on a tutorial originally published on semaphoreci.com.
Why Combine CI/CD, Docker, and Flask?
Continuous integration and deployment solve several real pain points for development teams:
- Developers focus on writing features rather than manually shepherding builds to production.
- Every commit is verified against automated tests before it reaches users.
- Git history gives you an audit trail and a clear rollback path.
- The classic “it works on my machine” problem disappears when every environment runs the same container image.
Docker reinforces these benefits by packaging the application and its runtime dependencies into a single, portable image. Key advantages include:
- Identical runtime environments for every developer on the team.
- No bleed between OS-level packages and application dependencies.
- Improved portability — the same image runs locally, in CI, and in production.
- Lower overhead compared to full virtual machines, because only the required runtime is bundled, not an entire OS.
Prerequisites
Make sure the following are available before you start:
- Python 2.7 or 3.x (the Dockerfile example below targets Python 2.7; update the base image tag for Python 3).
- Docker and Docker Compose installed locally.
- A GitHub or Bitbucket repository to host the project.
- A Semaphore account (free tier is sufficient for this tutorial).
- A Heroku account for deployment.
Project Structure
The finished application uses the following directory layout:
app/
├── templates/
│ └── index.html
├── tests/
│ └── test_endpoints.py
├── app.py
├── Dockerfile
├── docker-compose.yml
└── requirements.txt
app.py contains all routing logic and renders HTML templates stored in the templates/ directory. Tests live in tests/ and are isolated from the main application code.
Setting Up the Python Environment
Create and activate a virtual environment, then install dependencies. Using a virtual environment keeps the project’s packages separate from system-level Python packages.
$ virtualenv flask-env
$ . flask-env/bin/activate
$ pip install -r requirements.txt
A typical requirements.txt for this project includes Flask, PyMongo, and a test runner such as nose:
Flask
pymongo
nose
Writing Application Tests
Test-Driven Development (TDD) encourages you to define expected behaviour before writing the implementation. It also replaces repetitive manual testing with an automated suite that runs on every build.
The test class uses setUp and tearDown methods so each test runs against a fresh state. setUp creates a Flask test client, and tearDown clears the database collection so data from one test cannot influence another.
# tests/test_endpoints.py
from app import app, db
from flask import url_for
import unittest
class FlaskTodosTest(unittest.TestCase):
def setUp(self):
"""Set up test application client."""
self.app = app.test_client()
self.app.testing = True
def tearDown(self):
"""Clear DB after running tests."""
db.todos.remove({})
def test_home_status_code(self):
"""Assert that the homepage returns HTTP 200."""
result = self.app.get('/')
self.assertEqual(result.status_code, 200)
def test_todo_creation(self):
"""Assert that a POST to /new redirects with HTTP 302."""
response = self.app.post(
'/new',
data=dict(name="First todo", description="Test todo")
)
self.assertEqual(response.status_code, 302)
if __name__ == '__main__':
unittest.main()
Run the test suite locally with:
$ nosetests -v
Building the Flask Application
The application connects to a MongoDB instance (hosted on mLab in the original tutorial, though any MongoDB URI works) and exposes two routes:
GET /— Fetches all todo items from the database and renders theindex.htmltemplate.POST /new— Accepts form data, inserts a new todo document, then redirects back to/.
# app.py
import os
from flask import Flask, redirect, url_for, request, render_template
from pymongo import MongoClient
app = Flask(__name__)
# Replace with your actual MongoDB connection string.
client = MongoClient(
"mongodb://username:password@database_url:port_number/db_name")
db = client['db_name']
@app.route('/')
def todo():
_items = db.todos.find()
items = [item for item in _items]
return render_template('index.html', items=items)
@app.route('/new', methods=['POST'])
def new():
item_doc = {
'name': request.form['name'],
'description': request.form['description']
}
db.todos.insert_one(item_doc)
return redirect(url_for('todo'))
if __name__ == "__main__":
app.run(host='0.0.0.0', debug=True)Code language: PHP (php)
Start the development server with python app.py and open http://127.0.0.1:5000 in your browser. Flask defaults to port 5000. To override the port, pass it explicitly:
app.run(host='0.0.0.0', port=8080, debug=True)Code language: PHP (php)
Security note: Do not commit real database credentials to version control. Use environment variables or a secrets manager and read them with os.environ.get().
Dockerizing the Application
Writing the Dockerfile
The Dockerfile defines the container image for the application:
FROM python:2.7
ADD . /todo
WORKDIR /todo
EXPOSE 5000
RUN pip install -r requirements.txt
ENTRYPOINT ["python", "app.py"]Code language: JavaScript (javascript)
What each instruction does:
- FROM python:2.7 — Starts from an official Python 2.7 base image. Change to
python:3.11-slim(or another 3.x tag) for modern projects. - ADD . /todo — Copies the project files into a
/tododirectory inside the container. - WORKDIR /todo — Sets
/todoas the working directory for subsequent commands. - EXPOSE 5000 — Documents that the container listens on port 5000.
- RUN pip install -r requirements.txt — Installs Python dependencies inside the container.
- ENTRYPOINT — Runs
python app.pywhen the container starts.
Writing the Docker Compose File
Docker Compose simplifies building and running the container with a single command by codifying the configuration in docker-compose.yml:
web:
build: .
container_name: flock
ports:
- "5000:5000"
volumes:
- .:/todoCode language: JavaScript (javascript)
- build: . — Builds the image from the Dockerfile in the current directory.
- ports — Maps container port 5000 to host port 5000.
- volumes — Mounts the project directory into the container, so code changes are reflected without rebuilding the image during development.
Build and run the container:
$ docker-compose build
$ docker-compose up
Docker pulls the base image, installs dependencies, and starts the application. It is then accessible at http://127.0.0.1:5000.
Setting Up the CI/CD Pipeline with Semaphore
With the application tested and containerized locally, the next step is to automate the build, test, and deployment cycle using Semaphore CI.
Creating a New Semaphore Project
Log in to your Semaphore account, click Create new, and select Project from the dropdown. On the next screen, choose whether your repository is hosted on GitHub or Bitbucket.

Search for and select your project repository:

Select the branch to build (typically main or master):

Platform Detection and Build Configuration
Semaphore analyses the repository and detects the language and platform automatically.


When a Dockerfile is present, Semaphore recommends the Docker platform. You then configure the commands Semaphore should run — typically docker-compose build followed by the test command. Semaphore will refuse to proceed to deployment if any test fails.

Deploying to Heroku
Once the build and tests pass, click Set Up Deployment and choose Heroku as the target platform.


After entering your Heroku API key and target application name, Semaphore completes the first deployment:

Enable automatic deployment so that every subsequent push to the configured branch triggers a build and, if tests pass, an automatic deployment. The first deployment is always triggered manually.

The application is now live on Heroku:

Conclusion
This tutorial covered the full journey from a local Flask application to an automated deployment pipeline:
- Built a simple Flask todo application backed by MongoDB.
- Wrote unit tests using Python’s built-in
unittestmodule and the Flask test client. - Containerized the application with a
Dockerfileanddocker-compose.yml. - Connected the repository to Semaphore to automate builds and testing.
- Configured automatic deployment to Heroku, triggered on every successful build.
The result is a pipeline where pushing code is the only manual step. Semaphore handles the rest: building the Docker image, running tests, and shipping to production only when everything passes.