Tutorials · 3 min read · 13 October 2026
Deploy Django with Docker and GitHub Actions
Push to main and your Django app rebuilds and redeploys itself. A Dockerfile, a compose file and one GitHub Actions workflow.
By the end of this, every push to main builds a fresh Docker image of your Django app, pushes it to GitHub's container registry, and restarts it on your server with migrations applied. No more SSH-ing in to git pull by hand.
What you need
- A Django project with a
requirements.txtthat includesgunicorn. - A Linux server (any VPS) with Docker and the Docker Compose plugin installed.
- A GitHub repository for the project.
1. The Dockerfile
Put this in the project root. Change config.wsgi to your project's WSGI module.
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Collect static files into the image (needs STATIC_ROOT set in settings)
RUN python manage.py collectstatic --noinput
EXPOSE 8000
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3"]Add a .dockerignore so you don't copy junk into the image:
.git
.env
__pycache__/
*.pyc
venv/
node_modules/
media/2. The compose file on the server
On your server, create a folder such as /srv/myapp with this docker-compose.yml and a .env file holding your secrets. Replace the image name with your own, in lowercase.
services:
web:
image: ghcr.io/yourname/myapp:latest
env_file: .env
ports:
- "8000:8000"
depends_on:
- db
restart: unless-stopped
db:
image: postgres:16
environment:
POSTGRES_DB: myapp
POSTGRES_USER: myapp
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
restart: unless-stopped
volumes:
pgdata:Put a reverse proxy such as Nginx or Caddy in front of port 8000 for HTTPS. Caddy is the quickest, because it gets certificates for you automatically.
3. Repository secrets
In your GitHub repo, go to Settings, then Secrets and variables, then Actions, and add:
SERVER_HOST: your server's IP or domain.SERVER_USER: the user that runs Docker on the server.SERVER_SSH_KEY: a private SSH key whose public half is in that user's~/.ssh/authorized_keys.
4. The workflow
Save this as .github/workflows/deploy.yml.
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/yourname/myapp:latest
- uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SERVER_SSH_KEY }}
script: |
cd /srv/myapp
docker compose pull web
docker compose up -d web
docker compose exec -T web python manage.py migrate --noinputImage names on GitHub's registry must be lowercase. If your GitHub username has capitals, type the lowercase name into tags yourself rather than building it from github.repository.
5. Let the server pull the image
If the package is private, log the server in to the registry once, using a GitHub personal access token with the read:packages scope:
echo YOUR_TOKEN | docker login ghcr.io -u yourname --password-stdinPush and watch it deploy
Commit everything and push to main. Open the Actions tab on GitHub and you'll see the build, the push and the deploy run in order. From now on, merging to main is your deploy button.
Common problems
- collectstatic fails during the build: set
STATIC_ROOTin settings, and make sure settings don't crash when secrets are missing at build time. - Permission denied on SSH: the public key isn't in
authorized_keysforSERVER_USER, or the key secret has extra spaces. - Old version still running: check that the
tagsin the workflow and theimagein compose are exactly the same name.
