Reference Guide Detailed deployment notes with production context and concrete examples.

Flask CI/CD Deployment Pipeline Basics

If you're trying to automate Flask deployments with CI/CD, this guide shows you how to set up a basic pipeline step-by-step. The goal is to run tests on every push, deploy only from the main branch, and safely restart Gunicorn on the server.

Quick Fix / Quick Setup

Use this GitHub Actions workflow as a working baseline for a Flask app deployed over SSH to a VPS:

yaml
# .github/workflows/deploy.yml
name: Flask CI/CD

on:
  push:
    branches: [main]

jobs:
  test-and-deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        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: |
          pytest -q

      - name: Add SSH key
        uses: webfactory/ssh-agent@v0.9.0
        with:
          ssh-private-key: ${{ secrets.DEPLOY_SSH_KEY }}

      - name: Trust server host key
        run: |
          mkdir -p ~/.ssh
          ssh-keyscan -H ${{ secrets.DEPLOY_HOST }} >> ~/.ssh/known_hosts

      - name: Deploy to server
        run: |
          ssh ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} '
            set -e
            cd /var/www/myapp &&
            git pull origin main &&
            source venv/bin/activate &&
            pip install -r requirements.txt &&
            flask db upgrade &&
            sudo systemctl restart myapp &&
            sudo systemctl reload nginx
          '

Replace:

  • /var/www/myapp with your app path
  • venv with your virtualenv path
  • myapp with your systemd service name
  • flask db upgrade if you do not use Flask-Migrate

Required GitHub Actions secrets:

  • DEPLOY_HOST
  • DEPLOY_USER
  • DEPLOY_SSH_KEY

What’s Happening

CI runs automated checks after a git event such as a push or pull request. CD performs deployment actions after those checks pass. For Flask, the basic path is: install dependencies, run tests, connect to the server, update code, install packages, run migrations, restart Gunicorn, and verify service health.

Most failures happen at the boundaries:

  • SSH authentication
  • wrong deployment path
  • missing environment variables
  • broken virtualenv path
  • invalid systemd service configuration
  • failed database migrations

