Deployment Guide for Django + Vue.js on a VPS

By Saurav Sharma

Overview

This guide provides a secure, production-ready deployment process for the Focus Timer application on a Linux VPS. It follows best practices at each step and includes brief explanations to help beginners understand.

Prerequisites

1. VPS Setup

1.1 Create Unprivileged User

Why? Running services as non-root improves security.

# Add a user, no password login, add to sudo group
adduser --disabled-password --gecos "" focususer
usermod -aG sudo focususer
# Set up SSH access for the new user
# Copy root's authorized keys to the new user
mkdir -p /home/focususer/.ssh
cp /root/.ssh/authorized_keys /home/focususer/.ssh/authorized_keys
# Set ownership and permissions
chown -R focususer:focususer /home/focususer/.ssh
chmod 700 /home/focususer/.ssh
chmod 600 /home/focususer/.ssh/authorized_keys

Explanation:

1.2 Secure SSH Access

Why? Prevent unauthorized root or password-based logins.

# On your local machine, generate SSH key if needed:
ssh-keygen -t ed25519 -C "[email protected]"

# Copy public key to server:
ssh-copy-id [email protected]

On server, edit /etc/ssh/sshd_config:

  Port 22
  PermitRootLogin no
  PasswordAuthentication no
  ChallengeResponseAuthentication no
  UsePAM yes
  sudo systemctl reload ssh

Explanation:

1.2.1. Logging in as focususer

Why? Operating as a non-root user mitigates the risk of accidental system-wide changes.

# Exit the current root SSH session:
exit

# From your local machine, SSH back in as the unprivileged user:
ssh [email protected]

Explanation:

1.3 Firewall & Fail2ban

Why? Limit open ports and block brute-force attempts.

# Install UFW & Fail2ban
sudo apt update && sudo apt install -y ufw fail2ban

# Basic UFW rules
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable

# Configure Fail2ban (e.g., /etc/fail2ban/jail.local)
sudo tee /etc/fail2ban/jail.local <<EOF
[DEFAULT]
bantime  = 3600
findtime = 600
maxretry = 5

[sshd]
enabled = true
port    = 22
filter  = sshd
logpath = /var/log/auth.log
EOF
sudo systemctl restart fail2ban

Explanation:

1.4 Automatic Security Updates

Why? Keep critical packages patched.

sudo apt install -y unattended-upgrades
sudo dpkg-reconfigure unattended-upgrades

Explanation:

2. System Dependencies

Install tools required for building and deployment.

sudo apt install -y git build-essential curl libpq-dev

Explanation:

3. Database: PostgreSQL

sudo apt install -y postgresql postgresql-contrib

# Create database and user
check ./setup.md for PostgreSQL setup

# Secure PostgreSQL: only listen locally and enforce password auth
sudo sed -i "s/#listen_addresses = 'localhost'/listen_addresses = 'localhost'/" /etc/postgresql/*/main/postgresql.conf
# Only allow local socket and password auth for TCP
host    all             all             127.0.0.1/32            md5
sudo systemctl restart postgresql

Brief: PostgreSQL is reliable and scalable for production.

4. Redis for Celery

sudo apt install -y redis-server
sudo systemctl enable --now redis-server.service

# Secure Redis: bind only to localhost, enable protected mode, optional password
sudo sed -i "s/^bind .*/bind 127.0.0.1 ::1/" /etc/redis/redis.conf
sudo sed -i "s/^protected-mode no/protected-mode yes/" /etc/redis/redis.conf
sudo sed -i "s/# requirepass foobared/requirepass StrongRedisPassw0rd/" /etc/redis/redis.conf
sudo systemctl restart redis

Brief: Redis acts as broker and result backend for Celery.

5. Codebase Deployment

5.1 Clone Repository

# Switch to DIR where you want to keep your project
# ( using a sample project name, replace with your actual repo )
git clone https://github.com/your-repo/focus-timer-django-vue.git
cd focus-timer-django-vue

5.2 Environment Variables

Why? Keep secrets out of source code.

# In project root, create .env
touch .env
# Populate .env with necessary variables
# Secure .env file permissions
chmod 600 .env

6. Python Virtual Environment

cd backend
python3 -m venv .venv
source .venv/bin/activate
pip install uv
pip install -r requirements.txt

7. Frontend Build (Vue.js)

cd ../frontend-vue
# install node
curl -fsSL https://raw.githubusercontent.com/mklement0/n-install/stable/bin/n-install | bash -s 22

npm run build

Brief: Outputs static assets in dist/.

8. Django Migrations & Static Files

cd ../backend
source .venv/bin/activate
python manage.py migrate
python manage.py collectstatic --noinput

9. Application Server: Gunicorn

pip install gunicorn

Create /etc/systemd/system/gunicorn.service:

[Unit]
Description=Gunicorn daemon for Focus Timer
After=network.target

[Service]
User=focususer
Group=www-data
WorkingDirectory=/home/focususer/focus-timer-django-vue/backend
ExecStart=/home/focususer/focus-timer-django-vue/.venv/bin/gunicorn \
  backend.wsgi:application \
  --bind 127.0.0.1:8000 \
  --workers 4
EnvironmentFile=/home/focususer/focus-timer-django-vue/.env
Restart=on-failure
PrivateTmp=true

[Install]
WantedBy=multi-user.target

Enable and start:

sudo systemctl daemon-reload
sudo systemctl enable --now gunicorn

10. Background Workers: Celery & Beat

[Unit]
Description=Celery worker service for Focus Timer
After=network.target

