How-To Guides
Deploy to Production
Complete guide to deploying ZineCore2 in production
This guide covers deploying ZineCore2 to production with nginx, gunicorn, and PostgreSQL.
Prerequisites
- ✅ Server with Ubuntu 22.04+ or similar Linux distribution
- ✅ Root or sudo access
- ✅ Domain name configured (optional but recommended)
- ✅ Basic Linux administration skills
Architecture Overview
Internet → nginx (reverse proxy) → gunicorn (WSGI server) → Django → PostgreSQL
Components:
- nginx — Web server, handles static files, reverse proxy
- gunicorn — Python WSGI HTTP server
- Django — Application framework
- PostgreSQL — Database
- systemd — Service management
Step 1: Server Setup
Update System
sudo apt update
sudo apt upgrade -y
Install Dependencies
sudo apt install -y \
python3.12 \
python3.12-venv \
python3-pip \
postgresql-16 \
postgresql-contrib \
nginx \
git \
curl
Create System User
# Create dedicated user for the application
sudo useradd --system --shell /bin/bash --home /opt/zinecore2 zinecore2
sudo mkdir -p /opt/zinecore2
sudo chown zinecore2:zinecore2 /opt/zinecore2
Step 2: Clone and Setup Application
Switch to Application User
sudo -u zinecore2 -i
cd /opt/zinecore2
Clone Repository
git clone https://github.com/ZineCore2/server.git
cd server
git submodule update --init
Create Virtual Environment
curl -LsSf https://astral.sh/uv/install.sh | sh
source $HOME/.cargo/env
uv venv
source .venv/bin/activate
Install Dependencies
uv sync --no-dev
Step 3: Configure PostgreSQL
Create Database
# As postgres user
sudo -u postgres psql
-- Create database and user
CREATE DATABASE zinecore2_prod;
CREATE USER zinecore2_prod WITH PASSWORD 'STRONG_PASSWORD_HERE';
GRANT ALL PRIVILEGES ON DATABASE zinecore2_prod TO zinecore2_prod;
\q
Configure PostgreSQL
# Edit pg_hba.conf to allow password authentication
sudo nano /etc/postgresql/16/main/pg_hba.conf
Add line:
local zinecore2_prod zinecore2_prod md5
Restart PostgreSQL:
sudo systemctl restart postgresql
Step 4: Configure Django
Create Production Settings
# As zinecore2 user
cd /opt/zinecore2/server/backend
Create .env:
cat > .env << 'EOF'
DJANGO_SETTINGS_MODULE=zinecore.settings.production
SECRET_KEY=GENERATE_LONG_RANDOM_SECRET_KEY_HERE
DEBUG=False
ALLOWED_HOSTS=yourdomain.com,www.yourdomain.com
CORS_ALLOWED_ORIGINS=https://yourdomain.com
DATABASE_URL=postgresql://zinecore2_prod:STRONG_PASSWORD_HERE@localhost/zinecore2_prod
STATIC_ROOT=/opt/zinecore2/server/staticfiles
MEDIA_ROOT=/opt/zinecore2/server/media
EOF
Generate Secret Key
python3 -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())'
Copy output to SECRET_KEY in .env.
Create Production Settings File
Ensure backend/zinecore/settings/production.py exists:
# backend/zinecore/settings/production.py
from .base import *
from decouple import config
DEBUG = config('DEBUG', default=False, cast=bool)
SECRET_KEY = config('SECRET_KEY')
ALLOWED_HOSTS = config('ALLOWED_HOSTS', default='').split(',')
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': config('DB_NAME', default='zinecore2_prod'),
'USER': config('DB_USER', default='zinecore2_prod'),
'PASSWORD': config('DB_PASSWORD'),
'HOST': config('DB_HOST', default='localhost'),
'PORT': config('DB_PORT', default='5432'),
}
}
# Security settings
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
# CORS
CORS_ALLOWED_ORIGINS = config('CORS_ALLOWED_ORIGINS', default='').split(',')
CORS_ALLOW_CREDENTIALS = True
# Static files
STATIC_URL = '/static/'
STATIC_ROOT = config('STATIC_ROOT', default='/opt/zinecore2/server/staticfiles')
MEDIA_URL = '/media/'
MEDIA_ROOT = config('MEDIA_ROOT', default='/opt/zinecore2/server/media')
Step 5: Initialize Database
cd /opt/zinecore2/server/backend
# Run migrations
DJANGO_SETTINGS_MODULE=zinecore.settings.production \
../.venv/bin/python manage.py migrate
# Load vocabularies
DJANGO_SETTINGS_MODULE=zinecore.settings.production \
../.venv/bin/python manage.py load_vocabularies
# Load geographic data
DJANGO_SETTINGS_MODULE=zinecore.settings.production \
../.venv/bin/python manage.py load_geonames
# Collect static files
DJANGO_SETTINGS_MODULE=zinecore.settings.production \
../.venv/bin/python manage.py collectstatic --noinput
# Create superuser
DJANGO_SETTINGS_MODULE=zinecore.settings.production \
../.venv/bin/python manage.py createsuperuser
Step 6: Configure Gunicorn
Create Gunicorn Configuration
sudo nano /opt/zinecore2/server/gunicorn.conf.py
# gunicorn.conf.py
bind = '127.0.0.1:8000'
workers = 4 # (2 x CPU cores) + 1
worker_class = 'sync'
timeout = 120
keepalive = 5
# Logging
accesslog = '/var/log/zinecore2/gunicorn-access.log'
errorlog = '/var/log/zinecore2/gunicorn-error.log'
loglevel = 'info'
# Process naming
proc_name = 'zinecore2'
# Server mechanics
daemon = False
pidfile = '/run/zinecore2/gunicorn.pid'
Create Log Directory
sudo mkdir -p /var/log/zinecore2
sudo chown zinecore2:zinecore2 /var/log/zinecore2
sudo mkdir -p /run/zinecore2
sudo chown zinecore2:zinecore2 /run/zinecore2
Test Gunicorn
cd /opt/zinecore2/server/backend
DJANGO_SETTINGS_MODULE=zinecore.settings.production \
../.venv/bin/gunicorn zinecore.wsgi:application -c ../gunicorn.conf.py
Test in another terminal:
curl http://localhost:8000/api/
Stop with Ctrl+C if working.
Step 7: Configure systemd
Create systemd Service
sudo nano /etc/systemd/system/zinecore2.service
[Unit]
Description=ZineCore2 gunicorn daemon
Requires=zinecore2.socket
After=network.target postgresql.service
[Service]
Type=notify
User=zinecore2
Group=zinecore2
RuntimeDirectory=zinecore2
WorkingDirectory=/opt/zinecore2/server/backend
Environment="PATH=/opt/zinecore2/server/.venv/bin"
Environment="DJANGO_SETTINGS_MODULE=zinecore.settings.production"
ExecStart=/opt/zinecore2/server/.venv/bin/gunicorn \
--config /opt/zinecore2/server/gunicorn.conf.py \
zinecore.wsgi:application
ExecReload=/bin/kill -s HUP $MAINPID
KillMode=mixed
TimeoutStopSec=5
PrivateTmp=true
[Install]
WantedBy=multi-user.target
Create Socket
sudo nano /etc/systemd/system/zinecore2.socket
[Unit]
Description=ZineCore2 gunicorn socket
[Socket]
ListenStream=127.0.0.1:8000
User=zinecore2
[Install]
WantedBy=sockets.target
Enable and Start Service
sudo systemctl daemon-reload
sudo systemctl enable zinecore2.socket
sudo systemctl enable zinecore2.service
sudo systemctl start zinecore2.socket
sudo systemctl start zinecore2.service
Check Status
sudo systemctl status zinecore2.service
Should show active (running).
Step 8: Configure nginx
Create nginx Configuration
sudo nano /etc/nginx/sites-available/zinecore2
# Upstream gunicorn
upstream zinecore2_server {
server 127.0.0.1:8000 fail_timeout=0;
}
# Redirect HTTP to HTTPS
server {
listen 80;
server_name yourdomain.com www.yourdomain.com;
return 301 https://$host$request_uri;
}
# HTTPS server
server {
listen 443 ssl http2;
server_name yourdomain.com www.yourdomain.com;
# SSL certificates (from Let's Encrypt)
ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
# SSL configuration
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512;
# Security headers
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
# Logging
access_log /var/log/nginx/zinecore2-access.log;
error_log /var/log/nginx/zinecore2-error.log;
# Client body size (for file uploads)
client_max_body_size 10M;
# Static files
location /static/ {
alias /opt/zinecore2/server/staticfiles/;
expires 30d;
add_header Cache-Control "public, immutable";
}
# Media files
location /media/ {
alias /opt/zinecore2/server/media/;
expires 30d;
}
# Proxy to gunicorn
location / {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://zinecore2_server;
# Timeouts
proxy_connect_timeout 120;
proxy_send_timeout 120;
proxy_read_timeout 120;
}
}
Enable Site
sudo ln -s /etc/nginx/sites-available/zinecore2 /etc/nginx/sites-enabled/
sudo nginx -t # Test configuration
sudo systemctl restart nginx
Step 9: Setup SSL with Let's Encrypt
Install Certbot
sudo apt install -y certbot python3-certbot-nginx
Obtain Certificate
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com
Follow prompts to:
- Enter email
- Agree to terms
- Choose to redirect HTTP to HTTPS
Test Auto-Renewal
sudo certbot renew --dry-run
Certificates auto-renew via systemd timer.
Step 10: Firewall Configuration
Configure UFW
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable
sudo ufw status
Should show:
Status: active
To Action From
-- ------ ----
OpenSSH ALLOW Anywhere
Nginx Full ALLOW Anywhere
Step 11: Monitoring and Maintenance
Log Locations
Application logs:
/var/log/zinecore2/gunicorn-access.log
/var/log/zinecore2/gunicorn-error.log
nginx logs:
/var/log/nginx/zinecore2-access.log
/var/log/nginx/zinecore2-error.log
systemd logs:
sudo journalctl -u zinecore2.service -f
Backup Database
# Create backup
sudo -u postgres pg_dump zinecore2_prod > backup-$(date +%Y%m%d).sql
# Automate with cron
sudo crontab -e
Add:
# Daily backup at 2 AM
0 2 * * * sudo -u postgres pg_dump zinecore2_prod > /opt/backups/zinecore2-$(date +\%Y\%m\%d).sql
Update Application
cd /opt/zinecore2/server
git pull
git submodule update --remote
source .venv/bin/activate
uv sync --no-dev
cd backend
DJANGO_SETTINGS_MODULE=zinecore.settings.production \
../.venv/bin/python manage.py migrate
DJANGO_SETTINGS_MODULE=zinecore.settings.production \
../.venv/bin/python manage.py collectstatic --noinput
# Restart service
sudo systemctl restart zinecore2.service
Troubleshooting
Service Won't Start
Check logs:
sudo journalctl -u zinecore2.service -n 50
Common issues:
- Database connection failed (check .env)
- Port already in use
- Permission errors
502 Bad Gateway
Cause: nginx can't connect to gunicorn
Check:
sudo systemctl status zinecore2.service
sudo netstat -tlnp | grep 8000
Static Files Not Loading
Cause: Static files not collected or nginx misconfigured
Solution:
cd /opt/zinecore2/server/backend
DJANGO_SETTINGS_MODULE=zinecore.settings.production \
../.venv/bin/python manage.py collectstatic --noinput
# Check nginx config
sudo nginx -t
Database Connection Errors
Check .env file:
cat /opt/zinecore2/server/backend/.env
Test connection:
psql -U zinecore2_prod -d zinecore2_prod -h localhost
Security Checklist
- DEBUG = False
- Strong SECRET_KEY (40+ characters)
- SSL/HTTPS configured
- Firewall configured (only 80/443/22 open)
- Database password is strong
- Regular backups configured
- Security headers configured in nginx
- CORS configured for specific origins only
- Application runs as non-root user
- Static files served by nginx (not Django)
Performance Optimization
gunicorn Workers
# gunicorn.conf.py
workers = multiprocessing.cpu_count() * 2 + 1
nginx Caching
# Add to nginx config
location /static/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
PostgreSQL Tuning
sudo nano /etc/postgresql/16/main/postgresql.conf
shared_buffers = 256MB
effective_cache_size = 1GB
work_mem = 4MB
Restart PostgreSQL:
sudo systemctl restart postgresql
Next Steps
- Development: Troubleshooting — Debug production issues
- API Reference — Test your deployment
- Architecture — Understanding the system
Deployment complete! Your ZineCore2 server is now running in production.