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.txt that includes gunicorn.
- A Linux server (any VPS) with Docker and the Docker Compose plugin installed.
- A GitHub repository for the project.
- The Dockerfile
Put this in the project root. Change config.wsgi to your project's WSGI module.
dockerfile
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:
text
.git
.env
__pycache__/
*.pyc
venv/
node_modules/
media/
- 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.
yaml
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.
- 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.
- The workflow
Save this as .github/workflows/deploy.yml.
{% raw %}
yaml
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 noinput
{% endraw %}
Image 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.
- 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:
bash
echo YOUR_TOKEN | docker login ghcr.io -u yourname --password-stdin
Push 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_ROOT in 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_keys for SERVER_USER, or the key secret has extra spaces.
Old version still running: check that the tags in the workflow and the image in compose are exactly the same name.
How do you handle deploys on your side projects? Still SSH and git pull, or something fancier? Tell me in the comments.
I write more of these at amanxavier.com, mostly about Django, Next.js and building products from India.
Top comments (0)