VPS Deployment Guide
This document covers the exact steps used to deploy TradeEntry on the production VPS, including all configuration, fixes, and troubleshooting discovered along the way.
Server Details
| Item | Value |
|---|---|
| VPS IP | 103.178.166.188 |
| OS | Ubuntu 22.04 |
| User | kss |
| Main site | https://tradeentry.in |
| Docs site | https://docs.tradeentry.in |
| Registrar | GoDaddy |
| Web server | nginx |
| SSL | Let's Encrypt / Certbot |
| Database | PostgreSQL 16 via Docker |
1. DNS Configuration (GoDaddy)
Login to GoDaddy DNS Manager and create these records:
| Type | Name | Value | TTL |
|---|---|---|---|
| A | @ | 103.178.166.188 | 600 |
| A | docs | 103.178.166.188 | 600 |
| CNAME | www | tradeentry.in | 1 Hour |
Remove any existing GoDaddy Website Builder entries, domain forwarding, or parking/redirect rules.
Wait 5–30 minutes for DNS to propagate, then verify from Windows CMD:
nslookup tradeentry.in
ipconfig /flushdns
2. Connect to VPS
ssh kss@103.178.166.188
3. Install nginx + required system packages
sudo apt update
sudo apt install nginx -y
sudo systemctl enable nginx
sudo systemctl start nginx
sudo systemctl status nginx
Expected: active (running)
Also install the system-level tools the backend shells out to — these are apt
packages, NOT Python packages, so neither pull.sh nor uv run app.py will
ever install them for you:
sudo apt install unrar postgresql-client -y
| Package | Used by | If missing |
|---|---|---|
unrar | EODIEOD pipeline (nseieod/eodieod/rario.py) — extracts the 4 daily vendor .rar files | Pipeline raises "No RAR extraction tool found" — or, via the web page's background task, appears to silently hang at 0% (2026-07-14 incident) |
postgresql-client | psql/pg_dump on the VPS shell (DB swaps, backups, sync scripts) | Manual DB operations in the Sync Process doc fail |
4. Firewall Setup
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow OpenSSH
sudo ufw enable
sudo ufw status
Ports 8000, 5173, 3000 are kept internal only — all external traffic goes through nginx on 80/443.
5. Install Node.js (via nvm — no sudo needed)
The system Node.js may be too old (v12). Use nvm:
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
source ~/.bashrc
nvm install 20
node --version # should show v20.x.x
npm --version
6. Install Python (via uv)
curl -LsSf https://astral.sh/uv/install.sh | sh
source ~/.bashrc
uv python install 3.11
uv --version
7. Clone the Repository
Generate a deploy key for the private GitHub repo:
ssh-keygen -t ed25519 -C "tradeentry" -f ~/.ssh/tradeentry_deploy
cat ~/.ssh/tradeentry_deploy.pub
Add the public key to GitHub → Repository → Settings → Deploy Keys.
Then clone:
eval "$(ssh-agent -s)"
ssh-add ~/.ssh/tradeentry_deploy
git clone git@github.com:srchains/tradeentry.git ~/tradeentry
cd ~/tradeentry
Configure git to use the deploy key going forward:
# ~/.ssh/config
Host github.com
IdentityFile ~/.ssh/tradeentry_deploy
IdentitiesOnly yes
8. Configure backend .env
nano ~/tradeentry/backend/.env
APP_ENV=VPS
# VPS DB (Docker PostgreSQL on 127.0.0.1)
VPS_DB_HOST=127.0.0.1
VPS_DB_NAME=tedb
VPS_DB_USER=postgres
VPS_DB_PASS=tedb
# Paths (created automatically on startup)
VPS_CSV_PATH=/home/kss/tradeentry/csvs
VPS_AMI_PATH=/home/kss/tradeentry/csvs/AMI
# Common
DB_PORT=5432
POSTGRES_PASSWORD=tedb
DOCS_URL=https://docs.tradeentry.in
127.0.0.1 not localhost for DB_HOSTOn Linux, localhost resolves to a Unix socket. Docker PostgreSQL only exposes TCP on 127.0.0.1.
Using localhost will cause connection refused errors even when Docker is running fine.
9. PostgreSQL via Docker
The database runs as a Docker container. Start and configure it:
# Pull and start PostgreSQL 16
docker run -d \
--name postgres-db \
--restart=always \
-e POSTGRES_PASSWORD=tedb \
-e POSTGRES_DB=tedb \
-p 5432:5432 \
postgres:16
# Verify it's running
docker ps
Make sure it survives reboots:
docker update --restart=always postgres-db
Test the connection:
PGPASSWORD=tedb psql -h 127.0.0.1 -U postgres -d tedb -c "\dt"
Check row counts:
PGPASSWORD=tedb psql -h 127.0.0.1 -U postgres -d tedb \
-c "SELECT COUNT(*) FROM scr_nseeq_eod;"
10. Set Up teudb (Auth Database) and Create the First Admin
TradeEntry v2.0 added a second database, teudb, for the user/auth system — registration, login, JWT tokens, and the admin panel. It's a separate physical database on the same Postgres container as tedb. This step is one-time: it only applies to the first deploy that introduces the v2.0 auth code; every deploy after that just needs pull.sh (step 15 covers ongoing updates).
10.1 Add the required env vars
nano ~/tradeentry/backend/.env
Add:
JWT_SECRET=<generate below — never reuse a local dev secret>
FRONTEND_URL=https://tradeentry.in
EMAIL_NOTIFIER=console
SMS_NOTIFIER=console
AUTH_ENFORCED=false
VPS_UDB_HOST=127.0.0.1
VPS_UDB_NAME=teudb
VPS_UDB_USER=postgres
VPS_UDB_PASS=tedb
Generate JWT_SECRET:
cd ~/tradeentry/backend
source venv/bin/activate
python -c "import secrets; print(secrets.token_urlsafe(48))"
app/auth/tokens.py raises immediately on import if JWT_SECRET is unset — and main.py imports it unconditionally, so this crashes the entire backend, not just auth. Set this before restarting the service after pulling the v2.0 auth code, or every existing page (bhav downloader, dashboards, everything) goes down too.
Verification and password-reset links just print to journalctl until real SMTP credentials are added. See teudb Overview "Admin API reference" for the branded-HTML SMTP sender and how to switch to it later.
10.2 Create the teudb database
PGPASSWORD=tedb psql -h 127.0.0.1 -U postgres -c "CREATE DATABASE teudb;"
PGPASSWORD=tedb psql -h 127.0.0.1 -U postgres -d teudb -c "CREATE EXTENSION IF NOT EXISTS citext;"
10.3 Run both migration chains
cd ~/tradeentry/backend
source venv/bin/activate
alembic upgrade head # tedb
alembic -c alembic_user.ini upgrade head # teudb
Confirm teudb landed on the right head:
alembic -c alembic_user.ini current
should print 806cdcd16a87 (head). See teudb Migrations for the two-Alembic-environment workflow — mixing up -c alembic_user.ini with the plain tedb chain is the single most dangerous mistake available in this codebase.
10.4 Create the first ADMIN account
cd ~/tradeentry/backend
source venv/bin/activate
python -m scripts.create_admin
This runs interactively — connect over a real SSH session, not a background/non-interactive shell. It prompts for email, mobile country code + number, an optional full name, and a password (minimum 12 characters, confirmed twice).
This is the only way to create an ADMIN account, and there is exactly one — enforced by a database constraint (ux_usr_master_single_admin), so running this script a second time fails once an admin already exists. Every other category (DEVELOPER, POWER, PAID, FREE) is granted afterward through that admin's own User Management panel at /profile — never by running this script again. See teudb Overview "Creating the first admin" for the full design rationale, including how to promote an existing user to ADMIN instead (a manual UPDATE, not this script).
With AUTH_ENFORCED=false, every page stays open exactly as it worked before v2.0 — nothing breaks for the existing team mid-rollout. Log in as the new admin, confirm the sidebar and /profile User Management panel work, then set AUTH_ENFORCED=true in .env and sudo systemctl restart tradeentry as a deliberate, separate step.
11. nginx Configuration
Main site — tradeentry.in
sudo nano /etc/nginx/sites-available/tradeentry.in
server {
server_name tradeentry.in www.tradeentry.in;
# Backend API — proxy to FastAPI on port 8000
location /api/ {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
# EODIEOD .rar uploads can be 100s of MB (Option file ~170MB+) --
# nginx's default client_max_body_size (1MB) silently 413s these
# before the request ever reaches FastAPI's own 300MB check. Set
# above that backend limit so nginx is never the tighter one.
client_max_body_size 350M;
}
# WebSocket connections
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;
}
# Frontend — Vite preview on port 5173
location / {
proxy_pass http://127.0.0.1:5173;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/tradeentry.in/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/tradeentry.in/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
}
server {
if ($host = www.tradeentry.in) { return 301 https://$host$request_uri; }
if ($host = tradeentry.in) { return 301 https://$host$request_uri; }
listen 80;
server_name tradeentry.in www.tradeentry.in;
return 404;
}
/api/ block MUST come before location /nginx uses longest-prefix matching. If location / comes first in the file it still works,
but put API blocks above / as a convention to make the intent clear.
The critical fix here: without these blocks, the browser calls https://tradeentry.in:8000/api
directly — which is HTTP-only — and the browser blocks it as a mixed-content error.
Docs site — docs.tradeentry.in
sudo nano /etc/nginx/sites-available/docs.tradeentry.in
server {
server_name docs.tradeentry.in;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/tradeentry.in/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/tradeentry.in/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
}
server {
if ($host = docs.tradeentry.in) { return 301 https://$host$request_uri; }
listen 80;
server_name docs.tradeentry.in;
return 404;
}
Enable sites
sudo ln -s /etc/nginx/sites-available/tradeentry.in /etc/nginx/sites-enabled/
sudo ln -s /etc/nginx/sites-available/docs.tradeentry.in /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx
12. SSL / HTTPS with Certbot
sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d tradeentry.in -d www.tradeentry.in -d docs.tradeentry.in
Choose redirect HTTP → HTTPS when prompted.
Certbot automatically edits the nginx config to add SSL certificate paths.
Verify auto-renewal:
sudo certbot renew --dry-run
13. First-time Build and Start
cd ~/tradeentry
APP_ENV=VPS uv run app.py
app.py will:
- Check system dependencies
- Create Python venv and install requirements
- Run
npm installfor frontend and docs - Build frontend (
npm run build) and docs (npm run build) - Start all three services (FastAPI, Vite preview, Docusaurus serve)
This takes 5–10 minutes on first run. After it completes, all services are live.
14. systemd Service (Auto-start on Reboot)
Create the startup wrapper
nano ~/tradeentry/start.sh
#!/bin/bash
# Load nvm so node/npm are on PATH
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
# uv installs to ~/.local/bin
export PATH="$HOME/.local/bin:$PATH"
export APP_ENV=VPS
cd /home/kss/tradeentry
exec uv run app.py --no-build
chmod +x ~/tradeentry/start.sh
Create the service file
sudo nano /etc/systemd/system/tradeentry.service
[Unit]
Description=TradeEntry Trading Platform
After=network.target docker.service
Wants=docker.service
[Service]
Type=simple
User=kss
WorkingDirectory=/home/kss/tradeentry
ExecStart=/home/kss/tradeentry/start.sh
Restart=on-failure
RestartSec=10
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
Enable and start
sudo systemctl daemon-reload
sudo systemctl enable tradeentry
sudo systemctl start tradeentry
sudo systemctl status tradeentry
Service management commands
sudo systemctl status tradeentry # check status
sudo journalctl -u tradeentry -f # live logs
sudo journalctl -u tradeentry -n 50 # last 50 lines
sudo systemctl restart tradeentry # restart
sudo systemctl stop tradeentry # stop
15. Deploying Code Updates
Use pull.sh (kept at /home/kss/pull.sh) for every routine deploy — it's the one-command path:
bash ~/pull.sh
It pulls main, detects what changed (backend/frontend/docs/requirements.txt), installs backend dependencies only when requirements.txt changed, runs both Alembic migration chains (tedb and teudb, only when new migration files landed — see teudb Migrations "Deployment"), rebuilds frontend/docs only when their files changed, and restarts the tradeentry service only if backend or frontend changed. A migration failure halts the deploy before the restart, so the service never runs against an unmigrated teudb.
pull.sh does not handle the one-time setup a brand-new database needs — creating teudb itself, or adding JWT_SECRET to .env for the first time (see step 10). Those are manual, done once.
Manual fallback (if pull.sh itself is broken, or for a from-scratch rebuild)
cd ~/tradeentry
git pull
# If only backend/config changes (no frontend code changed):
sudo systemctl restart tradeentry
# If frontend or docs changed — rebuild then restart:
sudo systemctl stop tradeentry
APP_ENV=VPS uv run app.py # builds + starts interactively
# Once confirmed working, Ctrl+C then:
sudo systemctl start tradeentry
This path does not run Alembic migrations either — run step 10.3 manually first if new migration files are part of the update.
16. Running Bhav Download Manually
cd ~/tradeentry/backend
APP_ENV=VPS ../backend/venv/bin/python -m nseeod.downloadbhav
Or via the web UI at https://tradeentry.in/bhav-downloader.
Troubleshooting
psql fails with "no such file or directory"
Symptom:
psql: error: connection to server on socket "/var/run/postgresql/.s.PGSQL.5432" failed: No such file or directory
Cause: psql tries a Unix socket by default. The PostgreSQL database is running inside Docker, which only exposes TCP.
Fix: Always connect via TCP with -h 127.0.0.1:
PGPASSWORD=tedb psql -h 127.0.0.1 -U postgres -d tedb -c "SELECT 1;"
pg_lsclusters shows down but port 5432 is occupied
Symptom: Native pg_ctlcluster 14 main start fails with "Address already in use".
Cause: Docker is running PostgreSQL on port 5432. The native PostgreSQL cluster cannot also bind that port.
Fix: Use Docker PostgreSQL — it IS the database. Do not try to start the native cluster. Check Docker:
docker ps | grep postgres
If the Docker container is missing:
docker start postgres-db
LAST SYNC shows "---" / "Calculating..."
Cause 1 — CORS (pre-nginx): Backend returns correct data but CORS blocks the browser.
Fixed by setting allow_origins=["*"] in backend/app/main.py when APP_ENV=VPS.
Cause 2 — Mixed content (post-nginx): The frontend is served over HTTPS but tries to call
https://tradeentry.in:8000/api directly — browsers block this as a mixed-content error.
Visible in DevTools Console:
Failed to load resource: net::ERR_SSL_PROTOCOL_ERROR :8000/api/bhav-downloader/last-update
Fix: The frontend now uses relative /api URLs. nginx proxies /api/ → port 8000.
The nginx /api/ location block must be present in the tradeentry.in config.
Cause 3 — PostgreSQL down: Docker container stopped or VPS rebooted without --restart=always.
docker ps # check if postgres-db is up
docker start postgres-db # start if stopped
docker update --restart=always postgres-db # prevent future occurrences
File upload fails with "413 Request Entity Too Large" / status code 413
Symptom: Uploading a large file (e.g. an EODIEOD .rar on the EODIEOD Upload page)
fails with a generic Request failed with status code 413 in the browser console —
no specific error message, just the bare axios error.
Cause: nginx's default client_max_body_size is only 1MB. It rejects the
request itself, before it ever reaches FastAPI — which is exactly why the error
is generic rather than one of the backend's own specific messages (e.g. the
300MB-per-file check in eodieod_upload.py would say "... file exceeds 300MB limit" if the request had actually reached it).
How to tell which layer rejected it: check the failed request's Response body in
DevTools → Network. Plain/blank body (nginx's own error page) means nginx; a JSON
body with a detail field means the backend's own check fired instead — in that
case, raise _MAX_UPLOAD_BYTES in backend/app/api/eodieod_upload.py, not nginx.
Fix (nginx layer): add client_max_body_size to the /api/ location block in
/etc/nginx/sites-available/tradeentry.in (see the config in section 11 above —
already includes this):
sudo nano /etc/nginx/sites-available/tradeentry.in
# add: client_max_body_size 350M; inside location /api/ { ... }
sudo nginx -t && sudo systemctl reload nginx
systemd service fails with status=127
Symptom: journalctl -u tradeentry shows start.sh: uv: command not found.
Cause: systemd runs with a minimal PATH that does not include ~/.local/bin where uv is installed.
Fix: Add to start.sh:
export PATH="$HOME/.local/bin:$PATH"
Also add nvm loader for node/npm:
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
Vite preview blocks tradeentry.in hostname
Symptom: nginx proxies correctly but Vite returns 403 Forbidden or host rejection error.
Fix: Add allowedHosts to frontend/vite.config.js:
preview: {
port: 5173,
host: true,
allowedHosts: ['tradeentry.in', 'www.tradeentry.in', 'localhost'],
}
Documentation link broken (HTTPS → HTTP:3000)
Symptom: "View Documentation" link becomes https://tradeentry.in:3000 which fails because port 3000 is HTTP only.
Fix: Two parts:
Home.jsxuseshttp://explicitly for docs link (notwindow.location.protocol)docs.tradeentry.innginx proxy routes to port 3000 over HTTPS
npm not found when running app.py on Windows
Cause: On Windows, npm is npm.cmd — not in PATH as plain npm.
Fix: Already handled in app.py with shutil.which("npm").
Old Node.js version (v12) on Ubuntu
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
source ~/.bashrc
nvm install 20
Quick Reference
| Task | Command |
|---|---|
| Check all services | sudo systemctl status tradeentry nginx docker |
| View app logs | sudo journalctl -u tradeentry -f |
| Restart app | sudo systemctl restart tradeentry |
| Check DB rows | PGPASSWORD=tedb psql -h 127.0.0.1 -U postgres -d tedb -c "SELECT COUNT(*) FROM scr_nseeq_eod;" |
| Reload nginx | sudo nginx -t && sudo systemctl reload nginx |
| Kill ports manually | fuser -k 8000/tcp 5173/tcp 3000/tcp |
| Routine deploy (pulls, migrates, builds, restarts) | bash ~/pull.sh |
| Check Docker | docker ps |
| Start Docker DB | docker start postgres-db |
| Live rebuild | APP_ENV=VPS uv run app.py |
| Skip rebuild | APP_ENV=VPS uv run app.py --no-build |
| Migrate tedb only | cd ~/tradeentry/backend && alembic upgrade head |
| Migrate teudb only | cd ~/tradeentry/backend && alembic -c alembic_user.ini upgrade head |
| Create the first ADMIN | cd ~/tradeentry/backend && python -m scripts.create_admin |
Last updated: 2026-08-31