Step-by-Step Guide

  1. Make sure manual deployment already works
    CI/CD should automate a known-good deployment path. Before adding automation, confirm you can deploy manually on the server.
    Minimum server stack:
    • Python
    • Git
    • virtualenv
    • Gunicorn
    • Nginx
    • systemd service for the app

    If that is not set up yet, start with Deploy Flask with Nginx + Gunicorn (Step-by-Step Guide).
  2. Create or confirm the deployment directory
    Use a fixed path for the application:
    bash
    sudo mkdir -p /var/www/myapp
    sudo chown -R deploy:deploy /var/www/myapp
    

    Keep your virtualenv in a stable location:
    bash
    cd /var/www/myapp
    python3 -m venv venv
    
  3. Create a deploy user with limited access
    Your deploy user should be able to:
    • access the app directory
    • run git pull
    • activate the virtualenv
    • restart the app service
    • optionally reload Nginx

    Example sudoers rule:
    bash
    sudo visudo
    

    Add:
    sudoers
    deploy ALL=NOPASSWD: /bin/systemctl restart myapp, /bin/systemctl reload nginx, /bin/systemctl status myapp, /bin/systemctl status nginx
    
  4. Verify the Flask app works directly on the server
    Run the exact commands that the pipeline will later automate:
    bash
    cd /var/www/myapp
    source venv/bin/activate
    pip install -r requirements.txt
    flask db upgrade
    sudo systemctl restart myapp
    sudo systemctl status myapp --no-pager
    

    If your app depends on runtime environment variables, configure them first. See Flask Environment Variables and Secrets Setup.
  5. Generate an SSH key pair for deployment
    On your local machine or a secure admin host:
    bash
    ssh-keygen -t ed25519 -C "github-actions-deploy" -f deploy_key
    

    Copy the public key to the deploy user account on the server:
    bash
    ssh-copy-id -i deploy_key.pub deploy@your-server
    

    Or manually append deploy_key.pub to:
    bash
    ~/.ssh/authorized_keys
    
  6. Store deployment secrets in GitHub
    In your repository settings, add these Actions secrets:
    • DEPLOY_HOST
    • DEPLOY_USER
    • DEPLOY_SSH_KEY

    DEPLOY_SSH_KEY should contain the full private key content, including header and footer:
    text
    -----BEGIN OPENSSH PRIVATE KEY-----
    ...
    -----END OPENSSH PRIVATE KEY-----
    
  7. Create the workflow file
    Save this as:
    bash
    .github/workflows/deploy.yml
    

    Use this baseline:
    yaml
    name: Flask CI/CD
    
    on:
      push:
        branches: [main]
    
    jobs:
      test-and-deploy:
        runs-on: ubuntu-latest
    
        steps:
          - name: Checkout
            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: |
              pytest -q
    
          - name: Add SSH key
            uses: webfactory/ssh-agent@v0.9.0
            with:
              ssh-private-key: ${{ secrets.DEPLOY_SSH_KEY }}
    
          - name: Trust server host key
            run: |
              mkdir -p ~/.ssh
              ssh-keyscan -H ${{ secrets.DEPLOY_HOST }} >> ~/.ssh/known_hosts
    
          - name: Deploy to server
            run: |
              ssh ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} '
                set -e
                cd /var/www/myapp &&
                git pull origin main &&
                source venv/bin/activate &&
                pip install -r requirements.txt &&
                flask db upgrade &&
                sudo systemctl restart myapp &&
                sudo systemctl reload nginx
              '
    
  8. Run tests before deploy
    At minimum:
    bash
    pytest -q
    

    If tests need environment variables, inject test-safe values in the workflow:
    yaml
    - name: Run tests
      env:
        FLASK_ENV: testing
        SECRET_KEY: test-secret
        DATABASE_URL: sqlite:///test.db
      run: |
        pytest -q
    

    Do not reuse production secrets for test execution unless strictly required.
  9. Restrict deployment to the production branch
    For a basic pipeline, deploy only from main:
    yaml
    on:
      push:
        branches: [main]
    

    If you want tests on pull requests too:
    yaml
    on:
      push:
        branches: [main]
      pull_request:
    

    Then split deploy into a branch-guarded job.
  10. Use a predictable remote deploy sequence
    Keep the remote command sequence simple and atomic:
    bash
    ssh deploy@your-server '
      set -e
      cd /var/www/myapp &&
      git pull origin main &&
      source venv/bin/activate &&
      pip install -r requirements.txt &&
      flask db upgrade &&
      sudo systemctl restart myapp &&
      sudo systemctl reload nginx
    '
    

    Important points:
    • set -e stops on first failure
    • absolute paths reduce ambiguity
    • systemctl should manage Gunicorn, not background shell commands
  11. Confirm your systemd service is correct
    Example Flask Gunicorn service:
    ini
    # /etc/systemd/system/myapp.service
    [Unit]
    Description=Gunicorn instance for myapp
    After=network.target
    
    [Service]
    User=www-data
    Group=www-data
    WorkingDirectory=/var/www/myapp
    EnvironmentFile=/etc/myapp.env
    ExecStart=/var/www/myapp/venv/bin/gunicorn --workers 3 --bind unix:/run/myapp.sock wsgi:app
    
    [Install]
    WantedBy=multi-user.target
    

    Reload and enable if you change it:
    bash
    sudo systemctl daemon-reload
    sudo systemctl enable myapp
    sudo systemctl restart myapp
    

    For a full service setup, see Flask systemd + Gunicorn Service Setup.
  12. Keep production secrets on the server
    Runtime secrets usually should not live in the CI workflow. Keep them in an environment file or service configuration on the server.
    Example:
    ini
    # /etc/myapp.env
    FLASK_ENV=production
    SECRET_KEY=replace-this
    DATABASE_URL=postgresql://user:pass@localhost/myapp
    

    Then restart the app:
    bash
    sudo systemctl restart myapp
    
  13. Add a post-deploy health check
    Verify the app responds after restart:
    bash
    curl -I https://yourdomain.com
    curl -fsS https://yourdomain.com/health
    

    You can add this directly to the workflow:
    yaml
    - name: Health check
      run: |
        curl -fsS https://yourdomain.com/health
    
  14. Validate Nginx only when needed
    If your deploy includes Nginx config changes, validate before reload:
    bash
    sudo nginx -t
    sudo systemctl reload nginx
    

    If Nginx is misconfigured, deploy may appear to succeed while traffic still fails. See Fix Flask 502 Bad Gateway (Step-by-Step Guide).
  15. Keep rollback simple
    For a basic VPS pipeline, rollback discipline means:
    • deploy small changes
    • keep commits clean
    • confirm migrations are safe
    • inspect logs before retrying failed restarts

    Advanced release models can later move to:
    • tagged releases
    • immutable artifacts
    • blue/green deployment
    • staged approval gates

