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:
# .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/myappwith your app pathvenvwith your virtualenv pathmyappwith your systemd service nameflask db upgradeif you do not use Flask-Migrate
Required GitHub Actions secrets:
DEPLOY_HOSTDEPLOY_USERDEPLOY_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
- 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). - Create or confirm the deployment directory
Use a fixed path for the application:bashsudo mkdir -p /var/www/myapp sudo chown -R deploy:deploy /var/www/myapp
Keep your virtualenv in a stable location:bashcd /var/www/myapp python3 -m venv venv - 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:bashsudo visudo
Add:sudoersdeploy ALL=NOPASSWD: /bin/systemctl restart myapp, /bin/systemctl reload nginx, /bin/systemctl status myapp, /bin/systemctl status nginx - Verify the Flask app works directly on the server
Run the exact commands that the pipeline will later automate:bashcd /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. - Generate an SSH key pair for deployment
On your local machine or a secure admin host:bashssh-keygen -t ed25519 -C "github-actions-deploy" -f deploy_key
Copy the public key to the deploy user account on the server:bashssh-copy-id -i deploy_key.pub deploy@your-server
Or manually appenddeploy_key.pubto:bash~/.ssh/authorized_keys - Store deployment secrets in GitHub
In your repository settings, add these Actions secrets:DEPLOY_HOSTDEPLOY_USERDEPLOY_SSH_KEY
DEPLOY_SSH_KEYshould contain the full private key content, including header and footer:text-----BEGIN OPENSSH PRIVATE KEY----- ... -----END OPENSSH PRIVATE KEY----- - Create the workflow file
Save this as:bash.github/workflows/deploy.yml
Use this baseline:yamlname: 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 ' - Run tests before deploy
At minimum:bashpytest -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. - Restrict deployment to the production branch
For a basic pipeline, deploy only frommain:yamlon: push: branches: [main]
If you want tests on pull requests too:yamlon: push: branches: [main] pull_request:
Then split deploy into a branch-guarded job. - Use a predictable remote deploy sequence
Keep the remote command sequence simple and atomic:bashssh 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 -estops on first failure- absolute paths reduce ambiguity
systemctlshould manage Gunicorn, not background shell commands
- 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:bashsudo systemctl daemon-reload sudo systemctl enable myapp sudo systemctl restart myapp
For a full service setup, see Flask systemd + Gunicorn Service Setup. - 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:bashsudo systemctl restart myapp - Add a post-deploy health check
Verify the app responds after restart:bashcurl -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 - Validate Nginx only when needed
If your deploy includes Nginx config changes, validate before reload:bashsudo 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). - 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 installand 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
EnvironmentFileor service environment settings. - Migration command fails → schema is behind or database URL is wrong → test
flask db upgrademanually on the server first. - Service unit misconfiguration → restart succeeds poorly or app crashes immediately → inspect
ExecStart,WorkingDirectory, andUserin 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:
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, andDEPLOY_SSH_KEYare 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.
Related Guides
- Deploy Flask with Nginx + Gunicorn (Step-by-Step Guide)
- Flask Environment Variables and Secrets Setup
- Fix Flask 502 Bad Gateway (Step-by-Step Guide)
- Flask Production Checklist (Everything You Must Do)
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.