BlogGuides

Deploy Django and Vue.js on a VPS: Nginx, Gunicorn, Celery

Deploy a Django and Vue.js app on a VPS: Nginx serves the Vue build, Gunicorn runs Django, Celery and Redis under systemd, with HTTPS from Certbot.

On this page

To deploy a Django and Vue.js app on a VPS, build the Vue app into static files and let Nginx serve them, with a try_files fallback to index.html for Vue Router. Nginx proxies /api/, /auth/ and /admin/ to Gunicorn running Django under systemd, Celery and Celery Beat run as two more systemd services with Redis as the broker, PostgreSQL holds the data, and Certbot adds HTTPS.

This is the exact setup I use for my Focus Timer app, one domain for both halves, so the browser never needs CORS.

In this guide:

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.

Here is what runs on the server when you are done:

PieceRuns asJob
Nginxnginx serviceServes the Vue dist/ and Django's static files, proxies API calls, terminates HTTPS
Django (Gunicorn)gunicorn serviceAnswers /api/, /auth/ and /admin/ on 127.0.0.1:8000
Celery workercelery serviceRuns background tasks
Celery Beatcelery-beat serviceSchedules periodic tasks
Redisredis-server serviceBroker and result backend for Celery
PostgreSQLpostgresql serviceThe database

Prerequisites

  • A fresh Ubuntu VPS on a supported LTS release (22.04 or 24.04), or Debian 12.

  • 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.

Shell
# 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 the sudo group, allowing administrative commands.

1.2 Secure SSH Access

Why? Prevent unauthorized root or password-based logins.

Shell
# 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:

Shell
  Port 22
  PermitRootLogin no
  PasswordAuthentication no
  KbdInteractiveAuthentication no
  UsePAM yes

Then check the settings that are really in effect and reload SSH. Many cloud images put an override in /etc/ssh/sshd_config.d/ (often 50-cloud-init.conf) that turns password login back on, and those files win over the main config:

Shell
sudo sshd -T | grep -Ei 'passwordauthentication|permitrootlogin'
sudo systemctl reload ssh

Keep your current session open and test a new login from another terminal before you log out, so a typo can't lock you out.

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_keys file, allowing key-based logins.

  • PermitRootLogin no: Disables SSH login as root, forcing administrative access via a less-privileged user plus sudo.

  • PasswordAuthentication no: Turns off password-based logins entirely; only key-based auth is allowed.

  • KbdInteractiveAuthentication no (older guides call it ChallengeResponseAuthentication, now a deprecated alias): 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 (like pam_faillock lockouts, which replaced the removed pam_tally2) can apply to every SSH session. Fail2Ban is not a PAM module; it reads the logs, as in step 1.3.

  • 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.

Shell
# 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 the focususer account.

1.3 Firewall & Fail2ban

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

Shell
# 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.

  • On Debian 12 there is no /var/log/auth.log unless rsyslog is installed; replace the logpath line with backend = systemd.

Fail2Ban can guard your Django login too, not just SSH. I wrote that up separately in Fail2Ban for Django logins and the admin.

1.4 Automatic Security Updates

Why? Keep critical packages patched.

Shell
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.

Shell
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

Shell
sudo apt install -y postgresql postgresql-contrib

# Create the database user (it asks for a password) and a database it owns
sudo -u postgres createuser --pwprompt focusdbuser
sudo -u postgres createdb --owner focusdbuser focusdb

Brief: PostgreSQL is reliable and scalable for production. The Ubuntu package already listens on localhost only, and since PostgreSQL 14 passwords sent over TCP use scram-sha-256 by default, so there is nothing to loosen. Point Django at it with DATABASE_URL=postgres://focusdbuser:yourpassword@127.0.0.1:5432/focusdb (or the equivalent DATABASES settings) in your .env.

4. Redis for Celery

Shell
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-server

Brief: Redis acts as broker and result backend for Celery. With a password set, the broker URL becomes redis://:StrongRedisPassw0rd@127.0.0.1:6379/0; use your own password, not this one.

5. Codebase Deployment

5.1 Clone Repository

Shell
# 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.

Shell
# In project root, create .env
touch .env
# Populate .env with necessary variables (SECRET_KEY, DEBUG=False,
# ALLOWED_HOSTS, the database URL, the Redis URL)
# Secure .env file permissions
chmod 600 .env

systemd's EnvironmentFile reads plain KEY=value lines, so keep the file in that form, with no export and no spaces around =.

6. Python Virtual Environment

Shell
# In the project root: the systemd units below and deploy.sh use this .venv
sudo apt install -y python3-venv
python3 -m venv .venv
source .venv/bin/activate
pip install uv
uv pip install -r backend/requirements.txt

7. Frontend Build (Vue.js)

Shell
cd frontend-vue
# install node (an LTS line, 22 or 24); n-install edits your shell profile,
# so open a new shell before using node
curl -fsSL https://raw.githubusercontent.com/mklement0/n-install/stable/bin/n-install | bash -s 22
exec $SHELL -l

cd ~/focus-timer-django-vue/frontend-vue
npm ci
npm run build

Brief: Outputs static assets in dist/. npm ci installs exactly what package-lock.json pins. Since the API is on the same domain, the frontend can call relative paths like /api/..., and you don't need a CORS package on the Django side.

8. Django Migrations & Static Files

Shell
cd ~/focus-timer-django-vue/backend
source ../.venv/bin/activate
python manage.py migrate
python manage.py collectstatic --noinput

9. Application Server: Gunicorn

