Running it on your own machine
Summary
One Node process serves the editor, the HTTP API and the MCP server. There are four ways to install it — Docker, an Unraid template, an Umbrel app, and a Proxmox script that makes an LXC container — and all four set the same handful of variables. Every /api call needs a token, and the server refuses to listen on a reachable address without one.
What is being installed
videola-server is one process. It serves the built editor as static files, answers /api for the HTTP interface, and carries the same Rust core as WebAssembly that the browser build does — so a project opened over the API and a project opened in the editor are opened by the same code.
| Variable | Default | What it is |
|---|---|---|
VIDEOLA_TOKEN | none | The bearer token every /api call carries. Required on any address but loopback |
VIDEOLA_HOST | 127.0.0.1 | The bind address. 0.0.0.0 inside a container, so the published port reaches it |
VIDEOLA_PORT | 7331 | |
VIDEOLA_STORAGE_ROOT | the working directory | Projects and imported media. This is the directory to back up |
VIDEOLA_WEB_ROOT | none | Where the built editor is. Without it the server answers the API and serves no editor |
VIDEOLA_WASM | none | The core. Without it the server cannot open a project at all |
VIDEOLA_LOCALE | en | en or de: what the server writes into generated names. The editor follows the browser |
VIDEOLA_MAX_PROJECTS | 8 | How many projects stay open in memory, each with its own core instance |
VIDEOLA_YOUTUBE_CLIENT_ID | none | The OAuth client a channel sign-in runs through. With the secret below, or not at all |
VIDEOLA_YOUTUBE_CLIENT_SECRET | none | The other half of it. Without both there is no sign-in, only the fields in the dialogue |
VIDEOLA_ELEVENLABS_KEY | none | The key transcription for lyric videos runs through. The audio goes to ElevenLabs, and the button in the editor says so |
VIDEOLA_WHISPER | none | A transcriber on this machine instead — Whisper through whisper.cpp or faster-whisper. Called as <cmd> --input <audio> --output <json> [--model …], and the audio goes nowhere. Used in preference to the key |
VIDEOLA_WHISPER_MODEL | none | Handed to it as --model, for a runner that has to be told which one to load |
The token is not a hardening option. The server checks the bind address at startup and refuses to listen on anything but loopback without one, because an open Videola hands every machine that can reach it read and write access to the storage root. That check is in configFromEnv, and the deployment test in apps/server/src/deploy.test.ts asserts it against every file below — so a template that offered the token as optional would fail the build rather than ship.
The storage root has to exist; the server does not create it. Every installer below makes it, and a server that made it itself would silently write a projects directory into whatever cwd happened to be.
Docker
docker run -d --name videola \
-p 7331:7331 \
-e VIDEOLA_TOKEN="$(openssl rand -hex 24)" \
-v videola-data:/data \
ghcr.io/fgilde/videola:latestThe image sets VIDEOLA_HOST, VIDEOLA_PORT, VIDEOLA_STORAGE_ROOT, VIDEOLA_WEB_ROOT and VIDEOLA_WASM itself; the token is the one thing it cannot invent for you. It runs as the node user and declares /data as a volume.
Unraid
templates/videola.xml is a Community Applications template, and ca_profile.xml at the repository root is what Community Applications reads when it is pointed at this repository. Copy it into /boot/config/plugins/dockerMan/templates-user/ and it appears under Add Container, or point CA at this repository.
It offers the port, the /data path (/mnt/user/appdata/videola by default) and the token, which is marked required and masked. The two advanced variables are the locale and how many projects stay open. The template pins :latest deliberately: an Unraid box updates a container by pulling it, and a pinned version would make Check for Updates a permanent no.
Umbrel
fgilde-videola/ holds umbrel-app.yml and docker-compose.yml in the shape the app store expects, and umbrel-app-store.yml beside it makes the repository itself a community app store: add https://github.com/fgilde/videola under App Store → ⋯ → Community app stores. The compose file pins the exact version, because an app store shows a version number to the person installing it and latest would make that number a guess — the deployment test checks that the pin, the manifest's version: and the release tag are the same string.
Two things worth knowing about the compose file:
APP_HOSTisfgilde-videola_server_1, notlocalhost. Umbrel puts every app behind its own proxy container, and that proxy is what publishes a port. The server binds0.0.0.0inside its own container so the proxy can reach it;127.0.0.1would be reachable only from the server's own network namespace.- The health check goes through
/api/healthwith the token. A check against the open port would say the process started; this one says the core loaded and the storage root answers. It carries the token because the endpoint is behind the same gate as everything else — an unauthenticated health endpoint would be a hole in exactly one place.
CasaOS
store/casaos/ is a one-app CasaOS source. In CasaOS, App Store → Add source with
https://github.com/fgilde/videola/releases/download/store/casaos-appstore.zipThe archive is rebuilt by .github/workflows/casaos-store.yml on every push that touches store/casaos/, and lives at a release tag that never changes, so the URL keeps working.
The packaged token is change-this-token, which is in a public file: change it in the install dialog.
Cosmos
store/cosmos/servapps/Videola/ is a Cosmos ServApp: one service, one named volume, and a SERVAPP route to port 7331. Its installer form asks for the token before the container starts, so nothing runs with a token from a public file.
Proxmox VE
bash -c "$(curl -fsSL https://raw.githubusercontent.com/fgilde/videola/main/deploy/proxmox/videola.sh)"Run it on the PVE host. It makes an unprivileged Debian 13 container with nesting off — the server is one Node process and needs neither root in the host's namespace nor a container runtime inside itself — installs Node from Debian's own archive, fetches the server bundle from the latest release, generates a token, writes a systemd unit and waits for /api/health to answer before it prints the URL and the token.
CTID, DISK_GB, CORES, RAM_MB, BRIDGE, STORAGE and PORT are environment variables with sensible defaults; without a CTID it takes the next free one.
Self-contained on purpose. The community helper scripts source a shared build.func from another repository at run time. That is convenient, and it means the script breaks whenever that file moves. This one needs pct, which every PVE host has.
deploy/proxmox/install.sh is the half that runs inside the container, and it runs on its own as well — on a plain Debian VM, on a Raspberry Pi, in a container somebody made themselves:
curl -fsSL https://raw.githubusercontent.com/fgilde/videola/main/deploy/proxmox/install.sh | bashIt is idempotent: run it again and it fetches the current release, keeps the token and the storage root it already wrote, and restarts the service. Regenerating the token on every run would lock out every client that stored it.
What the unit forbids
The service file is the part of a bundle install that outlives the script, so what it cannot do matters more than what it starts:
User=videola
ProtectSystem=strict
ProtectHome=yes
PrivateDevices=yes
NoNewPrivileges=yes
RestrictNamespaces=yes
ReadWritePaths=/var/lib/videolaOne writable path, and nothing else the machine offers. A video editor has no business with a device, a kernel module or somebody else's home directory.
Adding material from a link
A link is not a file, and a browser may not read another origin's video. So the server does it, with yt-dlp. Import media opens one dialogue with both ways in: files from this computer on one side, a link or a search on the other — the type, the codec, the format and the quality the way MeTube asks them, and a progress bar while it runs. What comes back is imported exactly like a file somebody dropped on the window.
Nothing has to be installed for this beyond the server itself. The image carries yt-dlp and ffmpeg, so a container started with the line above can already fetch; the editor finds the server it was served from on its own, and an editor opened at videola.app is pointed at one under Publishing destinations.
The image carries yt-dlp and ffmpeg. Anywhere else, install both and the server finds them on PATH; VIDEOLA_YTDLP points at a different binary. Without them every other part of the editor works and the dialogue says this server cannot fetch, rather than failing halfway into a download.
curl "localhost:7331/api/fetch/ready" -H "authorization: Bearer $VIDEOLA_TOKEN"
curl "localhost:7331/api/fetch/search?q=chopin" -H "authorization: Bearer $VIDEOLA_TOKEN"
curl -X POST "localhost:7331/api/fetch?url=https://…&kind=video&format=mp4&quality=1080" \
-H "authorization: Bearer $VIDEOLA_TOKEN" -o clip.mp4Only where it may go. A server that fetches whatever it is handed is a way into the network it runs on, so the address a link really resolves to is checked first, and one that lands on a private, loopback or link-local address is refused — the address a cloud instance keeps its credentials at included.
Only material you hold the rights to. No software can tell your own talk from somebody else's film, and this one does not try. It is a downloader on a machine you run.
Publishing destinations
A finished video's last step is usually not "a file in the downloads folder". A destination is a place to send it — a YouTube channel, a Vimeo account, or any URL that takes a file — set up once and used from then on.
| Kind | What it needs | What it does |
|---|---|---|
youtube | clientId, clientSecret, refreshToken | a resumable upload through the Data API |
vimeo | accessToken | a tus upload to the account |
peertube | accessToken, plus instance and channelId | uploads to any PeerTube instance |
mastodon | accessToken, plus instance | attaches the video to a post |
bluesky | appPassword, plus handle | posts through the AT Protocol's video service |
telegram | botToken, plus chatId | sends it as a video to a chat or a channel |
facebook | pageToken, plus pageId | uploads it to a page, unpublished by default |
webhook | url | posts the file as a multipart form, with any headers you name |
What is deliberately not here: Instagram and TikTok. Instagram's publishing API takes a URL and fetches the video itself, which a server nobody can reach from outside cannot offer. TikTok wants an audited developer application before anything but a draft folder is reachable. Both would be a button that fails for almost everybody who pressed it.
The shortest setup on the list is Bluesky: an app password from the account's own settings, no developer account and no review. PeerTube is next — free software on somebody's own machine, with no quota and no company in the middle.
In the dialogue the destinations are a list, each under its own mark; New destination and Edit open the same form. A secret is never read back: editing shows the fields empty, and an empty field means "leave it".
Signing in instead of pasting tokens
The three YouTube values come from two different pages of Google's console, and the third is only ever printed by a tool almost nobody has installed. So the server can run the sign-in itself: the Destinations dialogue offers Sign in with YouTube, the browser goes to Google, and what comes back is exactly the refresh token the publisher already uses -- along with the channel name the destination is then called after.
That needs one OAuth client of your own, once:
VIDEOLA_YOUTUBE_CLIENT_ID=…apps.googleusercontent.com
VIDEOLA_YOUTUBE_CLIENT_SECRET=…The redirect URI to register in Google's console is the address the editor is opened at plus /api/destinations/oauth/youtube/callback, for example http://localhost:7331/api/destinations/oauth/youtube/callback.
No client is shipped: an OAuth secret in a public repository is a published secret, and the quota attached to it would be one bucket for every installation in the world. Without one the dialogue says so and leaves the fields; with one, every destination after that is a button.
The way back from Google carries no bearer token -- it is a redirect, not a call from the editor. What stands in for it is the state: minted by the guarded half of the flow, single use, ten minutes, held only in memory. A callback with an unknown state is refused.
Where the client comes from
Once per server, in Google's console:
- Open console.cloud.google.com and make a project -- the name does not matter, it only ever shows up in the console.
- APIs & Services, Library, and enable the YouTube Data API v3.
- OAuth consent screen: user type External, an app name and a contact address. Add the scopes
.../auth/youtube.uploadand.../auth/youtube.readonly-- the second is what lets Videola name the destination after the channel. Add yourself as a test user. - Credentials, Create credentials, OAuth client ID, application type Web application. As an authorised redirect URI, enter the address the editor runs at plus
/api/destinations/oauth/youtube/callback. - Put the client id and secret in the two environment variables above -- or straight into the fields in the dialogue, on a server that has none.
Two things Google does not say out loud. While the app is in Testing, refresh tokens expire after seven days; to avoid that, set it to In production and click through the unverified-app warning. And a project's quota is 10,000 points a day against an upload's 1,600 -- about six videos a day, per project. That is exactly why Videola ships no client of its own: it would be one bucket for everybody.
For anyone who would rather not, two destinations on the list need no console at all: Bluesky takes an app password from the account's own settings, and PeerTube a token from your instance.
curl -X POST localhost:7331/api/destinations \
-H "authorization: Bearer $VIDEOLA_TOKEN" -H "content-type: application/json" \
-d '{"kind":"youtube","name":"My channel",
"secrets":{"clientId":"…","clientSecret":"…","refreshToken":"…"},
"settings":{"privacyStatus":"unlisted"}}'
curl -X POST "localhost:7331/api/destinations/dst_…/publish?title=Summer" \
-H "authorization: Bearer $VIDEOLA_TOKEN" -H "content-type: video/mp4" \
--data-binary @summer.mp4Secrets go in and never come out. GET /api/destinations says that a destination holds a refresh token; nothing says what it is. A token that can be read back is a token that leaks through a screen share, a log or a browser history — rotating one means writing it again, which is a click, and the alternative is a class of accident this program will not make possible.
Private unless you say otherwise. A YouTube upload is private and a Vimeo one is visible to nobody until the destination's settings say another word. A mistake that publishes a rough cut to the world cannot be taken back by an undo.
Where the tokens come from. There is no browser dance here: this server has nowhere to put a redirect, and it would be a second way to get the same string. Run Google's own installed-app flow once — their oauth2l, a five-line script, or the flow any of their quickstarts prints — and paste the three values it gives you. Vimeo issues a personal access token with an upload scope from the account page, which is one field.
Why the server and not the browser. The upload needs a client secret, and a secret in a browser is not a secret. The encoder stays where the material is: the editor exports in the tab, sends the bytes here, and this end talks to the platform.
What is not there yet. An interrupted upload starts again rather than resuming: the session URL would have to outlive the request, which is a job queue and a different feature. There is no schedule, no thumbnail upload and no playlist. Adding a kind is one function in publish.ts and one row in the table of what it requires.
The server bundle
videola-server-<version>.tar.gz is attached to every release: the three entry points, the WASM the core lives in, the built editor, and a README. It needs Node 22 and nothing else — esbuild has already bundled every dependency into the entry points, so there is no node_modules to install and nothing to resolve at run time.
It is built by node deploy/bundle.mjs, which is the same command the release workflow runs. A recipe that exists only inside a workflow is the recipe that breaks on the day it is needed.
Backing it up
The storage root. Everything else — the bundle, the image, the container — is reinstallable in a minute; the projects and the imported media are not. A .videola file is a ZIP with the media inside it, so a copy of that directory is a copy of the work with nothing to reconstruct.