Docker#
One image runs the vid2context HTTP API with everything the full feature set needs: yt-dlp (1000+ sites), deno (yt-dlp's JavaScript runtime for YouTube), ffmpeg, and optionally whisper.cpp for local speech-to-text. The same image deploys to Railway, Cloud Run and Alibaba Cloud.
Files: deploy/containers/ (Dockerfile, compose file, platform configs, test.sh). The root Dockerfile is a different, smaller image: the MCP stdio server for directory listings.
Build and run#
Docker 23 or newer (BuildKit). From the repository root:
docker build -f deploy/containers/Dockerfile -t vid2context .
docker run -d --name vid2context -p 127.0.0.1:8787:8787 -v vid2context-data:/data --env-file .env vid2context
curl http://127.0.0.1:8787/api/v1/health
curl "http://127.0.0.1:8787/api/v1/transcript?url=https://youtu.be/jNQXAC9IVRw"--env-file .env is optional: every key is optional (see .env.example). With none set you get YouTube captions plus the keyless speech-to-text and LLM rungs.
Hardened run, the way test.sh checks it:
docker run -d -p 127.0.0.1:8787:8787 -v vid2context-data:/data \
--read-only --tmpfs /tmp --cap-drop ALL --security-opt no-new-privileges vid2contextWhat is in the image#
| Piece | Version | How it is verified |
|---|---|---|
| Node | 24.22.2 on Debian 13 (trixie) slim | pinned base image tag |
| ffmpeg, ffprobe | Debian's package (7.1) | Debian's signed archive |
| yt-dlp | 2026.10.26, unpacked build in /opt/yt-dlp |
the release's SHA2-256SUMS, whose own sha256 is pinned in the Dockerfile |
| deno | v2.9.8 | pinned sha256 per architecture |
| whisper.cpp (optional) | v1.9.5 | built from the tag, commit hash checked |
| tini | Debian's package | PID 1: reaps yt-dlp and ffmpeg children, forwards signals |
Only the server and what it imports go in: @vid2context/server, every provider that declares the node runtime, and their dependencies, installed with npm ci --omit=dev --ignore-scripts from package-lock.json. The build imports the generated registry once, so a provider with a missing entry file fails the build instead of the deploy.
Why tsx and not plain Node. Node 24 can strip TypeScript types itself, but only for "erasable" syntax. The code uses constructor parameter properties (packages/core/src/telemetry/memory.ts), which Node rejects with ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX. The image runs node --import tsx packages/server/src/node.ts: one process, the same transform npm run start:server uses in development, at the tsx version locked in package-lock.json.
Why the unpacked yt-dlp. The single-file yt-dlp unpacks its libraries into /tmp on every run. That fails when /tmp is mounted noexec (as --tmpfs does) and costs startup time on every call.
Size, measured on 2026-11-14 (uncompressed, linux/amd64):
| Image | Size |
|---|---|
| default | 1398 MB |
--build-arg WITH_WHISPER=1 |
1429 MB |
Debian's ffmpeg package and its libraries are 461 MB of that, node_modules 105 MB plus the transformers.js runtime for local speech-to-text (onnxruntime-node, trimmed to this platform), the unpacked yt-dlp 99 MB, deno 92 MB, Node 122 MB.
Environment#
The image sets these; override any with -e:
| Variable | Image value | Why |
|---|---|---|
HOST |
0.0.0.0 |
listen on the container network (the server's own default is 127.0.0.1) |
PORT |
8787 |
platforms that inject PORT (Railway, Cloud Run) override it |
VID2CONTEXT_HOME |
/data |
library, models, media cache, managed files |
VID2CONTEXT_PUBLIC |
1 |
refuse local file paths as inputs. Set 0 only for a private container where you mount media |
YTDLP_PATH, YTDLP_AUTO_UPDATE |
/opt/yt-dlp/yt-dlp, 0 |
use the verified copy; rebuild the image to update it |
FFMPEG_PATH, FFPROBE_PATH |
Debian's binaries | no managed download (VID2CONTEXT_FFMPEG_DOWNLOAD=0) |
XDG_CACHE_HOME, DENO_DIR |
under /data/cache |
yt-dlp and deno caches survive restarts and stay off the read-only root |
Set VID2CONTEXT_API_KEYS before exposing the port beyond your machine: without it every route is open. Provider keys are the same variables as in .env.example. Never bake keys into the image or pass them as build args; use --env-file, compose's env_file, or the platform's secret store.
Volumes and writes#
The process runs as the unprivileged node user (uid 1000) and writes only to /data (declared as a VOLUME) and /tmp, so --read-only --tmpfs /tmp works. Use a named volume (as above) or a bind mount owned by uid 1000. To transcribe your own files in a private container: -e VID2CONTEXT_PUBLIC=0 -v "$PWD/media:/media:ro", then ?url=/media/talk.mp3&mode=stt.
whisper.cpp#
docker build -f deploy/containers/Dockerfile --build-arg WITH_WHISPER=1 -t vid2context:whisper .
docker run --rm -v vid2context-data:/data vid2context:whisper \
node --import tsx providers/stt-whisper-cpp/src/setup.ts --model base.en
docker run -d -p 127.0.0.1:8787:8787 -v vid2context-data:/data -e WHISPER_CPP_MODEL=base.en vid2context:whisperThe build compiles whisper-cli and whisper-server with one CPU backend per instruction set (GGML_CPU_ALL_VARIANTS), so the image runs well on any x86-64 or arm64 host, not only the build machine. Models are never in the image: the setup command downloads a verified model into /data/models/whisper. Tested on 2026-11-14: tiny.en transcribed the Apollo 11 clip in under a second, read-only root, no network rungs.
Compose#
docker compose -f deploy/containers/compose.yaml up -d --build
docker compose -f deploy/containers/compose.yaml --profile postgres --profile stt --profile ollama up -dThe server reads the repository's .env and runs read-only with all capabilities dropped. Each profile starts one local service. Compose cannot change the server's environment per profile, so point the server at a service by adding its line to .env, then up -d again:
| Profile | Service | Line for .env |
|---|---|---|
postgres |
pgvector/pgvector:0.8.1-pg17 |
DATABASE_URL=postgres://vid2context:vid2context@postgres:5432/vid2context (set POSTGRES_PASSWORD in your shell to change the password, and use it here) |
stt |
speaches 0.8.3, CPU |
LOCAL_STT_BASE_URL=http://stt:8000/v1 and LOCAL_STT_MODEL=Systran/faster-whisper-small; download the model once with curl -X POST http://127.0.0.1:8000/v1/models/Systran/faster-whisper-small from inside the network (docker compose exec server node -e "fetch('http://stt:8000/v1/models/Systran/faster-whisper-small',{method:'POST'}).then(r=>console.log(r.status))") |
ollama |
ollama/ollama:0.40.13 |
OLLAMA_BASE_URL=http://ollama:11434/v1 and OLLAMA_MODEL=qwen3:8b, after docker compose exec ollama ollama pull qwen3:8b |
WITH_WHISPER=1 docker compose ... up --build builds the whisper.cpp variant. VID2CONTEXT_BIND=0.0.0.0 publishes the port beyond loopback (set VID2CONTEXT_API_KEYS first).
YouTube and datacenter IPs#
YouTube blocks many datacenter and cloud IPs ("Sign in to confirm you're not a bot", or HTTP 429). A container on a laptop or home server uses your residential IP and works; the same container on a cloud host often gets only metadata, with captions and downloads refused. In a test from a cloud VM on 2026-11-14, YouTube answered 429 to both the innertube captions and yt-dlp's subtitle request, while yt-dlp still downloaded the audio. Robust options: run the container at home and publish it with a Cloudflare Tunnel (see also remote MCP), or keep a cloud deploy for other sites and uploaded media. YouTube cookies (YTDLP_COOKIES_FILE, see the yt-dlp guide) help but tie requests to an account.
Checks#
deploy/containers/test.sh # hadolint, compose config, platform configs, build, run checks
LIVE=1 deploy/containers/test.sh # plus a real YouTube transcript and speech-to-text on the Apollo clip
deploy/containers/test.sh --whisper # the whisper.cpp variant
deploy/containers/test.sh --lint-only # configs onlyThe run checks start the image on a random port with --read-only --tmpfs /tmp and a temporary volume, then check health, the health check command, non-root uid, the read-only root, a writable /data, and that yt-dlp, deno, ffmpeg and ffprobe run.
Updating pinned tools#
Bump a build arg in deploy/containers/Dockerfile, then run deploy/containers/railway/gen-dockerfile.sh so the Railway copy follows. Use releases at least two weeks old.
# yt-dlp: the sha256 of the release's SHA2-256SUMS file
curl -fsSL https://github.com/yt-dlp/yt-dlp/releases/download/<version>/SHA2-256SUMS | sha256sum
# deno: one per architecture
curl -fsSL https://github.com/denoland/deno/releases/download/<version>/deno-x86_64-unknown-linux-gnu.zip | sha256sum
curl -fsSL https://github.com/denoland/deno/releases/download/<version>/deno-aarch64-unknown-linux-gnu.zip | sha256sum
# whisper.cpp: the commit the tag points to
git ls-remote https://github.com/ggml-org/whisper.cpp 'refs/tags/<version>^{}'