Shell
uv pip install gunicorn

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

INI
[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:

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

10. Background Workers: Celery & Beat

  • Create /etc/systemd/system/celery.service:
INI
[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:
INI
[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:
Shell
sudo systemctl daemon-reload
sudo systemctl enable --now celery celery-beat

Explanation:

  • Type=simple: Celery stays in the foreground and systemd tracks that process directly, so there is no --detach and no PID file.

  • DatabaseScheduler: needs the django-celery-beat package in INSTALLED_APPS and its migrations applied; run only one Beat.

  • Restart=on-failure: automatically recovers from crashes.

11. Reverse Proxy: Nginx

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

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

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

    # in case 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;
    client_max_body_size 10M;
}

Enable and reload:

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

Explanation:

  • Security headers prevent clickjacking and MIME-sniffing.

  • try_files $uri $uri/ /index.html: any path that isn't a real file gets the Vue app, so a refresh on a Vue Router page in HTML5 history mode doesn't 404.

  • client_max_body_size: limits upload size to mitigate DoS.

  • The HTTP to HTTPS redirect comes in the next step: Certbot adds it to this file for you.

12. SSL/TLS with Let's Encrypt

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

Brief: Provides free, auto-renewing certificates. Certbot edits the server block to listen on 443 and redirect port 80 to HTTPS; sudo certbot renew --dry-run checks that renewal works. Once HTTPS works, add HSTS inside the server block:

Nginx
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

Add preload only when every subdomain serves HTTPS and you mean to submit the domain to the preload list; it is hard to undo. Also set SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") in Django, since Nginx's proxy_params sends that header, and run through Django's deployment checklist with python manage.py check --deploy.

13. Monitoring & Logging

  • Use journalctl -u gunicorn -f and journalctl -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. Cron can't type a password, so store it in ~/.pgpass (a line 127.0.0.1:5432:focusdb:focusdbuser:yourpassword, mode 600) and create the folder first:

    Shell
      mkdir -p ~/backups
      chmod 600 ~/.pgpass
      crontab -e
      # Add:
      0 2 * * * pg_dump -h 127.0.0.1 -U focusdbuser focusdb | gzip > ~/backups/db-$(date +\%F).sql.gz
    
  • Secure backup storage (offsite or S3).

15. Routine Maintenance

  • Keep OS & packages updated:

    Shell
      sudo apt update && sudo apt upgrade -y
    
  • Review logs, monitor disk and memory.

16. Nginx permissions

Why? Nginx needs access to static files and directories.

Shell
# 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 deploy.sh

Few helpful commands to restart services after deployment

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

Shell
sudo systemctl daemon-reload

2. Restart your Django app (Gunicorn)

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

3. Restart Celery worker & beat

Shell
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)

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

5. (Optional) Restart backing services

Shell
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.

Shell
#!/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:

Shell
chmod +x deploy.sh

After each push, pull the code and run it from the project root: git pull && ./deploy.sh. Restarting Gunicorn this way drops requests for a second or two, which is fine for a side project.

Key takeaways

  • Build Vue into dist/ and let Nginx serve it; Django only answers the API, auth and admin paths.
  • try_files $uri $uri/ /index.html keeps Vue Router's history mode working on refresh.
  • Serving both halves on one domain means no CORS setup at all.
  • Gunicorn, the Celery worker and Celery Beat each get a systemd unit, with secrets in one .env file.
  • Lock SSH down to keys, check sshd -T for cloud overrides, and add HSTS only after HTTPS works.

Frequently asked questions

Should Nginx or Django serve the Vue.js build?

Nginx. The Vue build is plain HTML, JavaScript and CSS, and Nginx serves static files faster and with less memory than a Python process. Point the server block's root at dist/, add the try_files fallback to index.html, and keep Gunicorn for the paths Django really answers.

Do I need CORS for Django and Vue on one domain?

No. When Nginx serves the Vue app and proxies /api/ to Django on the same domain, the browser sees one origin, so there is no cross-origin request. You only need django-cors-headers when the frontend lives on a different domain or port, as during local development with the Vite dev server.

Why does refreshing a Vue Router page give a 404?

In history mode the path, like /settings, exists only inside the Vue app, so the server finds no such file. Add try_files $uri $uri/ /index.html; to the Nginx location / block. Any path that isn't a real file then returns index.html, and Vue Router shows the right page.

How do I deploy updates after pushing to Git?

SSH in, run git pull in the project root, then run the deploy.sh script above. It installs requirements, applies migrations, collects static files, rebuilds the Vue app and restarts the services. Pass --skip-frontend when only the backend changed, to skip the slow npm build.

Can ox deploy a Django and Vue.js app like this?

Yes. ox reads a Django app's requirements and runs it with Gunicorn under systemd, with PostgreSQL and Redis when it sees them. A [static] block serves the built Vue app next to it and sends API paths like /api to Django. You still write no Nginx or unit files yourself.

Next step: if you'd rather not maintain all of this by hand, ox runs the same shape of app on your own server. Read the Django guide for the backend, the static site guide for serving the Vue build, the [static] settings for sending /api to Django, and setting environment variables for what goes in your .env today.

Updated : fixed the PostgreSQL, virtualenv, Node and Celery steps, updated the SSH settings for current Ubuntu, moved HSTS after HTTPS, and added a FAQ.

First published on blog.sorv.dev.

Keep reading

Deploy to a server you own

ox sets up systemd, Caddy and PostgreSQL on your Ubuntu server and deploys on every push. Free during the beta.

Sign up with GitHub Read the docs