Deploy Django on a VPS with Docker: Step-by-Step Guide

Published: (December 26, 2025 at 11:54 AM EST)
8 min read
Source: Dev.to

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

RequirementDetails
Django applicationReady to be containerized
VPS accountHetzner (or any other preferred provider)
Domain nameFor SSL termination and reverse proxy
Docker knowledgeBasic 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

  1. Install Docker (if you haven’t already).
  2. 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:

  1. Build the image locally (or in CI).

    docker build -t my-django-app .
  2. 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:latest

    Using latest is fine for a tutorial, but consider semantic versioning or commit hashes for real projects.

  3. 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

  1. Create a new server (Ubuntu 22.04 LTS is a solid choice).

  2. Update the system

    sudo apt update && sudo apt upgrade -y
  3. 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
  4. Create a non‑root user (optional but recommended)

    sudo adduser deployer
    sudo usermod -aG docker deployer
  5. 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

  1. 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
  2. Create a .env file with your Django and PostgreSQL settings (e.g., DJANGO_SECRET_KEY, POSTGRES_PASSWORD, etc.).

  3. Create a docker-compose.prod.yml that 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:
  4. Run the stack

    docker compose -f docker-compose.prod.yml up -d
  5. 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 -d

    Caddy 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

  1. Connect via SSH

    ssh root@your-server-ip

    Replace your-server-ip with the IP address shown in your provider’s dashboard or welcome email.

  2. 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.
  3. 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 .env file.

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

ComponentPurpose
DockerContainerize the Django app
Docker ComposeOrchestrate Django, Postgres, and Caddy
PostgresRelational database
CaddyReverse proxy + automatic TLS
Hetzner firewallNetwork‑level traffic filtering
UFW / iptablesHost‑level firewall
fail2banProtect 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!

Back to Blog

Related posts

Read more »