Deploy Django with GitHub Actions CI/CD
Manual Django deployments usually fail in predictable ways: someone forgets to run migrations, static files are outdated, the wrong branch gets pushed to production, or a restar...
Problem statement
Manual Django deployments usually fail in predictable ways: someone forgets to run migrations, static files are outdated, the wrong branch gets pushed to production, or a restart happens before the new release is actually ready. Even when the deploy works, the process is hard to repeat consistently.
A safe Django GitHub Actions deployment turns the release into a defined sequence: push approved code, connect securely to the server, create a new release, install dependencies, run migrations, collect static files, switch the active release, restart Gunicorn, verify health, and keep a previous release available for rollback.
GitHub Actions helps with repeatability, but only if the server is already configured correctly and the workflow is limited to controlled production releases.
Quick answer
A practical production pattern is:
- Trigger a GitHub Actions workflow only from the production branch or a protected environment.
- Use SSH with a non-root deploy user.
- Upload the application code to a timestamped release directory on the server.
- Reuse a server-side virtualenv and shared environment configuration.
- Run:
pip install -r requirements.txtpython manage.py migrate --noinputpython manage.py collectstatic --noinput
- Switch
/srv/myapp/currentto the new release. - Restart Gunicorn.
- Verify a health endpoint and service status.
- Roll back the symlink if checks fail.
This gives you a simple, production-safe GitHub Actions Django deployment pipeline without introducing a full container platform.
Step-by-step solution
Choose a safe deployment approach for Django with GitHub Actions
For most VPS deployments, use one of these patterns.
Direct server deploy over SSH
GitHub Actions connects to the server and runs the release steps remotely. This is the simplest option for a single Ubuntu server.
Build artifact first, then deploy artifact
Instead of syncing the repository directly, Actions creates a tarball and uploads that to the server. This reduces variation between CI and release input and avoids deploying extra repository files.
When to avoid deploying on every push
Do not deploy every branch automatically. Limit production deploys to:
mainorproduction- tagged releases
- a protected GitHub Environment requiring approval
Prepare the production server for CI/CD deployments
The server should already support a successful manual deployment before automation.
Create a dedicated deploy user
sudo adduser deploy
sudo mkdir -p /srv/myapp/{releases,shared,run}
sudo chown -R deploy:deploy /srv/myapp
Set up the deploy user's SSH directory:
sudo -u deploy mkdir -p /home/deploy/.ssh
sudo -u deploy chmod 700 /home/deploy/.ssh
Add the CI public key to /home/deploy/.ssh/authorized_keys and set:
sudo -u deploy chmod 600 /home/deploy/.ssh/authorized_keys
Allow the deploy user to restart Gunicorn without a password
If your workflow runs sudo systemctl restart gunicorn, configure that explicitly. Otherwise CI will block on a password prompt.
sudo visudo -f /etc/sudoers.d/myapp-deploy
Add:
deploy ALL=NOPASSWD: /bin/systemctl restart gunicorn, /bin/systemctl status gunicorn
If your distro uses a different systemctl path, adjust it accordingly.
Set up application directories and release layout
Use a release structure like this:
/srv/myapp/releases/20260424-120000/srv/myapp/current-> symlink to active release/srv/myapp/shared/.env/srv/myapp/shared/media/
Create shared paths:
sudo -u deploy mkdir -p /srv/myapp/shared/media
Configure Python environment and dependency installation
Create one virtualenv outside the release directories:
sudo -u deploy python3 -m venv /srv/myapp/venv
sudo -u deploy /srv/myapp/venv/bin/pip install --upgrade pip
A shared virtualenv is common on a single VPS, but it weakens rollback isolation. If one deploy upgrades dependencies, switching current back to older code may not fully restore service. For stronger rollback behavior, use per-release virtualenvs or deploy a prebuilt runtime artifact.
Keep runtime secrets on the server
Store production secrets in /srv/myapp/shared/.env, not in the repository and not inside the uploaded release archive.
At minimum, production should already have a valid SECRET_KEY outside the repo.
Before automating deploys, confirm production settings are already correct:
DEBUG=FalseALLOWED_HOSTSincludes the real hostnamesCSRF_TRUSTED_ORIGINSmatches your HTTPS originsSECURE_PROXY_SSL_HEADERis configured correctly behind Nginx if TLS terminates at the proxy- secure cookie settings are enabled where appropriate
Ensure Gunicorn, Nginx, and systemd are already working manually
Your app service should already run from /srv/myapp/current. Example systemd service:
[Unit]
Description=Gunicorn for myapp
After=network.target
[Service]
User=deploy
Group=www-data
WorkingDirectory=/srv/myapp/current
EnvironmentFile=/srv/myapp/shared/.env
ExecStart=/srv/myapp/venv/bin/gunicorn myproject.wsgi:application \
--bind 127.0.0.1:8000 \
--workers 3
Restart=always
[Install]
WantedBy=multi-user.target
Verify before adding CI/CD:
sudo systemctl status gunicorn --no-pager
sudo nginx -t
Also make sure Nginx serves static and media from stable paths, not from temporary release-relative paths that disappear on rollback.
Configure GitHub Actions secrets securely
Store these in GitHub repository secrets or, preferably, a protected Environment:
DEPLOY_HOSTDEPLOY_PORTDEPLOY_USERDEPLOY_SSH_KEYDEPLOY_KNOWN_HOSTS
DEPLOY_KNOWN_HOSTS should contain the pinned SSH host key entry for the server. Do not treat ssh-keyscan output collected during the workflow as trusted verification by itself.
Keep runtime app secrets on the server in /srv/myapp/shared/.env. Do not copy production .env files through CI artifacts.
Restrict deployments to protected branches or environments
GitHub Environments let you require approvals and scope secrets to production. That is safer than exposing deploy credentials to every branch workflow.
Avoiding secret leakage in logs
Do not echo secrets. Avoid commands that print private keys or environment files. Keep shell scripts strict and minimal.
Create the GitHub Actions workflow for Django deployment
Create .github/workflows/deploy.yml:
name: Deploy Django
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies for checks
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run Django system checks
run: |
python manage.py check --deploy
env:
DJANGO_SETTINGS_MODULE: myproject.settings
# Add any minimum required environment values here if your
# settings module cannot load without them. Keep secrets in
# GitHub Environment secrets or on the server, not in the repo.
- name: Start SSH agent
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.DEPLOY_SSH_KEY }}
- name: Install pinned known_hosts
run: |
mkdir -p ~/.ssh
chmod 700 ~/.ssh
printf "%s\n" "${{ secrets.DEPLOY_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
chmod 600 ~/.ssh/known_hosts
- name: Upload release
run: |
RELEASE_ID=$(date +%Y%m%d-%H%M%S)
echo "RELEASE_ID=$RELEASE_ID" >> $GITHUB_ENV
tar --exclude='.git' -czf release.tar.gz .
ssh -p "${{ secrets.DEPLOY_PORT }}" "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \
"mkdir -p /srv/myapp/releases/$RELEASE_ID"
scp -P "${{ secrets.DEPLOY_PORT }}" release.tar.gz \
"${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:/srv/myapp/releases/$RELEASE_ID/"
- name: Run remote deploy
run: |
ssh -p "${{ secrets.DEPLOY_PORT }}" "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" <<EOF
set -e
RELEASE_PATH=/srv/myapp/releases/$RELEASE_ID
cd \$RELEASE_PATH
tar -xzf release.tar.gz
rm release.tar.gz
/srv/myapp/venv/bin/pip install -r requirements.txt
ln -sfn /srv/myapp/shared/.env .env
ln -sfn /srv/myapp/shared/media media
/srv/myapp/venv/bin/python manage.py migrate --noinput
/srv/myapp/venv/bin/python manage.py collectstatic --noinput
ln -sfn \$RELEASE_PATH /srv/myapp/current
sudo systemctl restart gunicorn
sudo systemctl status gunicorn --no-pager
curl --fail --silent http://127.0.0.1:8000/health/ > /dev/null
EOF
Run the Django release steps in the correct order
The order matters.
Unpack the new release into a fresh directory
Use a new timestamped directory for each deploy. Do not deploy in place over the current release.
Install pinned dependencies
Use pinned versions in requirements.txt. Installing into a shared virtualenv is common on a single VPS, but dependency changes should be intentional and tested.
Run database migrations safely
/srv/myapp/venv/bin/python manage.py migrate --noinput
Before risky schema changes, make sure a database backup exists. If a migration is not backward compatible, rollback may require restoring the database rather than only switching code.
Also note the transition risk: if the old code remains live while migrations have already changed the schema, old application code may stop working before the restart. Plan backward-compatible migrations when possible.
Collect static files
/srv/myapp/venv/bin/python manage.py collectstatic --noinput
Switch the active release symlink
Because the systemd service uses WorkingDirectory=/srv/myapp/current, switch the symlink before restarting Gunicorn:
ln -sfn /srv/myapp/releases/20260424-120000 /srv/myapp/current
Restart Gunicorn
sudo systemctl restart gunicorn
sudo systemctl status gunicorn --no-pager
Reload Nginx only if config changed
Usually an app deploy does not require an Nginx reload. If you changed site config:
sudo nginx -t
sudo systemctl reload nginx
Add verification checks after deployment
Run checks immediately after restart.
Check systemd service status
sudo systemctl status gunicorn --no-pager
Verify the application health endpoint
Add a minimal Django endpoint such as /health/ returning HTTP 200:
from django.http import JsonResponse
def health(request):
return JsonResponse({"status": "ok"})
Check it locally on the server:
curl --fail --silent http://127.0.0.1:8000/health/
Verify the reverse proxy path too
If Nginx is serving the public app, also verify through the externally served path or local vhost routing so you confirm the full request path is working, not just Gunicorn behind the proxy.
Example:
curl --fail --silent https://example.com/health/
Or from the server with the correct host header if needed:
curl --fail --silent -H "Host: example.com" http://127.0.0.1/health/
Review recent logs
journalctl -u gunicorn -n 50 --no-pager
Look for import errors, missing environment variables, database connection failures, or static path mistakes.
Add rollback protection to the GitHub Actions deployment flow
Keep previous releases in /srv/myapp/releases. If the new release fails after the symlink switch, roll back to the prior release:
ln -sfn /srv/myapp/releases/20260423-184500 /srv/myapp/current
sudo systemctl restart gunicorn
Automatic rollback is reasonable when:
- Gunicorn fails to start
- the health endpoint returns non-200
Manual rollback is safer when:
- migrations may have changed data irreversibly
- the failure affects background workers or external integrations
If all releases share one virtualenv, switching current back to an older release may not fully restore service when newer dependencies were already installed. For stronger rollback isolation, use per-release virtualenvs or deploy a prebuilt artifact or runtime.
If migrations are not backward compatible, code rollback alone may not restore service. In that case you may need a database restore or a forward-fix migration instead of a simple symlink rollback. Keep a tested rollback runbook before enabling automatic production deploys. For a deeper recovery process, see How to Roll Back a Django Deployment Safely.
Explanation
This setup works because it separates deployment into three concerns:
- CI orchestration in GitHub Actions
- runtime configuration on the server
- release switching through versioned directories and a
currentsymlink
That gives you repeatable deploys without storing production secrets in the repository or rebuilding the whole server each time.
SSH-based deployment is a good fit for:
- a single VPS
- small teams
- existing Gunicorn + Nginx + systemd setups
An artifact-based deploy is better when you want tighter control over what gets released. A Docker-based workflow is better when your production runtime already uses containers.
When manual GitHub Actions workflows become repetitive
Once you repeat this process across multiple Django projects, the release steps should move into a reusable shell script or workflow template. The first things worth standardizing are release directory creation, migration and collectstatic ordering, health checks, sudo-safe service restarts, and rollback commands. That reduces small differences between projects that often cause production mistakes.
Edge cases / notes
- Single VPS: this workflow is enough for many production apps if backups, monitoring, and rollback are in place.
- Docker deployments: replace the virtualenv and systemd steps with image build, image pull, and container restart logic.
- Long-running migrations: avoid blocking deploys with schema changes that lock large tables. Split migrations when needed.
- Media files: do not store user-uploaded media inside release directories. Keep media in
/srv/myapp/shared/mediaor external object storage. - Static files: make sure your web server serves static files from a stable location that still works after release switches and rollbacks.
- Timeouts and restarts: if Gunicorn startup is slow, systemd may mark the service failed. Check service timeouts and logs before assuming the workflow is broken.
- Proxy headers and TLS: if Nginx terminates TLS, make sure Django proxy settings, secure cookies, and HTTPS redirect behavior are already correct before automating deploys.
- Partial deploys: if dependency install or migration fails before the symlink switch, the current release stays active. That is another reason to avoid in-place updates.
Internal links
Before automating releases, review a full Django Deployment Checklist for Production.
If your app server and reverse proxy are not fully stable yet, set up Deploy Django with Gunicorn and Nginx on Ubuntu first.
If you are deploying an ASGI app instead of WSGI, see Deploy Django ASGI with Uvicorn and Nginx.
If you want a simpler HTTPS reverse proxy setup, see Deploy Django with Caddy and Automatic HTTPS.
For recovery procedures, keep a tested How to Roll Back a Django Deployment Safely.
FAQ
Should GitHub Actions run migrate on every deploy?
Usually yes, if your deployment process assumes schema and code move together. But only do this when migrations are non-interactive, tested, and compatible with your rollback plan.
Is SSH-based deployment secure enough for Django production?
Yes, if you use a non-root deploy user, restrict deploy triggers, pin the server host key, store the private key in GitHub secrets or environment secrets, and keep runtime application secrets on the server instead of in CI.
Should I deploy from source or from a built artifact?
For a small VPS, a tarball upload is usually fine. Built artifacts are better when you want a stricter, repeatable release package and less variation in what reaches production.
How do I roll back a failed Django GitHub Actions deployment?
Point /srv/myapp/current back to the previous release and restart Gunicorn. If the release included backward-incompatible migrations, or if a shared virtualenv introduced incompatible dependency changes, you may also need a database restore, dependency recovery, or a forward-fix deployment rather than a simple code rollback.