A stateless video/audio downloader for YouTube, Twitter/X, TikTok and Instagram (plus adult sites behind an opt-in NSFW toggle). Paste a link, pick a quality (or extract audio as mp3), download. Nothing is stored server-side — no database, no persisted files.
It also converts images (JPG, PNG, WebP) and videos (MP4, WebM, GIF, MP3) entirely in the browser — files are never uploaded.
Note: this project is heavily vibe coded — most of the implementation was written with Claude (Sonnet 5) — but guided end to end by a developer who understands what the code is actually doing.
- Frontend: Vite + React, TanStack Router/Query, Tailwind v4, shadcn/ui on Base UI. Pure client-side SPA. Package manager: bun.
- Backend: Go, stdlib
net/httponly. Wrapsyt-dlp+ffmpeg. - Deployment: Docker Compose (
docker-compose.yml) for both services.
- No database — job state lives in memory (
backend/internal/jobs), keyed by a random ID, and expires a few minutes after the download finishes. POST /api/downloadscreates a job, and the page immediately hands/fileto the browser's own download manager, so the download carries on with the tab backgrounded or closed./filestreams the result as it's produced:yt-dlp -o -straight through for a single format, or twoyt-dlpprocesses piped intoffmpeg -c copyfor a video+audio merge (fragmented MP4, or MKV for VP9/AV1). Bytes start within seconds, so nothing sits idle behind Cloudflare's 100s timeout. A failure before the first byte is an HTTP error (the browser shows a failed download); after it, the connection is aborted so a truncated file is never kept as complete. The page follows progress over SSE (/events, reconnecting viaGET /api/downloads/{id}if the tab was frozen).- Nothing is written to disk: the only temp files, an mp3's source audio
while it's converted, live on a RAM-backed tmpfs (
SCRATCH_SIZE). - Per-IP rate limiting and a server-wide cap on concurrent yt-dlp/ffmpeg processes, both in-memory, no external dependencies.
- The converter (
/convert) never touches the backend. Images go through a<canvas>; video through ffmpeg.wasm (single-threaded), self-hosted and fetched only on the first video conversion (~32 MB, ~11 MB gzipped, cached as immutable). The input is mounted read-only (WORKERFS) rather than copied into memory; the output must fit in WebAssembly's memory, hence the 2 GiB cap. - Successful
/proberesults are cached in memory for a few minutes so the same link isn't re-probed repeatedly. - Sensitive (age-restricted) tweets, which yt-dlp can't see logged out, fall back to the public FxEmbed API API. Only the tweet ID is sent, from the server — never the visitor's IP — and only when yt-dlp has already failed for that reason.
- Only the platform host is ever logged (e.g.
www.youtube.com) — never the URL or title. Logs are color-accented text on stdout by default (LOG_FORMAT=jsonfor structured output instead). - Optional dated log files (
LOG_DIR), bind-mounted to./logsat the repo root by default —cat logs/vndl-2026-09-23.log, nodocker compose execneeded. History survives a container rebuild this way; stdout capture alone doesn't. Auto-pruned afterLOG_RETENTION_DAYS. If the container can't write there,mkdir -p logson the host first (a fresh Docker-created directory is root-owned). - Only YouTube/Twitter/TikTok/Instagram (plus opt-in adult sites) are accepted; the NSFW toggle is a client-side preference only, not a server-side security boundary.
Backend needs yt-dlp/ffmpeg, which only exist inside its Docker
image — always run it via Docker unless you install both locally.
cp .env.example .env
docker compose up --build- Frontend: http://localhost:8081
- Backend: http://localhost:8080
For frontend-only iteration (proxies /api to the backend container):
cd frontend && bun install && bun dev # http://localhost:5173Frontend build runs vite build && tsc -b (in that order — vite build
generates the gitignored router types tsc needs).
SEO metadata (canonical URL, Open Graph/Twitter tags, sitemap.xml,
robots.txt) is hardcoded to dl.vncius.dev in frontend/index.html and
frontend/public/{robots.txt,sitemap.xml} — update those if deploying
under a different domain.
HTTPS is handled externally (e.g. a Cloudflare Tunnel) — the app itself only serves plain HTTP.
Set these in a .env at the repo root (next to docker-compose.yml) —
.env.example lists them all with their defaults. PORT, LOG_DIR and
the binary paths are fixed in docker-compose.yml, since the image, Caddy
and the log mount depend on them.
| var | default | purpose |
|---|---|---|
FRONTEND_PORT |
8081 |
host port for the frontend |
BACKEND_PORT |
8080 |
only used with a docker-compose.override.yml for direct local debugging |
PORT |
8080 |
backend listen port inside its container |
ALLOWED_ORIGINS |
http://localhost:5173 |
CORS allow-list |
RATE_LIMIT_RPS / RATE_LIMIT_BURST |
1 / 5 |
per-IP token bucket |
MAX_CONCURRENT_YTDLP |
6 |
server-wide cap on concurrent downloads, each held until its last byte is sent |
MAX_JOBS_PER_IP |
2 |
concurrent downloads per client (IPv6 grouped by /64); 0 disables |
MAX_JOB_DURATION |
2h |
a download (including the transfer to the client) running longer is stopped; 0 disables |
MAX_FILESIZE |
2G |
yt-dlp --max-filesize |
MAX_SSE_CONNECTIONS |
512 |
server-wide cap on progress streams; 0 disables |
FRONTEND_BIND |
127.0.0.1 |
interface the frontend port is published on |
BACKEND_MEM_LIMIT / BACKEND_CPUS |
2g / 2 |
backend container limits; memory includes SCRATCH_SIZE |
SCRATCH_SIZE |
512m |
RAM-backed tmpfs for mp3 source audio, shared by all jobs |
CONCURRENCY_MAX_WAIT |
15s |
max wait for a free slot over the cap |
JOB_TTL |
5m |
how long a job stays queryable, and a failed download retryable |
YTDLP_PATH / FFMPEG_PATH |
yt-dlp / auto |
binary paths |
LOG_FORMAT |
text |
text or json |
PROBE_CACHE_TTL |
10m |
how long probe results are cached; 0 disables |
LOGS_DIR |
./logs |
host directory for log files (must be writable by UID 1000) |
LOG_DIR |
(empty = disabled) | directory for dated log files; docker-compose.yml bind-mounts this to ./logs |
LOG_RETENTION_DAYS |
30 |
how many days of log files to keep before deleting |
The backend's port is never published by docker-compose.yml — the
frontend (Caddy) is the sole entry point, which per-IP rate limiting
depends on. Client IP is read from CF-Connecting-IP (Cloudflare
Tunnel) first, then X-Forwarded-For, then the raw TCP peer. That's why
Caddy is published on loopback only: cloudflared must run on the same
host. If it doesn't, set FRONTEND_BIND=0.0.0.0 and firewall the port to
cloudflared's address.
yt-dlp can still make requests the host allow-list never sees
(redirects, manifests, media segments). For a public deployment, also
block the backend container's egress to private/LAN ranges at the host
firewall (e.g. the DOCKER-USER iptables chain).
.gitlab-ci.yml redeploys on every push to main, via a self-hosted
GitLab Runner on the host itself (no inbound access needed). One-time
setup on the server:
- Install
gitlab-runner. - Create a project runner in GitLab (tag
home-server), then register:sudo gitlab-runner register --non-interactive \ --url "https://gitlab.com" --token "<token>" \ --executor "shell" --description "vndl-home-server"
sudo usermod -aG docker gitlab-runner && sudo systemctl restart gitlab-runner
Every push to main then rebuilds and restarts the stack automatically.
The job runs in the runner's own checkout, not your clone, so it doesn't
see a .env you keep elsewhere. Point it at one in GitLab → Settings →
CI/CD → Variables: VNDL_ENV_FILE = the file's absolute host path
(readable by the gitlab-runner user). Use an absolute LOGS_DIR in it,
since ./logs resolves against the runner's checkout.
backend/
cmd/server/ .entrypoint
internal/
api/ .HTTP handlers
config/ .env-based config
downloader/ .yt-dlp process building, format probing, url allow-list
jobs/ .in-memory job state + SSE fan-out
logging/ .canonical log line helper
middleware/ .CORS, rate limiting
frontend/
src/
routes/ .TanStack Router file-based routes
components/ .UI (download-console is the main feature)
hooks/ .use-download-progress (SSE)
lib/ .api client, formatting helpers- yt-dlp and FFmpeg do the actual downloading and processing.
- Sensitive tweets are resolved through the FxEmbed API
(
api.fxtwitter.com) — thanks to the FxEmbed project for running it publicly.
See CHANGELOG.md.