DEV Community

Python-T Point
Python-T Point

Posted on • Originally published at pythontpoint.in

🐍 How to set up CI/CD for a Python Flask app using GitHub Actions

"Automate or stagnate" β€” a DevOps engineer I once paired with, halfway through a 40-minute deploy script.

I didn’t get it at first. Then I spent three days debugging a Flask app that worked locally but failed silently in production. No logs. No tests. No repeatable deploy process β€” just a git push and a prayer.

That was the last time I treated deployment as an afterthought.

Now I know: CI/CD isn’t about speed. It’s about predictability. For a Python Flask app, using GitHub Actions to automate testing, linting, and deployment isn't optional β€” it’s the baseline for anything that needs to run reliably.

A real python flask github actions ci cd pipeline is more than a YAML file. It’s a chain of verifiable steps β€” testable, inspectable, and repeatable. When you push a commit, you should know exactly how your code gets built, tested, and deployed β€” and what happens when something fails.

This post walks through building that pipeline: from a minimal Flask app to a full workflow that validates every change and deploys only when everything passes.

python flask github actions ci cd

🐍 Flask App β€” Your Foundation Starts Here

A CI/CD pipeline only works if your app supports it.

Every Flask project I start includes a clear entry point, a requirements.txt, and a test suite. Here’s the minimal layout that works across teams and environments:

myflaskapp/
β”œβ”€β”€ app.py
β”œβ”€β”€ requirements.txt
β”œβ”€β”€ tests/
β”‚   └── test_routes.py
└── .github/workflows/ci-cd.yml
Enter fullscreen mode Exit fullscreen mode

app.py defines a basic route:

from flask import Flask

app = Flask(__name__)

@app.route("/")
def home():
    return {"status": "ok", "message": "Hello from Flask!"}

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)
Enter fullscreen mode Exit fullscreen mode

requirements.txt pins versions:

Flask==3.0.3
pytest==8.2.2
Enter fullscreen mode Exit fullscreen mode

And tests/test_routes.py ensures correctness:

import pytest
from app import app

@pytest.fixture
def client():
    app.config['TESTING'] = True
    with app.test_client() as client:
        yield client

def test_home_route(client):
    response = client.get("/")
    assert response.status_code == 200
    json_data = response.get_json()
    assert json_data['status'] == 'ok'
Enter fullscreen mode Exit fullscreen mode

Run locally:

$ python -m pytest
============================= test session starts ==============================
platform linux -- Python 3.11.9, pytest-8.2.2, pluggy-1.5.0
rootdir: /home/user/myflaskapp
collected 1 item

tests/test_routes.py .                                                   [100%]

============================== 1 passed in 0.12s ===============================
Enter fullscreen mode Exit fullscreen mode

This same command runs in CI. If it passes here, it will pass there β€” assuming the environment is consistent.

βš™οΈ GitHub Actions β€” How the Pipeline Works

A GitHub Actions workflow is a declarative script that runs in response to code changes.

When you push to a branch or open a PR, GitHub starts a fresh runner β€” an ephemeral Ubuntu VM β€” and runs your steps. No shared state. No lingering packages. Just a clean environment every time.

Here’s the core workflow in .github/workflows/ci-cd.yml:

name: CI/CD Pipeline

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.11'

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt

      - name: Run tests
        run: python -m pytest

      - name: Lint with flake8
        run: |
          pip install flake8
          flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
Enter fullscreen mode Exit fullscreen mode

What happens, step by step:

  • actions/checkout@v4 clones the repo using Git over HTTPS. It’s a lightweight composite action β€” no Docker, no overhead.
  • actions/setup-python@v5 installs Python 3.11 via pyenv, caching it for future runs. The version is isolated to the job.
  • Dependency installation runs in a fresh shell. No global site-packages. No accidental reliance on system packages.
  • pytest runs in the same context, so it sees the installed deps.
  • flake8 catches syntax errors and common anti-patterns β€” like F821 undefined name β€” before code is merged.

If any step fails, the pipeline stops. No merge. No deployment.

