This guide deploys Univerra on an Ubuntu VPS with:
- A subdomain such as
app.example.com - Nginx as the public web server
- HTTPS with Certbot/Let's Encrypt
- Flask backend running privately on
127.0.0.1:5001 - Built Vue/Vite frontend served as static files
- Cloudflare in front of the VPS with Full strict SSL
- Basic server and application hardening
Replace every example value before running commands:
APP_DOMAIN=app.example.com
APP_EMAIL=you@example.com
APP_DIR=/opt/univerra/appIn Cloudflare, open your domain and go to DNS.
Create this record:
Type: A
Name: app
IPv4 address: YOUR_VPS_PUBLIC_IPV4
Proxy status: DNS only for initial certificate setup
TTL: Auto
If your VPS has IPv6, also add:
Type: AAAA
Name: app
IPv6 address: YOUR_VPS_PUBLIC_IPV6
Proxy status: DNS only for initial certificate setup
TTL: Auto
Wait until DNS resolves:
dig +short app.example.comAfter HTTPS works, change the record to Proxied in Cloudflare.
Important:
- Do not create other public DNS-only records pointing to the same VPS IP unless needed.
- Do not expose
api.example.comdirectly — use same-origin/apithrough Nginx. Use same-origin/apithrough Nginx. - If your root domain is somewhere else, only the subdomain needs to point at this VPS.
SSH into the VPS as root or your provider-created sudo user:
ssh root@YOUR_VPS_PUBLIC_IPUpdate the system. If you are logged in directly as root, the sudo prefix is optional.
sudo apt update
sudo apt upgrade -y
sudo apt install -y curl git ufw fail2ban nginx ca-certificates gnupg lsb-releaseCreate a normal admin user if you only have root:
adduser deploy
usermod -aG sudo deployCopy your SSH public key to that user:
mkdir -p /home/deploy/.ssh
cp ~/.ssh/authorized_keys /home/deploy/.ssh/authorized_keys
chown -R deploy:deploy /home/deploy/.ssh
chmod 700 /home/deploy/.ssh
chmod 600 /home/deploy/.ssh/authorized_keysOpen a second terminal and confirm the new login works before disabling root/password login:
ssh deploy@YOUR_VPS_PUBLIC_IPThen harden SSH:
sudo nano /etc/ssh/sshd_configRecommended settings:
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
Restart SSH:
sudo systemctl restart sshEnable UFW. Do this only after allowing SSH:
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status verboseEnable automatic security updates:
sudo apt install -y unattended-upgrades
sudo dpkg-reconfigure --priority=low unattended-upgradesThe frontend uses Vite 7, so use a modern Node.js version. Node.js 22 LTS is a good default.
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs
node -v
npm -vInstall uv for the Python backend under the app user later. First create the app user:
sudo adduser --system --group --home /opt/univerra --shell /bin/bash univerra
sudo mkdir -p /opt/univerra
sudo chown -R univerra:univerra /opt/univerraSwitch to the app user:
sudo -iu univerraInstall uv:
curl -LsSf https://astral.sh/uv/install.sh | sh
export PATH="$HOME/.local/bin:$PATH"
uv --versionOption A: clone from Git:
cd /opt/univerra
git clone YOUR_REPO_URL app
cd appOption B: upload from your local machine:
Run this from your local project directory, not inside the VPS:
rsync -az --delete \
--exclude '.git' \
--exclude 'node_modules' \
--exclude 'frontend/node_modules' \
--exclude 'frontend/dist' \
--exclude 'backend/.venv' \
--exclude 'venv' \
./ deploy@YOUR_VPS_PUBLIC_IP:/tmp/univerra-upload/Then on the VPS:
sudo mkdir -p /opt/univerra/app
sudo rsync -a --delete /tmp/univerra-upload/ /opt/univerra/app/
sudo chown -R univerra:univerra /opt/univerra/app
sudo -iu univerra
cd /opt/univerra/appCreate the environment file. If you already uploaded a real .env, do not overwrite it.
test -f .env || cp .env.example .env
nano .envSet at least these values:
LLM_API_KEY=changeme
LLM_BASE_URL=https://api.openai.com/v1
LLM_MODEL_NAME=gpt-5.4
FLASK_DEBUG=False
FLASK_HOST=127.0.0.1
FLASK_PORT=5001
AUTH_SECRET_KEY=changeme
AUTH_TOKEN_MAX_AGE_SECONDS=604800
MONGODB_URI=your_mongodb_uri
MONGODB_DB_NAME=univerra
MONGODB_TIMEOUT_MS=5000Generate a strong auth secret:
openssl rand -hex 32Protect the file:
chmod 600 .envSecurity notes:
- Never commit
.env. - Keep
FLASK_DEBUG=Falseon the VPS. - Keep
FLASK_HOST=127.0.0.1so the backend is not exposed directly. - If using MongoDB Atlas, allowlist only the VPS IP.
- If using local MongoDB, bind MongoDB to
127.0.0.1and do not open port27017.
Still as the univerra user:
cd /opt/univerra/app
npm ci
npm ci --prefix frontend
cd backend
uv sync --frozen
uv pip install gunicorn
cd ..
VITE_API_BASE_URL=/api npm run buildThe VITE_API_BASE_URL=/api part is important. It makes the browser call:
https://app.example.com/api/...
instead of trying to call:
http://localhost:5001
Copy the built frontend to Nginx's web root:
exit
sudo mkdir -p /var/www/univerra
sudo rsync -a --delete /opt/univerra/app/frontend/dist/ /var/www/univerra/
sudo chown -R www-data:www-data /var/www/univerraCreate the service:
sudo nano /etc/systemd/system/univerra-backend.servicePaste:
[Unit]
Description=Univerra Flask API
After=network.target
[Service]
Type=simple
User=univerra
Group=univerra
WorkingDirectory=/opt/univerra/app/backend
EnvironmentFile=/opt/univerra/app/.env
Environment=PYTHONUNBUFFERED=1
ExecStart=/opt/univerra/app/backend/.venv/bin/gunicorn 'app:create_app()' --bind 127.0.0.1:5001 --workers 2 --threads 4 --timeout 300 --access-logfile - --error-logfile -
Restart=always
RestartSec=5
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.targetStart it:
sudo systemctl daemon-reload
sudo systemctl enable --now univerra-backend
sudo systemctl status univerra-backendCheck the backend locally:
curl http://127.0.0.1:5001/healthCheck that port 5001 is private:
sudo ss -tulpn | grep 5001You should see 127.0.0.1:5001, not 0.0.0.0:5001.
Create rate-limit zones:
sudo nano /etc/nginx/conf.d/univerra-rate-limit.confPaste:
limit_req_zone $binary_remote_addr zone=univerra_api:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=univerra_auth:10m rate=5r/m;Create a proxy snippet:
sudo nano /etc/nginx/snippets/univerra-proxy.confPaste:
proxy_http_version 1.1;
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_set_header Connection "";
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_buffering off;Create the site:
sudo nano /etc/nginx/sites-available/univerraPaste and replace app.example.com:
server {
listen 80;
listen [::]:80;
server_name app.example.com;
root /var/www/univerra;
index index.html;
client_max_body_size 50m;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
location = /api/auth/login {
limit_req zone=univerra_auth burst=5 nodelay;
include snippets/univerra-proxy.conf;
proxy_pass http://127.0.0.1:5001;
}
location = /api/auth/signup {
limit_req zone=univerra_auth burst=5 nodelay;
include snippets/univerra-proxy.conf;
proxy_pass http://127.0.0.1:5001;
}
location /api/ {
limit_req zone=univerra_api burst=30 nodelay;
include snippets/univerra-proxy.conf;
proxy_pass http://127.0.0.1:5001;
}
location = /health {
include snippets/univerra-proxy.conf;
proxy_pass http://127.0.0.1:5001;
}
location /assets/ {
try_files $uri =404;
expires 30d;
add_header Cache-Control "public, immutable";
}
location / {
try_files $uri $uri/ /index.html;
}
}Enable it:
sudo ln -s /etc/nginx/sites-available/univerra /etc/nginx/sites-enabled/univerra
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginxTest HTTP:
curl -I http://app.example.com
curl http://app.example.com/healthInstall Certbot using snap:
sudo apt install -y snapd
sudo snap install --classic certbot
sudo ln -sf /snap/bin/certbot /usr/local/bin/certbotIssue the certificate:
sudo certbot --nginx \
-d app.example.com \
--redirect \
--email you@example.com \
--agree-tos \
--no-eff-emailTest renewal:
sudo certbot renew --dry-runTest HTTPS:
curl -I https://app.example.com
curl https://app.example.com/healthWhen HTTPS works, add HSTS carefully. In the HTTPS server block created by Certbot, add:
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;Only use includeSubDomains if every subdomain that users visit already supports HTTPS. If unsure, use this safer first step:
add_header Strict-Transport-Security "max-age=86400" always;Then reload:
sudo nginx -t
sudo systemctl reload nginxAfter Certbot works:
- Go to Cloudflare DNS.
- Change the
apprecord from DNS only to Proxied. - Go to SSL/TLS -> Overview.
- Set encryption mode to Full strict.
Recommended Cloudflare settings:
SSL/TLS -> Overview: Full strict
SSL/TLS -> Edge Certificates: Always Use HTTPS = On
SSL/TLS -> Edge Certificates: Automatic HTTPS Rewrites = On
SSL/TLS -> Edge Certificates: Minimum TLS Version = TLS 1.2 or higher
SSL/TLS -> Edge Certificates: TLS 1.3 = On
Security -> Bots: Bot Fight Mode = On if available
Security -> WAF: Managed rules = On if available
Do not use Flexible SSL. Flexible encrypts browser-to-Cloudflare traffic but not Cloudflare-to-origin traffic and often causes redirect loops.
Once Cloudflare is proxied, Nginx will otherwise see Cloudflare IPs instead of real visitor IPs.
Create a Cloudflare real-IP config:
{
curl -s https://www.cloudflare.com/ips-v4 | sed 's#^#set_real_ip_from #; s#$#;#'
curl -s https://www.cloudflare.com/ips-v6 | sed 's#^#set_real_ip_from #; s#$#;#'
echo 'real_ip_header CF-Connecting-IP;'
echo 'real_ip_recursive on;'
} | sudo tee /etc/nginx/conf.d/cloudflare-real-ip.confReload:
sudo nginx -t
sudo systemctl reload nginxUpdate this config periodically because Cloudflare IP ranges can change.
Cloudflare protects traffic only if visitors actually pass through Cloudflare. If someone knows your VPS IP, they may try to bypass Cloudflare.
The easiest origin lock is Nginx allowlisting. Enable this only after:
- The Cloudflare DNS record is Proxied.
https://app.example.comworks through Cloudflare.sudo certbot renew --dry-runpasses.
Create an allowlist snippet:
{
curl -s https://www.cloudflare.com/ips-v4 | sed 's#^#allow #; s#$#;#'
curl -s https://www.cloudflare.com/ips-v6 | sed 's#^#allow #; s#$#;#'
echo 'deny all;'
} | sudo tee /etc/nginx/snippets/cloudflare-only.confThen add this line near the top of the HTTP and HTTPS server blocks for this site:
include snippets/cloudflare-only.conf;Reload:
sudo nginx -t
sudo systemctl reload nginxTest:
curl -I https://app.example.com
curl -I --resolve app.example.com:443:YOUR_VPS_PUBLIC_IP https://app.example.comThe Cloudflare request should work. The direct-to-origin request should return 403 or fail.
Stronger option: use your VPS provider firewall or UFW to allow ports 80 and 443 only from Cloudflare IP ranges. If you use this, remember that Docker published ports can bypass UFW unless you configure Docker firewall rules correctly. The direct systemd deployment in this guide avoids that issue.
Authenticated Origin Pulls adds mTLS between Cloudflare and Nginx. It helps ensure HTTPS requests to your origin are really coming from Cloudflare.
Install the Cloudflare origin-pull CA:
sudo mkdir -p /etc/nginx/certs
sudo curl -fsSL \
https://developers.cloudflare.com/ssl/static/authenticated_origin_pull_ca.pem \
-o /etc/nginx/certs/cloudflare-origin-pull-ca.pem
sudo chmod 644 /etc/nginx/certs/cloudflare-origin-pull-ca.pemIn the HTTPS server block, add:
ssl_client_certificate /etc/nginx/certs/cloudflare-origin-pull-ca.pem;
ssl_verify_client on;Reload Nginx:
sudo nginx -t
sudo systemctl reload nginxThen enable it in Cloudflare:
SSL/TLS -> Origin Server -> Authenticated Origin Pulls -> On
If the site breaks, disable the Cloudflare toggle first, remove or comment the two Nginx lines, and reload Nginx.
Good starter rules:
Rule: Bypass cache for API
Expression: starts_with(http.request.uri.path, "/api/")
Action: Bypass cache
Rule: Protect login
Expression: http.request.uri.path in {"/api/auth/login" "/api/auth/signup"}
Action: Managed Challenge or Rate Limit
Rule: Block obviously bad countries or ASNs
Expression: only if you know you never serve those locations
Action: Block or Managed Challenge
Rule: Challenge high-risk traffic
Expression: cf.threat_score gt 20
Action: Managed Challenge
Keep these practical:
- Do not block countries unless you are sure.
- Do not cache
/api/*. - During an attack, enable Under Attack Mode temporarily.
- Use Cloudflare notifications for spikes in 5xx errors.
No VPS can be made fully hack-proof, but this setup reduces the common risks.
Must do:
- Use SSH keys only.
- Disable root SSH login.
- Keep Ubuntu packages updated.
- Keep
FLASK_DEBUG=False. - Keep
.envprivate withchmod 600. - Bind backend to
127.0.0.1. - Expose only Nginx ports
80and443. - Use Cloudflare Full strict.
- Use strong unique API keys and rotate them if leaked.
- Use a strong
AUTH_SECRET_KEY. - Back up
.env, MongoDB, andbackend/uploads. - Review logs after deployment.
Recommended:
- Restrict backend CORS to your production domain in code or config if you add that option.
- Add Cloudflare Access in front of the app if it is private.
- Use a separate VPS for email, or no email on this VPS, to avoid leaking origin IP.
- Use provider snapshots before major updates.
- Set budget alerts for LLM API usage.
Backend logs:
sudo journalctl -u univerra-backend -fNginx logs:
sudo tail -f /var/log/nginx/access.log
sudo tail -f /var/log/nginx/error.logApp file logs:
sudo ls -la /opt/univerra/app/backend/logs
sudo tail -f /opt/univerra/app/backend/logs/*.logCheck services:
sudo systemctl status univerra-backend
sudo systemctl status nginx
sudo nginx -tCommon problems:
502 Bad Gateway
Backend is not running or not listening on 127.0.0.1:5001.
Check: sudo journalctl -u univerra-backend -f
Frontend loads but API fails
Frontend was probably built without VITE_API_BASE_URL=/api.
Rebuild: VITE_API_BASE_URL=/api npm run build
Cloudflare 526
Cloudflare Full strict cannot validate your origin cert.
Check: sudo certbot certificates
Cloudflare too many redirects
Usually caused by Flexible SSL or conflicting redirects.
Use Full strict.
Uploads fail
Check Nginx client_max_body_size and backend MAX_CONTENT_LENGTH.
Login/signup rate limited too aggressively
Adjust /etc/nginx/conf.d/univerra-rate-limit.conf.
For Git-based deployments:
sudo -iu univerra
cd /opt/univerra/app
git pull
export PATH="$HOME/.local/bin:$PATH"
npm ci
npm ci --prefix frontend
cd backend
uv sync --frozen
uv pip install gunicorn
cd ..
VITE_API_BASE_URL=/api npm run build
exit
sudo rsync -a --delete /opt/univerra/app/frontend/dist/ /var/www/univerra/
sudo chown -R www-data:www-data /var/www/univerra
sudo systemctl restart univerra-backend
sudo nginx -t
sudo systemctl reload nginxFor rsync deployments, upload new files first, then run the same install/build/restart steps.
The repository includes Docker files, but the current image runs the app in development mode. For a quick private demo, you can use it behind Nginx, but for production the systemd/static frontend path above is cleaner.
If you still use Docker, do not publish app ports on all interfaces. Bind them to localhost:
services:
univerra:
build: .
image: univerra-simulation:latest
container_name: univerra
env_file:
- .env
ports:
- "127.0.0.1:3000:3000"
- "127.0.0.1:5001:5001"
restart: unless-stopped
volumes:
- ./backend/uploads:/app/backend/uploadsThen configure Nginx:
location /api/ {
include snippets/univerra-proxy.conf;
proxy_pass http://127.0.0.1:5001;
}
location / {
include snippets/univerra-proxy.conf;
proxy_pass http://127.0.0.1:3000;
}Again: for serious production, prefer the static frontend plus systemd backend deployment above.
- Certbot Nginx instructions: https://certbot.eff.org/instructions?ws=nginx&os=snap
- Nginx reverse proxy docs: https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/
- Cloudflare Full strict SSL: https://developers.cloudflare.com/ssl/origin-configuration/ssl-modes/full-strict/
- Cloudflare protect your origin server: https://developers.cloudflare.com/fundamentals/security/protect-your-origin-server/
- Cloudflare IP addresses and origin allowlisting: https://developers.cloudflare.com/fundamentals/concepts/cloudflare-ip-addresses/
- Cloudflare restore original visitor IPs: https://developers.cloudflare.com/support/troubleshooting/restoring-visitor-ips/restoring-original-visitor-ips/
- Cloudflare Authenticated Origin Pulls: https://developers.cloudflare.com/ssl/origin-configuration/authenticated-origin-pull/set-up/global/
- Docker Engine on Ubuntu: https://docs.docker.com/engine/install/ubuntu/