A self-hosted scheduled livestreaming system. Push pre-recorded video files to multiple RTMP destinations (YouTube, X, Twitch) at scheduled times — including recurring schedules — without per-platform stream length limits.
This repo contains everything needed to run it:
streamctl/— the Go web app and NixOS module (the actual product)terraform/— provisions a DigitalOcean dropletnixos/— host configuration applied to the dropletflake.nix— top-level Nix flake; defines the package, the NixOS module, and the host configurationMakefile— orchestrates everything from your laptop
streamctl-system/
├── Makefile # main entry point — `make` for help
├── flake.nix # everything wired together
├── README.md
├── streamctl/ # the Go web app
│ ├── cmd/main.go
│ ├── go.mod
│ ├── internal/
│ │ ├── db/ # SQLite layer
│ │ ├── handlers/ # HTTP handlers + embedded templates
│ │ └── systemd/ # systemd unit generation
│ └── README.md # streamctl-specific notes
├── terraform/
│ ├── main.tf # droplet, ssh key, firewall
│ └── cloud-init.yaml # nixos-infect bootstrap
└── nixos/
├── configuration.nix # the host config (edit this)
└── hardware-configuration.nix # generated by nixos-infect
- You scp a video file into the droplet
- You open the streamctl web UI in your browser, log in with a single secret
- You configure RTMP endpoints (YouTube, X) once with stream keys
- You schedule a stream: pick a video, pick endpoints, set a time (one-shot or recurring)
- systemd fires at the scheduled time; ffmpeg pushes the video to all selected endpoints simultaneously via the
teemuxer (no re-encoding, just-c copy)
The web app generates systemd units rather than managing a worker queue. This means scheduled streams keep running even if the web app crashes or restarts.
nixwith flakes enabledterraform— install vianix shell nixpkgs#terraformor use the dev shell belowdoctlauthenticated (doctl auth init)- GNU
make(macOS ships an older BSD make; install GNU make vianix shell nixpkgs#gnumakeif needed) openssl- An SSH keypair at
~/.ssh/id_ed25519.pub(or changessh_key_pathinterraform/main.tf)
The repo provides a dev shell with all of these:
make shell # or: nix developOpen nixos/configuration.nix and change at minimum:
services.nginx.virtualHosts."stream.example.com"→ your real hostnamesecurity.acme.defaults.email→ your emailtime.timeZone→ if you want something other thanAmerica/Los_Angeles
make initmake createTakes ~6 minutes total: ~1 minute for the droplet to boot, ~5 minutes for nixos-infect to convert Ubuntu to NixOS. The Makefile will print next-step instructions when it finishes.
make wait-for-nixos
make pull-hardware-configThe hardware config is generated by nixos-infect and describes the droplet's actual disk layout. We need it locally so nixos-rebuild knows what disk to install GRUB on, etc.
make bootstrap-secretPrints a random 32-char secret to your terminal. Save it — that's your streamctl web UI password.
make deployThis builds streamctl from source on the droplet (the binary cache handles all dependencies; only streamctl itself compiles), copies the resulting NixOS configuration, and switches to it. The streamctl service starts, nginx comes up, ACME requests a Let's Encrypt cert.
First-build note:
make deploywill fail the first time withvendorHashmismatch because Go module hashes need to be pinned. The error message will show you the correct hash. Copy it intoflake.nix(replacevendorHash = null;with the suggested value) and runmake deployagain. Once committed, this hash becomes a normal pinned dependency.
Point an A record at the droplet's IP:
make ip
# → 134.209.x.xIn your DNS provider, set stream.example.com → that IP. Once DNS propagates, hit https://stream.example.com in a browser, log in with your secret, and you're ready to schedule streams.
Schedule a stream:
make upload FILE=~/path/to/event.mp4(or scp it manually)- Open the web UI, go to Endpoints, add YouTube and X endpoints with their RTMP URLs and stream keys
- Go to Streams, click "New stream", pick the video, pick endpoints (defaults to all), set the schedule:
- One-shot:
2026-05-15 14:00:00 - Weekly:
Tue *-*-* 14:00:00 - Weekdays:
Mon..Fri *-*-* 09:00:00
- One-shot:
- Save. The streams list shows the next trigger time and live status.
Edit NixOS config and redeploy:
$EDITOR nixos/configuration.nix
make deployUpdate streamctl source code and redeploy:
$EDITOR streamctl/internal/handlers/handlers.go
make deploy(Just edit and redeploy — the package is built from ./streamctl in the same flake, so any change is picked up.)
Update flake inputs (nixpkgs):
make update
make deployWatch logs:
make logs # streamctl web app
make timers # list scheduled streams
make stream-logs ID=3 # specific scheduled streamTear it all down:
make destroyTo work on streamctl without deploying:
make shell # enter dev shell
make build # build the binary; output at ./result/bin/cmd
make run-local # run on :8080 with /tmp/streamctl-local/ as data dirrun-local won't actually be able to call systemctl (you're not root), so scheduled streams will fail to register — but the UI, endpoint management, and stream creation all work for testing the frontend.
make help # show this list
make init # one-time terraform init
# provisioning
make create # create droplet + run nixos-infect
make wait-for-nixos # block until droplet is on NixOS
make pull-hardware-config # scp hardware config back
make bootstrap-secret # generate login secret on droplet
make ip # print the droplet's IP
# deployment
make deploy # build on droplet, switch
make deploy-local-build # build locally, copy to droplet
make deploy-dry # validate without switching
make update # update flake inputs
# local dev
make shell # nix develop
make build # build streamctl locally
make run-local # run streamctl locally for testing
# operations
make ssh # ssh into droplet
make logs # tail streamctl web app logs
make timers # list scheduled streamctl timers
make stream-logs ID=3 # tail a specific stream's ffmpeg output
make upload FILE=path # scp a file to the videos directory
make destroy # terraform destroy
Why one big flake instead of separate flakes for streamctl and the deploy? During development, nixos-rebuild --target-host needs to be able to find streamctl's source and pin its build inputs. Having both in one flake means a single git commit captures both an app change and the deployment that ships it — no cross-flake version drift. If you later want to publish streamctl as a reusable component for others to consume, you can split it into its own flake at that point.
Why Terraform + Nix, not just one or the other? Terraform handles cloud APIs really well (droplets, firewalls, DNS, attached storage) and Nix handles host configuration really well. Trying to make either tool do the other's job is painful. The split here is: Terraform owns the droplet's existence and network surface; Nix owns everything that runs on it.
Why nixos-infect over a custom NixOS image? A custom image is faster to boot but you have to maintain it (rebuild and re-upload on every nixpkgs version bump). nixos-infect adds ~5 minutes to droplet creation but otherwise stays out of the way. For a single droplet you create once, the trade-off favors infect.
Why nixos-rebuild --target-host over Colmena/Morph/deploy-rs? For a single host, the built-in tool is enough and has zero extra dependencies. If you grow to multiple hosts, switching to Colmena is a small refactor.
Why doctl for auth? Avoids storing the API token in a file. Make calls doctl auth token at runtime and exports the result as DIGITALOCEAN_TOKEN for that single Terraform invocation.
make create fails with "doctl auth token returned nothing": Run doctl auth list and confirm a context exists. If not, doctl auth init.
make deploy fails with hash mismatch in fixed-output derivation: This is the vendorHash = null placeholder. Copy the suggested hash from the error output into flake.nix and re-run.
make wait-for-nixos times out: SSH in manually with ssh root@$(make ip) and check tail -f /tmp/infect.log. If it failed, the log will tell you why; usually a transient network issue, and cloud-init clean && reboot will retry.
ACME / Let's Encrypt fails on first deploy: DNS probably isn't set up yet, or hasn't propagated. Set the A record, wait, then make deploy again.
Build is slow on the droplet: Use make deploy-local-build to build on your laptop and copy the result. Faster if your laptop is beefier than the VPS.
make: *** missing separator. Stop.: Make recipes must be indented with literal tabs, not spaces. If your editor auto-converts, that's the cause.
Bandwidth: Pushing one ~10 Mbps stream to two destinations needs ~20 Mbps sustained upload. Most VPS providers are fine, but check egress caps for very long streams. A 7-hour event at 20 Mbps total ≈ 70 GB.
Scheduling vs platform timing: Both YouTube and X want to see incoming RTMP a few seconds before they go live. Schedule the streamctl trigger 30–60 seconds before your platform-side scheduled start time so the streams are warm when viewers arrive.
X RTMP ingest: X has historically gated RTMP access by account type. Confirm in X's producer settings that you've been given a working URL and key for your specific account.
No re-encoding: streamctl uses ffmpeg -c copy, so whatever bitrate is in your source file is what goes to the platforms. Export from your editor at a sensible bitrate (~10 Mbps for 1080p is YouTube's recommendation; CBR is preferred for live ingest). See the streamctl README for more on bitrate tuning.
Hardening: The streamctl web service runs as root because it writes to /etc/systemd/system. The actual ffmpeg processes drop to a non-root user via the generated unit files. If you want least-privilege for the web layer, the alternative is polkit rules permitting a non-root user to run systemctl on streamctl-* units only. Not built yet; PRs welcome.