Self-Hosting a Django Web App: From Development to Deployment
This post walks through the full process of taking a finished Django project and making it accessible from the public internet, served from a PC you already own.
[01] Overall Architecture: Development vs. Production
Development and production environments are fundamentally different.
graph LR
subgraph Development
DEV_USER[Developer Browser] --> DEV_SERVER[runserver :8000]
DEV_SERVER --> DEV_DB[(SQLite)]
end
subgraph Production
EXT_USER[External User] --> ROUTER[Router
Port Forwarding]
ROUTER --> GUNICORN[Gunicorn
WSGI Server]
GUNICORN --> DJANGO[Django App]
DJANGO --> PROD_DB[(SQLite)]
end
style DEV_SERVER fill:#ffcccc
style GUNICORN fill:#ccffcc
| Item | Development | Production |
|---|---|---|
| Server |
runserver (single thread) |
Gunicorn (multi-worker) |
| DEBUG | True (exposes error detail) | False (hides errors) |
| SECRET_KEY | Hardcoding OK | Must come from env var |
| Access scope | localhost only | Public internet |
| Process management | Manual | systemd (auto-restart) |
[02] Introducing the Key Tools
Here are the main tools used in this post.
2-1. Gunicorn (Green Unicorn)
A WSGI HTTP server for Python. Used to run Python web frameworks like Django or Flask in production.
- WSGI (Web Server Gateway Interface): the standard interface between a Python web app and a web server
- Handles concurrent requests with multiple workers (runserver is single-threaded)
- Automatically respawns workers if a process dies
1
2
3
4
5
# Install
pip install gunicorn
# Run (3 workers, port 2929)
gunicorn myproject.wsgi:application --bind 0.0.0.0:2929 --workers 3
2-2. Nginx
A high-performance web server and reverse proxy. Handles static file serving, SSL termination, load balancing, and more.
- Sits in front of Gunicorn as a reverse proxy
- Serves static files (CSS, JS, images) directly without going through Gunicorn, improving performance
- Provides security features such as SSL (HTTPS) certificates and basic DDoS protection
1
External request -> Nginx (serves static files directly, forwards dynamic requests to Gunicorn) -> Gunicorn -> Django
For small services, Gunicorn alone is enough — no Nginx needed. This post focuses on the Gunicorn-only setup and covers the Nginx extension in [10].
2-3. systemd
Linux’s system and service manager. Handles service (daemon) registration, auto-start, and process supervision.
- Automatically starts the service when the PC boots
- Automatically restarts the process if it terminates abnormally
- Service logs are accessible via
journalctl
1
2
3
4
5
6
7
8
9
10
# Start / stop / restart a service
sudo systemctl start myservice
sudo systemctl stop myservice
sudo systemctl restart myservice
# Enable on boot
sudo systemctl enable myservice
# Tail logs
sudo journalctl -u myservice -f
2-4. ufw (Uncomplicated Firewall)
Ubuntu’s firewall management tool. A simple front-end for iptables.
1
2
3
4
5
6
7
8
# Enable the firewall
sudo ufw enable
# Allow a specific port
sudo ufw allow 2929/tcp
# View current rules
sudo ufw status
[03] Why You Shouldn’t Serve with runserver
Django’s runserver is for development only.
graph TD
A[Limits of runserver] --> B[Single thread
no concurrent access]
A --> C[No security hardening
assumes DEBUG mode]
A --> D[Inefficient static
file serving]
A --> E[No auto-recovery
on failure]
F[Advantages of Gunicorn] --> G[Multi-worker
handles concurrency]
F --> H[WSGI standard
production-tuned]
F --> I[Process management
auto-respawn workers]
F --> J[systemd integration
auto-start on boot]
Bottom line: runserver is a “demo”, Gunicorn is “the real thing”.
[04] Network Topology for External Access
To access your home PC from outside, you need DDNS + port forwarding.
sequenceDiagram
participant User as External User
participant DNS as DDNS Server
(iptime, etc.)
participant Router as Router
participant PC as Home PC
User->>DNS: Request xxx.xxx.com
DNS-->>User: Return router public IP
User->>Router: Connect to public-IP:2929
Router->>PC: Forward to internal-IP:2929
(port forwarding)
PC-->>Router: Django response
Router-->>User: Forward response
4-1. What is DDNS?
Home internet connections have IPs that change frequently (dynamic IPs). DDNS (Dynamic DNS) automatically maps the changing IP to a fixed domain name.
1
2
3
Router IP: 221.148.xxx.xxx (changes often)
| DDNS keeps it in sync
Domain: xxx.xxx.com (fixed)
Most iptime routers have DDNS built in and offer it for free.
4-2. What is port forwarding?
There are usually multiple devices behind your router. Port forwarding is the rule that decides which internal device a particular incoming port should be sent to.
graph LR
EXT[Public Internet] -->|:2929| ROUTER[Router]
EXT -->|:3131| ROUTER
ROUTER -->|:2929| PC1[PC - engwrite]
ROUTER -->|:3131| PC2[PC - other service]
style ROUTER fill:#ffffcc
When running multiple services on a single PC, distinguish them by port number:
-
xxx.xxx.com:2929-> engwrite (Django) -
xxx.xxx.com:3131-> another service
[05] Components of the Production Setup
graph TB
subgraph "Production Stack"
direction TB
A[systemd] -->|process management| B[Gunicorn]
B -->|WSGI protocol| C[Django]
C -->|ORM| D[(SQLite DB)]
E[.env environment file] -.->|SECRET_KEY
DEBUG
ALLOWED_HOSTS| C
F[ufw firewall] -.->|allow port 2929| B
end
| Component | Role | Without it? |
|---|---|---|
| Gunicorn | Python WSGI server, forwards requests to Django | No concurrency, slow |
| systemd | Process supervision, auto-start on boot | Manual startup after every reboot |
| Env vars | Manage secrets and settings outside the code | Leaked secrets, hardcoded config |
| Firewall | Allows only permitted ports from outside | Security exposure |
[06] Django Settings: Switching from Development to Production
Django settings should change for production.
graph LR
subgraph "Development mode"
D1[DEBUG = True]
D2[SECRET_KEY = hardcoded]
D3["ALLOWED_HOSTS = ['*']"]
end
subgraph "Production mode"
P1[DEBUG = False]
P2[SECRET_KEY = env var]
P3["ALLOWED_HOSTS = ['xxx.xxx.com']"]
end
D1 -->|change| P1
D2 -->|change| P2
D3 -->|change| P3
style D1 fill:#ffcccc
style D2 fill:#ffcccc
style D3 fill:#ffcccc
style P1 fill:#ccffcc
style P2 fill:#ccffcc
style P3 fill:#ccffcc
6-1. DEBUG = False
1
2
Development: errors expose code, variables, and SQL queries
Production: a single "Server Error (500)" line
6-2. SECRET_KEY
1
2
3
Development: any value is fine
Production: 50+ random characters, never expose
-> Otherwise session forgery, CSRF bypass, and other critical exploits become possible
6-3. ALLOWED_HOSTS
1
2
3
Development: ['*'] (allow all hosts)
Production: ['xxx.xxx.com', 'localhost'] (explicit domains only)
-> Prevents Host Header attacks
[07] Deployment Flow
flowchart TD
START([Start Deployment]) --> VENV[1. Create virtual env
python3 -m venv venv]
VENV --> DEPS[2. Install dependencies
pip install -r requirements.txt
pip install gunicorn]
DEPS --> ENV[3. Configure env vars
write .env
generate SECRET_KEY]
ENV --> MIGRATE[4. DB migration
python manage.py migrate]
MIGRATE --> SYSTEMD[5. Register systemd service
configure auto-start]
SYSTEMD --> FIREWALL[6. Open firewall port
ufw allow 2929/tcp]
FIREWALL --> ROUTER[7. Configure router
port forwarding + DDNS]
ROUTER --> TEST[8. External access test
xxx.xxx.com:2929]
TEST --> DONE([Deployment Done])
style START fill:#e6f3ff
style DONE fill:#e6ffe6
[08] systemd: Keep the Service “Always On”
systemd is Linux’s process manager. Register a service file and you get:
- Auto-start on boot — Django server starts when the PC boots
- Auto-restart on crash — process restarts 5 seconds after termination
- Structured log management
stateDiagram-v2
[*] --> Start: PC boot / systemctl start
Start --> Running: Gunicorn process spawned
Running --> Running: Handling requests
Running --> CrashStop: Process crash
CrashStop --> Start: Auto-restart after 5s
Running --> CleanStop: systemctl stop
CleanStop --> [*]
8-1. Service file structure
1
2
3
4
5
6
7
8
9
10
11
12
13
14
[Unit]
Description=Service description
After=network.target # Start after the network is ready
[Service]
User=kcloud # Run-as user
WorkingDirectory=/path/to/app # Project path
EnvironmentFile=/path/.env # Environment file
ExecStart=/path/gunicorn ... # Start command
Restart=always # Always restart
RestartSec=5 # Restart after 5 seconds
[Install]
WantedBy=multi-user.target # Auto-start on boot
[09] Running Multiple Services on a Single PC
You can run several web services on one machine.
graph TB
EXT[Public Internet]
EXT -->|:2929| S1
EXT -->|:3131| S2
EXT -->|:4040| S3
subgraph "Home PC (single machine)"
S1[engwrite
Gunicorn :2929
engwrite.service]
S2[Service B
Node.js :3131
service-b.service]
S3[Service C
Flask :4040
service-c.service]
end
style S1 fill:#ddeeff
style S2 fill:#ddfedd
style S3 fill:#ffddee
Rules:
- Each service uses a different port
- Each service gets its own systemd service file
- Add a separate port-forwarding rule per port on the router
[10] Security Checklist
Items you must verify before exposing a home server to the internet.
graph TD
SEC[Security Checklist] --> A[DEBUG = False
hide error details]
SEC --> B[SECRET_KEY
via env var]
SEC --> C[ALLOWED_HOSTS
explicit domain]
SEC --> D[Firewall
only required ports open]
SEC --> E[Password policy
min length + block common passwords]
SEC --> F[CSRF protection
token check on form submit]
A --> PASS{Pass?}
B --> PASS
C --> PASS
D --> PASS
E --> PASS
F --> PASS
PASS -->|All Yes| SAFE[Ready to deploy]
PASS -->|Any No| DANGER[Risky - fix required]
style SAFE fill:#ccffcc
style DANGER fill:#ffcccc
[11] Optional Extension: Nginx Reverse Proxy
For a more robust setup, you can add Nginx in front of Gunicorn.
graph LR
USER[External User] --> NGINX[Nginx
:2929]
NGINX -->|proxy| GUNICORN[Gunicorn
:8000 internal]
GUNICORN --> DJANGO[Django]
style NGINX fill:#ccffcc
style GUNICORN fill:#ffffcc
| Gunicorn only | Nginx + Gunicorn |
|---|---|
| Simpler setup | Efficient static file serving |
| Good for small scale | Can configure SSL (HTTPS) |
| Directly exposed externally | Basic DDoS mitigation |
At this project’s scale, Gunicorn alone is enough. Add Nginx later, once you have more users or need HTTPS.
[12] Full Architecture Summary
graph TB
subgraph "Internet"
USER[External User
Browser]
end
subgraph "Router"
DDNS[DDNS: xxx.xxx.com]
FWD[Port forwarding
:2929 -> internal-IP:2929]
end
subgraph "Home PC (Ubuntu)"
FW[ufw firewall
allow 2929/tcp]
SD[systemd
process manager]
GU[Gunicorn
WSGI server
3 workers]
DJ[Django
engwrite app]
DB[(SQLite
db.sqlite3)]
ENV[.env
SECRET_KEY
settings]
end
USER -->|xxx.xxx.com:2929| DDNS
DDNS --> FWD
FWD --> FW
FW --> SD
SD -->|manages| GU
GU -->|WSGI| DJ
DJ --> DB
ENV -.-> DJ
style USER fill:#e6f3ff
style GU fill:#ccffcc
style DJ fill:#ddeeff
style DB fill:#ffffcc
Once you understand this structure, you can deploy any web framework (Flask, FastAPI, Express, etc.) to a home server using the same pattern.