# Production deployment (Linux, no Docker)

Target: Ubuntu 24.04 LTS, one machine running Node + MariaDB + Nginx + coturn. Split the database,
TURN and media serving onto separate hosts as you grow; nothing in the app assumes they are co-located.

## 1. Packages

```bash
sudo apt-get update
sudo apt-get install -y nginx mariadb-server ffmpeg coturn certbot python3-certbot-nginx build-essential python3
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - && sudo apt-get install -y nodejs
sudo adduser --system --group --home /srv/zuzufy zuzufy
```

## 2. Application

```bash
sudo -u zuzufy git clone <your repo> /srv/zuzufy/app
cd /srv/zuzufy/app
sudo -u zuzufy npm ci --omit=dev
sudo -u zuzufy cp .env.example .env && sudo -u zuzufy chmod 600 .env
```

Production `.env` essentials:

```ini
NODE_ENV=production
PORT=8900
APP_URL=https://radio.example.com
TRUST_PROXY=1
SESSION_SECRET=<openssl rand -hex 32>
ENCRYPTION_KEY=<openssl rand -hex 32>
MEDIA_SIGNING_SECRET=<openssl rand -hex 24>
STORAGE_PATH=/srv/zuzufy/storage
PUBLIC_MEDIA_URL=/media            # or https://cdn.example.com/media
SERVE_MEDIA=0                      # Nginx serves media, not Node
LIVE_ANNOUNCED_IP=<public IPv4>
TURN_SERVER=turn:turn.example.com:3478?transport=udp,turns:turn.example.com:5349?transport=tcp
TURN_USERNAME=zuzufy
TURN_PASSWORD=<long random>
EMAIL_PROVIDER=smtp
SMTP_HOST=...  SMTP_USER=...  SMTP_PASS=...  EMAIL_FROM=no-reply@example.com
PAYMENT_PROVIDER=stripe            # optional
PAYMENT_SECRET=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
REQUIRE_ADMIN_TOTP=1
```

Then:

```bash
sudo -u zuzufy npm run migrate
sudo -u zuzufy npm run create-admin -- --email you@example.com --name "Admin"
```

## 3. systemd

`/etc/systemd/system/zuzufy.service`:

```ini
[Unit]
Description=Zuzufy
After=network-online.target mariadb.service
Wants=network-online.target

[Service]
User=zuzufy
Group=zuzufy
WorkingDirectory=/srv/zuzufy/app
Environment=NODE_ENV=production
ExecStart=/usr/bin/node server.js
Restart=always
RestartSec=3
KillSignal=SIGTERM
TimeoutStopSec=20
LimitNOFILE=1048576
# Hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/srv/zuzufy/storage /tmp
PrivateTmp=true

[Install]
WantedBy=multi-user.target
```

```bash
sudo systemctl daemon-reload && sudo systemctl enable --now zuzufy
journalctl -u zuzufy -f          # structured JSON logs (pino)
```

If a mediasoup worker dies the process exits deliberately and systemd restarts it; live sessions
fail over to automated programming.

## 4. Nginx (TLS, WebSocket proxying, media with secure_link)

`/etc/nginx/sites-available/zuzufy`:

```nginx
map $http_upgrade $connection_upgrade { default upgrade; '' close; }

upstream zuzufy { server 127.0.0.1:8900; keepalive 64; }

server {
  listen 80;
  server_name radio.example.com;
  return 301 https://$host$request_uri;
}

server {
  listen 443 ssl http2;
  server_name radio.example.com;
  # ssl_certificate / ssl_certificate_key managed by certbot

  client_max_body_size 4100m;            # largest upload (MAX_VIDEO_UPLOAD_MB) + headroom
  proxy_request_buffering off;           # stream uploads straight to Node

  # Media: served by Nginx directly. Private keys require the signature Zuzufy issues:
  #   md5 = base64url(MD5("{expires}{uri} {MEDIA_SIGNING_SECRET}"))
  location /media/public/ {
    alias /srv/zuzufy/storage/public/;
    expires 1d;
    add_header Access-Control-Allow-Origin * always;
    add_header X-Content-Type-Options nosniff always;
  }
  location /media/private/ {
    secure_link $arg_md5,$arg_expires;
    secure_link_md5 "$secure_link_expires$uri <MEDIA_SIGNING_SECRET>";
    if ($secure_link = "")  { return 403; }
    if ($secure_link = "0") { return 403; }
    alias /srv/zuzufy/storage/private/;
    add_header Cache-Control "private, max-age=3600" always;
    add_header Access-Control-Allow-Origin * always;   # Web Audio / MSE need CORS for CDN origins
    add_header X-Content-Type-Options nosniff always;
  }
  location /media/ { return 404; }       # tmp/ and anything else is never served

  location /ws {
    proxy_pass http://zuzufy;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection $connection_upgrade;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto https;
    proxy_read_timeout 3600s;
    proxy_send_timeout 3600s;
  }

  location / {
    proxy_pass http://zuzufy;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto https;
    proxy_read_timeout 300s;
  }
}
```