Common Causes

  • SSH authentication failure → CI cannot connect to the server → verify DEPLOY_SSH_KEY, authorized_keys, and host key trust.
  • Wrong working directory → remote commands run outside the app path → use an absolute deployment directory in the SSH command.
  • Missing virtualenv activation → packages or Flask CLI are unavailable → activate the correct venv before pip install and migrations.
  • Deploy user lacks sudo for systemctl → Gunicorn restart fails → allow only the required service commands in sudoers.
  • Environment variables missing on the server → app starts in CI tests but fails in production → verify the EnvironmentFile or service environment settings.
  • Migration command fails → schema is behind or database URL is wrong → test flask db upgrade manually on the server first.
  • Service unit misconfiguration → restart succeeds poorly or app crashes immediately → inspect ExecStart, WorkingDirectory, and User in the unit file.
  • Nginx reload masks app failure → proxy stays up but backend is down → check Gunicorn and app logs, not only Nginx status.

Debugging

Check the failing stage in order: CI logs, SSH access, remote commands, Gunicorn status, then Nginx status.

Commands to run:

bash
pytest -q
ssh deploy@your-server 'cd /var/www/myapp && git status && git pull origin main'
ssh deploy@your-server 'cd /var/www/myapp && source venv/bin/activate && pip install -r requirements.txt'
ssh deploy@your-server 'cd /var/www/myapp && source venv/bin/activate && flask db upgrade'
ssh deploy@your-server 'sudo systemctl status myapp --no-pager'
ssh deploy@your-server 'sudo journalctl -u myapp -n 100 --no-pager'
ssh deploy@your-server 'sudo nginx -t'
ssh deploy@your-server 'sudo systemctl status nginx --no-pager'
ssh deploy@your-server 'sudo tail -n 100 /var/log/nginx/error.log'
curl -I https://yourdomain.com
curl -fsS https://yourdomain.com/health

What to look for:

  • CI logs: package install failures, test failures, malformed SSH key, missing secret values
  • SSH session: wrong user, denied key, host verification failure
  • Gunicorn/systemd logs: import errors, missing env vars, permission problems, bad socket path
  • Nginx logs: upstream connect errors, socket mismatch, invalid config
  • Migration step: incorrect database URL, missing migration scripts, app import failures

If requests still fail after deploy, use Fix Flask 502 Bad Gateway (Step-by-Step Guide).

Checklist

  • Manual deployment works on the server before CI/CD is enabled.
  • DEPLOY_HOST, DEPLOY_USER, and DEPLOY_SSH_KEY are stored as CI secrets.
  • The deploy user can access the app directory and restart the Flask service.
  • The workflow runs tests before deployment.
  • Deployment is restricted to the production branch or release trigger.
  • The app virtualenv path is correct on the server.
  • Database migrations run successfully if included in the pipeline.
  • systemd restarts Gunicorn without errors.
  • Nginx reloads cleanly if included in the deploy step.
  • A post-deploy health check returns HTTP 200 or expected headers.

FAQ

Q: What is the minimum useful CI/CD pipeline for Flask?
A: Run tests on push, deploy only from main, update code on the server, install dependencies, run migrations, restart Gunicorn, and verify a health check.

Q: Should I use GitHub Actions for Flask CI/CD?
A: Yes. It is a common baseline for VPS deployments and works well with SSH-based deploys.

Q: Do I need Docker for CI/CD?
A: No. A Flask app can use CI/CD with a standard virtualenv, systemd, and Nginx setup.

Q: Where should production secrets live?
A: Usually on the server in systemd environment files or equivalent runtime configuration, not in the workflow unless required for deployment.

Q: How do I know the deploy actually worked?
A: Check the CI job status, systemd service status, application logs, and a post-deploy HTTP health check.

Final Takeaway

A basic Flask CI/CD pipeline should automate an existing manual deployment path: test, connect, update code, install dependencies, run migrations, restart Gunicorn, and verify health. Keep the first version simple, predictable, and easy to debug before adding more advanced release logic.