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

Deployment complete! Your ZineCore2 server is now running in production.
Copyright ©2026 ZineCore2 Contributors,