Deploy Django on a VPS with Docker: Step-by-Step Guide
Source: Dev.to
Who is this guide for?
- You are curious about self‑hosting and want to learn the fundamentals.
- You want to run small or side projects without paying for expensive managed services.
- You want to save money on hosting costs.
Who should skip this guide?
- You need the absolute fastest way to get started (e.g., a managed PaaS).
- You want to avoid ongoing maintenance.
- You need to run mission‑critical workloads and aren’t comfortable with server administration.
Looking for a simple, managed way to get started? Check out our product: sliplane.io.
Prerequisites
| Requirement | Details |
|---|---|
| Django application | Ready to be containerized |
| VPS account | Hetzner (or any other preferred provider) |
| Domain name | For SSL termination and reverse proxy |
| Docker knowledge | Basic familiarity (optional but helpful) |
We will use Docker to containerize the Django app and PostgreSQL as the database. The deployment will be performed with Docker Compose and Caddy as a reverse proxy for automatic SSL.
Why Docker?
Docker lets you define everything your Django app needs in a Dockerfile. You can then spin up an isolated container with all dependencies installed, avoiding “it works on my machine” problems.
- Consistent environment – same Python version, same system libraries.
- Separate services – run Django and PostgreSQL in different containers without dependency conflicts.
- Huge ecosystem – pre‑built images for databases, caches, web servers, etc.
If you want a deeper dive, see my [Django in Docker tutorial].
TL;DR – Minimal Docker Setup
Create two files in the root of your Django project:
Dockerfile
# ---------- Stage 1: Builder ----------
FROM python:3.13-slim AS builder
# Create and set the app directory
RUN mkdir /app
WORKDIR /app
# Optimize Python
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
# Upgrade pip
RUN pip install --upgrade pip
# Cache dependencies
COPY requirements.txt /app/
RUN pip install --no-cache-dir -r requirements.txt
# ---------- Stage 2: Production ----------
FROM python:3.13-slim
# Create a non‑root user and app directory
RUN useradd -m -r appuser && \
mkdir /app && \
chown -R appuser /app
# Copy Python packages from builder
COPY --from=builder /usr/local/lib/python3.13/site-packages/ /usr/local/lib/python3.13/site-packages/
COPY --from=builder /usr/local/bin/ /usr/local/bin/
WORKDIR /app
# Copy application code (preserve ownership)
COPY --chown=appuser:appuser . .
# Optimize Python (again, for the final image)
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
# Switch to non‑root user
USER appuser
# Expose the port Django will run on
EXPOSE 8000
# Start the app with Gunicorn
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "3", "myproject.wsgi:application"]
docker-compose.yml
services:
web:
build: .
ports:
- "8000:8000"
env_file:
- .env
depends_on:
- db
db:
image: postgres:17
volumes:
- postgres_data:/var/lib/postgresql/data
env_file:
- .env
volumes:
postgres_data:
Local Testing
- Install Docker (if you haven’t already).
- Run:
docker compose up
The Django app will be reachable at http://localhost:8000.
Make sure everything works locally before moving to production.
Production‑Ready Image Workflow
The compose file above is great for development but not ideal for production because the server rebuilds the image on every deploy. In production you should:
-
Build the image locally (or in CI).
docker build -t my-django-app . -
Push it to a container registry (e.g., GitHub Container Registry, GHCR).
# Authenticate echo $GITHUB_TOKEN | docker login ghcr.io -u YOUR_GITHUB_USERNAME --password-stdin # Tag and push docker tag my-django-app ghcr.io/YOUR_GITHUB_USERNAME/YOUR_REPO_NAME:latest docker push ghcr.io/YOUR_GITHUB_USERNAME/YOUR_REPO_NAME:latestUsing
latestis fine for a tutorial, but consider semantic versioning or commit hashes for real projects. -
Automate this step with a CI/CD pipeline (e.g., GitHub Actions). I have a separate post on that topic.
Setting Up the VPS
1️⃣ Choose a VPS Provider
We’ve compared several cheap providers in a separate post. Hetzner stands out for affordability, reliability, and performance. Using our affiliate link gives you €20 credit.
2️⃣ Size Your VPS
Start with the smallest available plan (e.g., 1 vCPU, 2 GB RAM). You can always scale up later as traffic grows.
3️⃣ Server Preparation
-
Create a new server (Ubuntu 22.04 LTS is a solid choice).
-
Update the system
sudo apt update && sudo apt upgrade -y -
Install Docker & Docker Compose
# Install Docker curl -fsSL https://get.docker.com -o get-docker.sh sudo sh get-docker.sh # Enable Docker to start on boot sudo systemctl enable docker # Install Docker Compose (v2 plugin) sudo apt install -y docker-compose-plugin -
Create a non‑root user (optional but recommended)
sudo adduser deployer sudo usermod -aG docker deployer -
Set up a firewall (UFW)
sudo ufw allow OpenSSH sudo ufw allow 80/tcp # HTTP sudo ufw allow 443/tcp # HTTPS sudo ufw enable
4️⃣ Deploy the Application
-
Log in to the server and clone your repository (or pull the image directly).
git clone https://github.com/YOUR_GITHUB_USERNAME/YOUR_REPO_NAME.git cd YOUR_REPO_NAME -
Create a
.envfile with your Django and PostgreSQL settings (e.g.,DJANGO_SECRET_KEY,POSTGRES_PASSWORD, etc.). -
Create a
docker-compose.prod.ymlthat pulls the pre‑built image instead of building locally:version: "3.9" services: web: image: ghcr.io/YOUR_GITHUB_USERNAME/YOUR_REPO_NAME:latest env_file: - .env depends_on: - db restart: unless-stopped db: image: postgres:17 volumes: - postgres_data:/var/lib/postgresql/data env_file: - .env restart: unless-stopped volumes: postgres_data: -
Run the stack
docker compose -f docker-compose.prod.yml up -d -
Set up Caddy as a reverse proxy (handles HTTPS automatically). Create a
Caddyfile:yourdomain.com { reverse_proxy web:8000 }Then add Caddy to the compose file:
services: caddy: image: caddy:2 ports: - "80:80" - "443:443" volumes: - ./Caddyfile:/etc/caddy/Caddyfile - caddy_data:/data restart: unless-stopped volumes: caddy_data:Deploy again:
docker compose -f docker-compose.prod.yml up -dCaddy will obtain and renew TLS certificates from Let’s Encrypt automatically.
Recap Checklist
- Dockerize Django app (
Dockerfile,docker-compose.yml). - Build and push image to a container registry (GHCR).
- Choose and provision a VPS (Hetzner recommended).
- Install Docker & Docker Compose on the server.
- Create production‑ready
docker-compose.prod.yml. - Set up environment variables (
.env). - Deploy stack with
docker compose up -d. - Add Caddy reverse proxy for HTTPS.
You now have a fully self‑hosted Django application running on a cheap VPS, with automated SSL and a clean, reproducible deployment pipeline. 🎉
Happy self‑hosting!
Quick‑Start Checklist for a Small Django + Postgres VPS
You’ll be surprised how far a basic VPS can get you – a Postgres database and a tiny Django app can run on as little as 1 GB of RAM. You can always resize later. Start simple: run both the database and the Django app on the same server (see our guide on running multiple apps on a single VPS). Only worry about more complex setups when you actually need to scale.
1. Where do I want my VPS to be located?
2. What processor architecture – x86 or ARM?
- ARM is getting more popular because it’s cheaper, but we’ve run into availability issues and occasional software‑compatibility problems.
- We usually stick with x86. If you develop on x86 and deploy on ARM, you might hit unexpected issues.
- The good thing about VPSs: you only pay for the time your server runs, so you can test an ARM server for a few cents and switch back to x86 if needed.
3. Shared or dedicated?
- Start with shared and upgrade if necessary.
- Shared servers are generally slower and can be unpredictable, so provider choice matters.
- With Hetzner, we’ve had good experiences – shared‑server performance has been consistent and reliable.
4. What OS should I choose?
- Ubuntu (latest LTS) is a safe default – it’s what we learned on and has the biggest community support.
- Differences between distributions are mostly a matter of preference. If you’re unsure, go with Ubuntu.
- In general, stick with what’s popular so you can find help online when needed.
After the Server Is Up – First Steps
-
Connect via SSH
ssh root@your-server-ipReplace
your-server-ipwith the IP address shown in your provider’s dashboard or welcome email. -
Hardening the Server
- Disable root login (use a regular user with
sudo). - Use SSH keys instead of passwords.
- Set up a firewall (UFW or iptables).
- Keep the system updated (
apt update && apt upgrade -y). - Install fail2ban to block brute‑force attempts.
- Consider running a vetted hardening script – just be sure you understand what it does.
- Disable root login (use a regular user with
-
Network‑level firewall (Hetzner)
- Allow only ports 22 (SSH), 80 (HTTP), and 443 (HTTPS).
- Using the Hetzner firewall in addition to a local firewall saves resources and blocks traffic before it reaches your server (useful when Docker can bypass local rules).
Install Docker
# Remove old versions
sudo apt-get remove docker docker-engine docker.io containerd runc
# Set up the repository
sudo apt-get update
sudo apt-get install \
ca-certificates \
curl \
gnupg \
lsb-release
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | \
sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] \
https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-compose-plugin
Verify the installation:
docker --version
docker compose version
Deploy the Django App with Docker Compose
1. Prepare environment variables
Create a .env file in the project directory on the server (never commit this file to Git):
POSTGRES_DB=mydb
POSTGRES_USER=myuser
POSTGRES_PASSWORD=mypassword
DATABASE_URL=postgres://myuser:mypassword@db:5432/mydb
DJANGO_SECRET_KEY=your-secret-key
Tip: For production‑grade security, consider using Docker secrets instead of a plain
.envfile.
2. Production‑ready docker‑compose.yml
services:
web:
image: ghcr.io/YOUR_GITHUB_USERNAME/YOUR_REPO_NAME:latest
ports:
- "8000:8000"
env_file:
- .env
depends_on:
- db
db:
image: postgres:17
volumes:
- postgres_data:/var/lib/postgresql/data
env_file:
- .env
caddy:
image: caddy:2.10.2
restart: unless-stopped
cap_add:
- NET_ADMIN
ports:
- "80:80"
- "443:443"
volumes:
- $PWD/conf:/etc/caddy
- caddy_data:/data
- caddy_config:/config
volumes:
postgres_data:
caddy_data:
caddy_config:
3. Configure Caddy
Create a conf directory next to docker‑compose.yml and add a Caddyfile:
yourdomain.com {
reverse_proxy web:8000
}
Replace yourdomain.com with your actual domain name.
Caddy will automatically obtain a Let’s Encrypt certificate for the domain.
4. Point DNS to the VPS
Add A (or AAAA) records for your domain that point to the VPS IP address. Propagation usually takes a few minutes.
5. Bring the stack up
docker compose up -d
You should now be able to reach your Django app at https://yourdomain.com.
6. Deploying a new version
docker compose pull web # fetch the latest image
docker compose up -d web # restart only the web service
You can automate this with a CI/CD pipeline (GitHub Actions, GitLab CI, etc.).
Recap – What We Used in This Guide
| Component | Purpose |
|---|---|
| Docker | Containerize the Django app |
| Docker Compose | Orchestrate Django, Postgres, and Caddy |
| Postgres | Relational database |
| Caddy | Reverse proxy + automatic TLS |
| Hetzner firewall | Network‑level traffic filtering |
| UFW / iptables | Host‑level firewall |
| fail2ban | Protect SSH and other services from brute‑force attacks |
| .env file (or Docker secrets) | Store sensitive configuration |
Final Thoughts
Deploying Django on a modest VPS is cheap, fast, and an excellent way to learn about infrastructure. Start simple, harden early, and let Docker and Caddy handle most of the heavy lifting. When traffic grows, you can split services onto separate machines, add a load balancer, or move to a managed platform—but for many small‑to‑medium projects, the setup above is more than enough. Happy coding!