Skip to main content

Requirements and sizing

Size the host for transcoding, not for web serving. ffmpeg runs inside the api container, so the box that answers HTTP is the same box that encodes video.

One TargetAll job on a 1080p source is twelve full-source encode passes: four HLS ladder rungs, four trick-play I-frame encodes and four progressive web videos. Expect roughly 1.5–2.5× the source duration of wall clock on 4 vCPU at -preset veryfast.

Host sizing

Launch profileHostWhy
Small or private — under 50 users, occasional uploads4 vCPU / 8 GB / 160 GB — about $63/monthThe floor. Set API_CPUS=3.0, API_MEM_LIMIT=4g, POSTGRES_SHARED_BUFFERS=512MB, POSTGRES_EFFECTIVE_CACHE_SIZE=3GB: the prod overlay's defaults target the 16 GB box, and Docker refuses to start a container whose cpus exceeds the host core count.
Public launch8 vCPU / 16 GB / 160 GB — about $168/monthThe overlay's defaults (API_CPUS=7.0, API_MEM_LIMIT=6g). Transcoding takes seven cores and still leaves one for the api and Postgres. Roughly halves upload-to-playable time.
With ClamAV (MALWARE_SCAN_ENABLED=true)add 2 GB of RAMclamd loads the full signature database into memory — about 1.5–2 GiB resident — and OOM-kills on a small host.

Do not use a 2 GB droplet. Production pulls images rather than building them, so next build never runs on the box, but Postgres, api, frontend, search, Caddy and one transcode still need about 4 GB before ClamAV.

Prices are DigitalOcean Premium droplet list prices as of the sizing work in deploy/README.md; treat them as an order of magnitude, not a quote.

Swap

A 4 GB swapfile is the difference between a slow transcode and an OOM-killed Postgres.

fallocate -l 4G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab

deploy/provision.sh does this — and the /etc/fstab line people forget — along with the service user, the Docker log cap, unattended-upgrades and the backup timer.

Transcode scratch

The prod overlay sets TMPDIR=/scratch, which redirects every temp-file site at once, including a full uncompressed PCM wav for Whisper and a full second encode for VP9. Budget:

about 4 × UPLOAD_MAX_SIZE × transcoding_concurrency

With the default UPLOAD_MAX_SIZE=2G that is roughly 8 GB per concurrent job. Mount a separate block-storage volume at the host path backing the transcode_tmp named volume; without one, scratch lands under /var/lib/docker on the same root disk as Postgres.

Raising transcoding_concurrency multiplies both CPU and scratch disk. Set transcoding_threads to vCPU−1 and leave concurrency at 1. Both are runtime settings in the admin UI and need no restart.

Software floors

RequirementVersionWhy
Docker Engine24 or newer
Docker Compose2.20 minimum, 2.24 in production2.20 for the top-level include: key; 2.24 for the !reset / !override merge tags that close the database port publishes. Below 2.24 those tags are silently ignored and Postgres, Redis and search stay exposed.
Go1.26Only if you build from source. A normal deployment pulls images.
PostgreSQL18Bundled by the compose stack.
Redis8Bundled by the compose stack.
Node.js20 or newer, with npmOnly for host-side frontend development.
GNU make and gitOnly for the source-checkout path.

Ports that must verify closed

The real firewall is the loopback discipline in docker-compose.prod.yml, not a host ufw — Docker installs its own DOCKER-USER iptables chain that is traversed before ufw's rules, so ufw deny 5432 has no effect on a container publishing 5432. Use a cloud firewall outside the host, and then verify from another machine:

nmap -Pn -p 22,80,443,3000,5432,6379,8080,8081 <host-ip>

Only 22, 80 and 443 may be open. If 5432 or 6379 answer, the prod overlay is not being applied — wrong -f chain, or a Compose older than 2.24.

Two more ports are opened only by the profiles that need them, because remote peers dial them directly and a reverse proxy cannot stand in front of either:

PortOpen it whenWhy it cannot be loopback
1935/tcpVIDRA_COMPOSE_PROFILES contains mediaRTMP ingest. OBS on a creator's laptop connects from the internet.
4001/tcp+udp…contains ipfslibp2p swarm. Peers dial in; a node nobody can reach only ever pushes.

Both stay closed on an instance that has not enabled those profiles, which is the default. deploy/provision.sh reads the profiles out of your env file and prints exactly the list your firewall needs.

The full service and port matrix is in Ports and services.

Managed Postgres — not at launch

internal/store/store.go hardcodes MaxConns=10 after ParseConfig, silently discarding pool_max_conns from the DSN. Core 10 plus search 10 plus a 4-connection import pool is 24 connections, above the smallest DigitalOcean Managed plan's cap. Run the bundled Postgres 18 with the nightly dump off-site, and move to managed once a DATABASE_MAX_CONNS knob exists.