```bash
sudo ln -s /etc/nginx/sites-available/zuzufy /etc/nginx/sites-enabled/
sudo certbot --nginx -d radio.example.com
sudo nginx -t && sudo systemctl reload nginx
```

**Apache** equivalent: `mod_proxy_wstunnel` for `/ws` (`ProxyPass /ws ws://127.0.0.1:8900/ws`),
`ProxyPass / http://127.0.0.1:8900/`, and either keep `SERVE_MEDIA=1` (Node streams media) or use
`mod_auth_token`-style signing at the edge.

**CDN**: point `PUBLIC_MEDIA_URL=https://cdn.example.com/media` at a CDN whose origin is the Nginx
media locations above; configure the CDN's token authentication with the same MD5 scheme, or let the
CDN forward `md5`/`expires` to Nginx and cache by full URL (Zuzufy buckets expiries so concurrent
listeners share identical URLs).

## 5. coturn (TURN for live WebRTC)

`/etc/turnserver.conf`:

```ini
listening-port=3478
tls-listening-port=5349
fingerprint
lt-cred-mech
user=zuzufy:<TURN_PASSWORD>
realm=turn.example.com
cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem
pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem
min-port=49160
max-port=49999
no-multicast-peers
denied-peer-ip=10.0.0.0-10.255.255.255
denied-peer-ip=172.16.0.0-172.31.255.255
denied-peer-ip=192.168.0.0-192.168.255.255
```

Avoid overlapping coturn's relay range with the SFU range if they share a host (e.g. SFU
`40000-49000`, coturn `49160-49999`).

## 6. Firewall

```bash
sudo ufw allow 22/tcp && sudo ufw allow 80,443/tcp
sudo ufw allow 40000:49000/udp && sudo ufw allow 40000:49000/tcp   # mediasoup (LIVE_RTC_*_PORT)
sudo ufw allow 3478,5349/tcp && sudo ufw allow 3478,5349/udp && sudo ufw allow 49160:49999/udp   # coturn
sudo ufw enable
```

MariaDB should listen on localhost or a private network only.

## 7. Stripe

Create a webhook endpoint at `https://radio.example.com/api/payments/webhook` with events
`checkout.session.completed`, `customer.subscription.updated`, `customer.subscription.deleted`,
`invoice.paid`, `invoice.payment_failed`, `charge.refunded`. Put its signing secret in
`STRIPE_WEBHOOK_SECRET`. Enable the Customer Portal in the Stripe dashboard. Webhook events are
stored and de-duplicated in `payment_events` (visible in Admin → Payments).

## 8. Optional: AI

```ini
AI_PROVIDER=anthropic
ANTHROPIC_API_KEY=...
AI_MODEL=claude-opus-5
TRANSCRIBE_PROVIDER=whisper-cpp
WHISPER_CPP_BIN=/usr/local/bin/whisper-cli
WHISPER_CPP_MODEL=/opt/whisper/ggml-base.en.bin
```

Build whisper.cpp from source (`cmake -B build && cmake --build build -j`) and download a model.
Transcription runs locally; only text is sent to the text provider.

## 9. Backups & operations

- `mariadb-dump --single-transaction zuzufy | gzip > /backups/zuzufy-$(date +%F).sql.gz` nightly.
- Back up `/srv/zuzufy/storage/public` and `private` (rsync/object storage). `tmp/` is disposable.
- Health checks for monitoring: `/health`, `/health/database`, `/health/media`, `/health/websocket`,
  `/health/broadcast` (HTTP 200/503 with JSON detail). Admin → System shows the same plus queues.
- Upgrades: `git pull && npm ci --omit=dev && npm run migrate && sudo systemctl restart zuzufy`.
  Rollback a migration with `npm run migrate:down 1`.

## 10. Scaling path

1. Move MariaDB to its own host; add a read replica for analytics if needed.
2. Put media behind a CDN (`PUBLIC_MEDIA_URL`); keep P2P delivery on to cut egress further.
3. Run several app instances behind Nginx `upstream`; add a Redis adapter for the Hub (see
   `docs/architecture.md` §3) and a Redis rate-limit store.
4. Dedicated SFU hosts per region for live; HLS packaging for very large live audiences.

## 11. Production checklist

- [ ] `NODE_ENV=production`, `https://` `APP_URL`, `TRUST_PROXY=1`, secrets generated (startup validates)
- [ ] `SERVE_MEDIA=0` with Nginx `secure_link` configured using the same `MEDIA_SIGNING_SECRET`
- [ ] `LIVE_ANNOUNCED_IP` set, SFU ports open, TURN reachable (test with a phone on cellular)
- [ ] SMTP configured (`EMAIL_PROVIDER=smtp`)
- [ ] First admin created with `create-admin`; `REQUIRE_ADMIN_TOTP=1`; 2FA enabled
- [ ] Stripe webhook secret set (if payments enabled)
- [ ] Backups scheduled and restore tested
- [ ] `MALWARE_SCAN_COMMAND` set if you accept uploads from untrusted creators
