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
- Overview
- Prerequisites
- 1. VPS Setup
- 2. System Dependencies
- 3. Database: PostgreSQL
- 4. Redis for Celery
- 5. Codebase Deployment
- 6. Python Virtual Environment
- 7. Frontend Build (Vue.js)
- 8. Django Migrations & Static Files
- 9. Application Server: Gunicorn
- 10. Background Workers: Celery & Beat
- 11. Reverse Proxy: Nginx
- 12. SSL/TLS with Let's Encrypt
- 13. Monitoring & Logging
- 14. Automated Backups
- 15. Routine Maintenance
- 16. Nginx permissions
- Few helpful commands to restart services after deployment
- Key takeaways
- Frequently asked questions
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:
- Securing the VPS
- PostgreSQL and Redis
- Building the Vue.js frontend
- Gunicorn, Celery and Beat under systemd
- Nginx for the Vue build and the Django API
- Frequently asked questions
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:
| Piece | Runs as | Job |
|---|---|---|
| Nginx | nginx service | Serves the Vue dist/ and Django's static files, proxies API calls, terminates HTTPS |
| Django (Gunicorn) | gunicorn service | Answers /api/, /auth/ and /admin/ on 127.0.0.1:8000 |
| Celery worker | celery service | Runs background tasks |
| Celery Beat | celery-beat service | Schedules periodic tasks |
| Redis | redis-server service | Broker and result backend for Celery |
| PostgreSQL | postgresql service | The 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.
# 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
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:
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_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.KbdInteractiveAuthentication no(older guides call itChallengeResponseAuthentication, 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_faillocklockouts, which replaced the removedpam_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.
# 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.
On Debian 12 there is no
/var/log/auth.logunless rsyslog is installed; replace thelogpathline withbackend = 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.
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 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
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
# 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 (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
# 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)
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
cd ~/focus-timer-django-vue/backend
source ../.venv/bin/activate
python manage.py migrate
python manage.py collectstatic --noinput
9. Application Server: Gunicorn
uv 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=simple: Celery stays in the foreground and systemd tracks that process directly, so there is no--detachand no PID file.DatabaseScheduler: needs thedjango-celery-beatpackage inINSTALLED_APPSand 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:
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:
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
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:
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 -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. Cron can't type a password, so store it in
~/.pgpass(a line127.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.gzSecure backup storage (offsite or S3).
15. Routine Maintenance
Keep OS & packages updated:
Shell 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 deploy.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
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.htmlkeeps 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
.envfile. - Lock SSH down to keys, check
sshd -Tfor 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.