[Service]
Type=simple
User=focususer
Group=www-data
WorkingDirectory=/home/focususer/focus-timer-django-vue/backend
EnvironmentFile=/home/focususer/focus-timer-django-vue/.env
ExecStart=/home/focususer/focus-timer-django-vue/.venv/bin/celery -A backend worker \
  --loglevel=info
Restart=on-failure

[Install]
WantedBy=multi-user.target
[Unit]
Description=Celery Beat scheduler for Focus Timer
After=network.target

[Service]
Type=simple
User=focususer
Group=www-data
WorkingDirectory=/home/focususer/focus-timer-django-vue/backend
EnvironmentFile=/home/focususer/focus-timer-django-vue/.env
ExecStart=/home/focususer/focus-timer-django-vue/.venv/bin/celery -A backend beat \
  --loglevel=info  \
  --scheduler django_celery_beat.schedulers:DatabaseScheduler
Restart=on-failure

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now celery celery-beat

Explanation:

11. Reverse Proxy: Nginx

Install and configure /etc/nginx/sites-available/focus-timer:

server {
    listen 80;
    server_name tymr.online www.tymr.online;

    root /home/focususer/focus-timer-django-vue/frontend-vue/dist;
    index index.html;

    # incase using websockets
    location /ws/ {
        proxy_pass http://127.0.0.1:8000;
        proxy_http_version 1.1;

        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $remote_addr;

        proxy_read_timeout 86400;
    }

    location / {
        try_files $uri $uri/ /index.html;
    }
    location /static/ {
        alias /home/focususer/focus-timer-django-vue/backend/static/;
    }
    location /api/    { proxy_pass http://127.0.0.1:8000/api/; include proxy_params; }
    location /auth/   { proxy_pass http://127.0.0.1:8000/auth/; include proxy_params; }
    location /admin/  { proxy_pass http://127.0.0.1:8000/admin/; include proxy_params; }

    # Security headers
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "no-referrer-when-downgrade" always;
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    client_max_body_size 10M;
}

Enable and reload:

sudo ln -s /etc/nginx/sites-available/focus-timer /etc/nginx/sites-enabled/
sudo nginx -t
sudo ln -s /etc/nginx/sites-available/focus-timer-redirect /etc/nginx/sites-enabled/
sudo systemctl reload nginx

Explanation:

12. SSL/TLS with Let's Encrypt

sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d tymr.online -d www.tymr.online

Brief: Provides free, auto-renewing certificates.

13. Monitoring & Logging

14. Automated Backups

15. Routine Maintenance

16. Nginx permissions

Why? Nginx needs access to static files and directories.

# Ensure Nginx can read static files
sudo chmod o+x /home/focususer
sudo chmod -R o+rX /home/focususer/focus-timer-django-vue/frontend-vue/dist
sudo chmod -R o+rX /home/focususer/focus-timer-django-vue/backend/static
sudo chmod -R o+rX /home/focususer/focus-timer-django-vue/backend/media
sudo chmod -R o+x /home/focususer/focus-timer-django-vue/frontend-vue
chmod +x /home/focususer/focus-timer-django-vue/.venv/bin/gunicorn
chmod +x /home/focususer/focus-timer-django-vue/.venv/bin/celery
chmod u+x restart_all.sh

Few helpful commands to restart services after deployment

1. Reload all systemd unit files (after any edits)

sudo systemctl daemon-reload

2. Restart your Django app (Gunicorn)

sudo systemctl restart gunicorn sudo systemctl status gunicorn # check exit status immediately journalctl -u gunicorn -f # live logs

3. Restart Celery worker & beat

sudo systemctl restart celery celery-beat sudo systemctl status celery # check worker status sudo systemctl status celery-beat journalctl -u celery -f # live worker logs journalctl -u celery-beat -f # live beat logs

4. Restart Nginx (reverse proxy & static files)

sudo systemctl restart nginx sudo systemctl status nginx sudo journalctl -u nginx -f

5. (Optional) Restart backing services

sudo systemctl restart redis-server postgresql sudo systemctl status redis-server postgresql sudo journalctl -u redis-server -f sudo journalctl -u postgresql -f

6. (Optional) I made a script to restart all services at once. Feel free to tweak it as it fits your needs.

#!/usr/bin/env bash

# Script to restart all Focus Timer services

set -euo pipefail

# Parse options
SKIP_FRONTEND=false
for arg in "$@"; do
  case $arg in
    --skip-frontend)
      SKIP_FRONTEND=true
      ;;
  esac
done
echo "Installing backend requirements..."
.venv/bin/uv pip install -r backend/requirements.txt

echo "Applying database migrations..."
.venv/bin/python backend/manage.py migrate --noinput

echo "Collecting static files..."
.venv/bin/python backend/manage.py collectstatic --noinput
if [ "$SKIP_FRONTEND" = false ]; then
  echo "Building frontend..."
  ( cd frontend-vue && npm ci && npm run build )
else
  echo "Skipping frontend build due to --skip-frontend option"
fi

echo "Reloading systemd daemon..."
sudo systemctl daemon-reload

services=(
  gunicorn
  celery
  celery-beat
  nginx
  redis-server
  postgresql
)

for service in "${services[@]}"; do
  echo "Restarting $service..."
  sudo systemctl restart "$service"
done

echo "Waiting for services to settle..."
sleep 2

echo "Services status:"
for service in "${services[@]}"; do
  echo "===== $service ====="
  sudo systemctl status "$service" --no-pager
done

echo "All services restarted."

Save this script as deploy.sh and make it executable:

chmod +x deploy.sh

First published on blog.sorv.dev.