Output from a passing run:

Ran 1 test in 0.123s
OK
flake8: 0 errors, 0 warnings
Enter fullscreen mode Exit fullscreen mode

This output is logged and surfaced in the PR. You don’t need to run anything locally.

πŸ” Understanding the Runner Environment

GitHub runners are disposable Ubuntu 22.04 VMs. Each job starts clean β€” no pip cache, no Git history, no environment variables beyond defaults.

That means pip install downloads every package from PyPI on every run β€” unless you cache.

Add this step to cut install time from ~30s to ~5s:

- name: Cache pip
  uses: actions/cache@v4
  with:
    path: ~/.cache/pip
    key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
Enter fullscreen mode Exit fullscreen mode

The cache key includes the OS and the hash of requirements.txt. If the file changes, the cache invalidates.

This works because pip stores downloaded wheels in ~/.cache/pip by default. GitHub Actions caches that directory between runs β€” safely, per-branch.

πŸ› οΈ Handling Secrets and Environment Variables

You’ll need secrets eventually β€” API keys, database credentials.

Never hardcode them.

Use GitHub’s repository Secrets UI:

  • Create a secret named PROD_API_KEY
  • Reference it in your workflow:

    • name: Deploy to production env: API_KEY: ${{ secrets.PROD_API_KEY }} run: ./deploy.sh

These values are injected at runtime, encrypted in transit and at rest. They never appear in logs β€” even if you echo $API_KEY.

GitHub masks secrets automatically in job output.

πŸš€ Deployment β€” When Automate Meets Ship

CI verifies. CD deploys.

Extend the pipeline to deploy on main after tests pass.

Assume a VPS running Nginx + Gunicorn. The deploy job should pull code, install dependencies, and reload the app.

Here’s the job:

deploy:
  needs: test
  runs-on: ubuntu-latest
  if: github.ref == 'refs/heads/main'
  steps:
    - name: Deploy to production
      uses: appleboy/ssh-action@v1.0.1
      with:
        host: ${{ secrets.HOST }}
        username: ${{ secrets.USER }}
        key: ${{ secrets.SSH_KEY }}
        script: |
          cd /var/www/myflaskapp
          git pull origin main
          source venv/bin/activate
          pip install -r requirements.txt
          sudo systemctl restart gunicorn
Enter fullscreen mode Exit fullscreen mode

The needs: test ensures this only runs if tests pass. The if condition restricts it to main.

But raw SSH has risks. A typo in script could break the app or lock you out.

So:

  • Use a deploy key with read-only access to the repo
  • Restrict SSH to GitHub’s IP ranges via firewall
  • Test the deploy script locally before automating it

πŸ›‘οΈ Safer Alternatives: Use Deploy Scripts

Inline scripts in YAML are hard to test and version.

Instead, check in a deploy script:

#!/bin/bash
set -e  # Exit on any failure

cd /var/www/myflaskapp
git fetch origin
git reset --hard origin/main

source venv/bin/activate
pip install -r requirements.txt

# Trigger Gunicorn reload without downtime
touch app.wsgi
Enter fullscreen mode Exit fullscreen mode

Then call it from the workflow:

script: bash /var/www/myflaskapp/deploy.sh
Enter fullscreen mode Exit fullscreen mode

set -e ensures the script halts at the first error. No half-updated deploys.

🌐 Zero-Downtime Deployments? Start Simple

You might worry about downtime during pip install or systemctl restart.

For most Flask apps, a sub-second gap is acceptable.

If it’s not, then consider process managers like supervisord, rolling restarts with Gunicorn workers, or container orchestration β€” but only when monitoring shows it’s needed.

Automate the common case first. Optimize the edge case only when it becomes the norm.

πŸ§ͺ Testing Strategy β€” Beyond "It Works on My Machine"

A pipeline is only as good as its tests.

The pytest job runs unit tests β€” fast and isolated. But that’s not enough.

Add layers:

1. Unit tests β€” verify logic (like test_home_route)

2. Integration tests β€” check component interactions

