Deployment Guide for Django + Vue.js project on VPS

Hi, I’m a self-taught backend developer with 3+ years of experience, currently working at a tech startup based in The Bahamas. I mostly work with Python and Django, building APIs, designing database models, and improving performance when needed. I enjoy learning new tools and technologies as projects require.
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
A fresh Ubuntu/Debian VPS (18.04+ or 20.04+).
Root or sudo access.
A domain name pointing to your VPS.
Basic familiarity with Linux shell.
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:
adduser: Creates a new Linux user with default settings.--disabled-password: Prevents setting a login password (forces SSH key auth only).--gecos "": Supplies empty fields for the user's full name and contact info.usermod -aG sudo: Appends (-a) the user to thesudogroup, allowing administrative commands.
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 "your_email@example.com"
# Copy public key to server:
ssh-copy-id focususer@your.domain.com
On server, edit /etc/ssh/sshd_config:
Port 22
PermitRootLogin no
PasswordAuthentication no
ChallengeResponseAuthentication no
UsePAM yes
sudo systemctl reload ssh
Explanation:
ssh-keygen -t ed25519: Generates a modern, high-security Ed25519 key pair.-C "comment": Adds an identifying comment (often your email).ssh-copy-id: Installs your public key on the remote server's~/.ssh/authorized_keysfile, allowing key-based logins.PermitRootLogin no: Disables SSH login asroot, forcing administrative access via a less-privileged user plussudo.PasswordAuthentication no: Turns off password-based logins entirely; only key-based auth is allowed.ChallengeResponseAuthentication no: Disables keyboard-interactive (challenge-response) methods, such as one-time passwords or other PAM-driven prompts, preventing any fallback from key-based authentication.- Without this setting, SSH could invoke PAM's challenge modules (e.g. OTP or custom scripts) that might allow weaker or interactive authentication paths.
UsePAM yes: Enables Pluggable Authentication Modules (PAM) integration.Even with password logins disabled, PAM handles account and session management after a successful key login.
PAM modules enforce additional security policies (e.g. account expiration, lockouts, resource limits, logging).
Ensures that system-wide policies (fail2ban, pam_tally2, custom modules) can apply to every SSH session.
systemctl reload ssh: Applies the above SSH daemon changes without dropping existing connections.
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 focususer@your.domain.com
Explanation:
exit: Ends the current SSH session as root and returns you to your local shell.ssh focususer@your.domain.com: Initiates a new SSH session using your key for thefocususeraccount.
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:
ufw default deny incoming: Blocks all incoming traffic by default.ufw default allow outgoing: Allows all outbound traffic.ufw allow OpenSSH: Opens SSH port (usually 22).ufw allow 'Nginx Full': Opens HTTP (80) and HTTPS (443) for web traffic.ufw enable: Activates the firewall with the defined rules.fail2ban: Monitors log files and bans IPs after too many failed login attempts.In
jail.local:bantime: Duration (1h) to ban offenders.findtime: Time window (10m) to track failures.maxretry: Max allowed failures (5) before ban.
1.4 Automatic Security Updates
Why? Keep critical packages patched.
sudo apt install -y unattended-upgrades
sudo dpkg-reconfigure unattended-upgrades
Explanation:
unattended-upgrades: Automatically downloads and installs security updates.dpkg-reconfigure unattended-upgrades: Opens a configuration prompt to enable auto-updates.
2. System Dependencies
Install tools required for building and deployment.
sudo apt install -y git build-essential curl libpq-dev
Explanation:
git: Version control system to clone your repository.build-essential: Installs GCC, make, and other build tools for compiling native extensions.curl: Tool to transfer data from or to a server (used for downloading scripts).libpq-dev: Development headers for PostgreSQL, required by psycopg2 Python package.
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
- Create
/etc/systemd/system/celery.service:
[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
- Create
/etc/systemd/system/celery-beat.service:
[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
- Enable & start both services:
sudo systemctl daemon-reload
sudo systemctl enable --now celery celery-beat
Explanation:
Type=forking/simple: ensures proper startup and process tracking.--detach: runs worker/beat in background.Restart=on-failure: automatically recovers from crashes.
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:
Security headers prevent clickjacking, MIME-sniffing, enforce HTTPS.
client_max_body_size: limits upload size to mitigate DoS.HTTP->HTTPS redirect ensures all traffic is encrypted.
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
Use
journalctl -u gunicorn -fandjournalctl -u celery -f.Set up Logrotate for Gunicorn logs if needed.
Consider external monitoring (Prometheus/Grafana, UptimeRobot).
14. Automated Backups
Write a cron job for nightly PostgreSQL dumps:
sudo crontab -u focususer -e # Add: 0 2 * * * pg_dump -U focusdbuser focusdb | gzip > ~/backups/db-$(date +\%F).sql.gzSecure backup storage (offsite or S3).
15. Routine Maintenance
Keep OS & packages updated:
sudo apt update && sudo apt upgrade -yReview logs, monitor disk and memory.
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





