Two pieces:
- Frontend — React build → nginx html root
- Backend — FastAPI service →
127.0.0.1:27012, proxied via nginx
Used throughout the rest of this guide. Adjust to match your setup:
DOMAIN=yourdomain.com # the domain serving the site
SERVER=user@your-server.example.com # ssh target
HTML_DIR=/var/www/$DOMAIN/html # nginx root for static files
USER_NAME=$USER # linux user owning portfolio-apiOpen the site config:
sudo nano /etc/nginx/sites-available/$DOMAIN.confInside the existing server { ... } block, add:
# ── Portfolio API proxy ─────────────────────────────────────────────
location /api/portfolio/ {
rewrite ^/api/portfolio/(.*)$ /$1 break;
proxy_pass http://127.0.0.1:27012;
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_http_version 1.1;
proxy_read_timeout 30s;
}
# ── Aggressive caching for hashed build assets ──────────────────────
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# ── SPA fallback ────────────────────────────────────────────────────
# Any request that didn't match a redirect, proxy, or static file
# falls through to index.html so React Router can handle it.
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache";
}Test + reload:
sudo nginx -t # syntax check
sudo systemctl reload nginxNote on order: nginx prefix-matching uses longest-match-wins, NOT declaration order. You can place these blocks anywhere in the
serverblock. Order only matters for regexlocation ~blocks.
# On the server
INSTALL_DIR=/home/$USER_NAME/portfolio-api
sudo mkdir -p $INSTALL_DIR
sudo chown $USER_NAME:$USER_NAME $INSTALL_DIRFrom your dev machine:
scp backend/main.py backend/get_spotify_token.py backend/requirements.txt \
backend/portfolio-api.service backend/.env.example \
$SERVER:/home/$USER_NAME/portfolio-api/Back on the server:
cd /home/$USER_NAME/portfolio-api
python3 -m venv venv
./venv/bin/pip install -r requirements.txt
# Configure
cp .env.example .env
nano .env # set ALLOWED_ORIGINS=https://$DOMAIN
# also SPOTIFY_* if you want now-playing
# Substitute your linux username into the systemd unit
sed -i "s/__USER__/$USER_NAME/g" portfolio-api.service
sudo cp portfolio-api.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now portfolio-api
sudo systemctl status portfolio-api # active (running)Verify:
curl http://127.0.0.1:27012/health
# {"ok":true,"ts":"..."}
curl https://$DOMAIN/api/portfolio/health
# same, via nginxOn your dev machine:
cd my-portfolio
# First-time deps
npm install
# (optional) refresh GitHub repo ranking — runs the scoring algorithm
npm run fetch-repos
# Build
npm run buildPush to server. CRITICAL: trailing slash on dist/ so contents (not the dir itself) upload. Force-readable perms so nginx can serve:
rsync -avz --delete \
--chmod=D755,F644 \
dist/ $SERVER:$HTML_DIR/If you skipped --chmod, fix perms after:
ssh $SERVER "sudo chmod -R a+rX $HTML_DIR"Save this as scripts/deploy.sh so you don't have to remember the flags:
#!/usr/bin/env bash
set -e
DOMAIN=${DOMAIN:?set DOMAIN env var}
SERVER=${SERVER:?set SERVER env var}
HTML_DIR=${HTML_DIR:-/var/www/$DOMAIN/html}
echo "→ building…"
npm run build
echo "→ syncing to $SERVER:$HTML_DIR…"
rsync -avz --delete --chmod=D755,F644 dist/ "$SERVER:$HTML_DIR/"
echo "→ ✓ deployed to https://$DOMAIN"Run with:
DOMAIN=yourdomain.com SERVER=user@your-server ./scripts/deploy.shOr chmod +x scripts/deploy.sh and export those vars to your shell rc.
| URL | Expected |
|---|---|
https://$DOMAIN/ |
Home |
https://$DOMAIN/uses |
Uses page |
https://$DOMAIN/now |
Now page |
https://$DOMAIN/colophon |
Colophon |
https://$DOMAIN/console |
Terminal |
https://$DOMAIN/guestbook |
Guestbook |
https://$DOMAIN/random-404 |
404 page |
https://$DOMAIN/api/portfolio/health |
{"ok":true,...} |
https://$DOMAIN/api/portfolio/visits |
{"total":N,...} |
SPA routes 404
→ The location / block with try_files is missing. Add it back.
Assets return 404
→ Permissions. nginx (www-data) must read the files. Check with:
namei -l $HTML_DIR/assets/index-<hash>.js
ls -la $HTML_DIR/assets/
# Fix: sudo chmod -R a+rX $HTML_DIRAssets return 403
→ Directory has drwx------. Run the chmod above.
API returns CORS error
→ ALLOWED_ORIGINS in backend .env doesn't include your domain. Fix and restart: sudo systemctl restart portfolio-api.
Backend won't start
→ journalctl -u portfolio-api -n 50 shows the error. Common causes:
- venv path wrong (check the
__USER__substitution actually ran) pip installdidn't complete- Port 27012 already in use
Spotify now-playing returns 503
→ SPOTIFY_* env vars not set. The endpoint is optional; the frontend gracefully degrades to a normal link.
Page works on direct hit but stale via Cloudflare
→ Purge Cloudflare cache (dashboard → Caching → Purge Everything). Asset URLs are hash-based so they self-bust, but index.html itself may be cached. The Cache-Control: no-cache header on the location / block tells CF not to cache it, but purge anyway after deploy.