3. Static analysis β€” catch bugs before execution

For integration, test the app as a running service:

import threading
import time
import requests
from app import app

def test_integration_live_server():
    server = threading.Thread(target=lambda: app.run(port=5000))
    server.daemon = True
    server.start()
    time.sleep(1)

    response = requests.get("http://localhost:5000/")
    assert response.status_code == 200
    assert response.json()['status'] == 'ok'
Enter fullscreen mode Exit fullscreen mode

This is slower, so mark it with pytest.mark.slow and skip it locally with -m "not slow".

For static analysis, add mypy:

pip install mypy
mypy app.py --strict
Enter fullscreen mode Exit fullscreen mode

It catches type mismatches β€” like passing a string where an int is expected.

And bandit for security:

pip install bandit
bandit -r app.py
Enter fullscreen mode Exit fullscreen mode

It flags dangerous patterns β€” pickle, eval, hardcoded passwords.

Add both to the workflow:

- name: Type check
  run: |
    pip install mypy
    mypy app.py --strict

- name: Security scan
  run: |
    pip install bandit
    bandit -r .
Enter fullscreen mode Exit fullscreen mode

Now the pipeline doesn’t just verify behavior β€” it enforces quality and safety.

🎯 Why This Matters: The Mechanism Behind Confidence

When you push, GitHub Actions:

1. Starts a fresh Ubuntu runner (no persistent state)

2. Clones the repo using actions/checkout@v4

3. Installs Python 3.11 via setup-python@v5 (using pyenv)

4. Installs deps with pip, optionally cached

5. Runs pytest, flake8, mypy, bandit in order

6. Reports results via GitHub’s Checks API

Each step is defined in code. The environment is explicit. There’s no hidden config.

This reproducibility is what makes CI trustworthy.

Compare that to β€œworks on my machine”: a custom Python version, global packages, local .env files. Those don’t survive handoffs.

GitHub Actions removes that variability β€” not by magic, but by treating the build environment as disposable and versioned.

🟩 Final Thoughts

A python flask github actions ci cd pipeline isn’t about tools. It’s about reducing uncertainty.

It forces a simple question: Can this app be built, tested, and deployed by a machine that knows nothing about the developer?

If yes, you’ve built something durable β€” something that outlives laptops, onboarding, and team changes.

I used to dread deploys. Now I merge with confidence. Because when the pipeline turns green, it’s not luck β€” it’s proof.

That shift β€” from hope to verification β€” is what turns side projects into systems people depend on.

❓ Frequently Asked Questions

Can I use GitHub Actions for free?

Yes. GitHub offers free CI/CD minutes for public repositories and limited minutes for private repos under the free plan. Usage scales with paid plans.

πŸ“‘ Table of Contents

  • 🐍 Flask App β€” Your Foundation Starts Here
  • βš™οΈ GitHub Actions β€” How the Pipeline Works
  • πŸ” Understanding the Runner Environment
  • πŸ› οΈ Handling Secrets and Environment Variables
  • πŸš€ Deployment β€” When Automate Meets Ship
  • πŸ›‘οΈ Safer Alternatives: Use Deploy Scripts
  • 🌐 Zero-Downtime Deployments? Start Simple
  • πŸ§ͺ Testing Strategy β€” Beyond "It Works on My Machine"
  • 🎯 Why This Matters: The Mechanism Behind Confidence
  • 🟩 Final Thoughts
  • ❓ Frequently Asked Questions
  • Can I use GitHub Actions for free?
  • How do I debug a failed GitHub Actions job?
  • Should I run migrations in the pipeline?
  • πŸ“š References & Further Reading

How do I debug a failed GitHub Actions job?

Click on the failed job in the Actions tab. Each step is expandable. Look at the logs β€” they show exact commands run and output. Use echo statements or set -x in scripts to trace execution.

Should I run migrations in the pipeline?

Not directly. Apply database migrations after deploy, not during CI. The pipeline should test code, not modify shared state. Use a separate, manual or gated step for migrations.

πŸ“š References & Further Reading

Top comments (0)