Skip to content

btcppdev/streamctl

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

41 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

streamctl

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 droplet
  • nixos/ — host configuration applied to the droplet
  • flake.nix — top-level Nix flake; defines the package, the NixOS module, and the host configuration
  • Makefile — 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

What it does, end-to-end

  1. You scp a video file into the droplet
  2. You open the streamctl web UI in your browser, log in with a single secret
  3. You configure RTMP endpoints (YouTube, X) once with stream keys
  4. You schedule a stream: pick a video, pick endpoints, set a time (one-shot or recurring)
  5. systemd fires at the scheduled time; ffmpeg pushes the video to all selected endpoints simultaneously via the tee muxer (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.

Prerequisites on your laptop

  • nix with flakes enabled
  • terraform — install via nix shell nixpkgs#terraform or use the dev shell below
  • doctl authenticated (doctl auth init)
  • GNU make (macOS ships an older BSD make; install GNU make via nix shell nixpkgs#gnumake if needed)
  • openssl
  • An SSH keypair at ~/.ssh/id_ed25519.pub (or change ssh_key_path in terraform/main.tf)

The repo provides a dev shell with all of these:

make shell      # or: nix develop

First-time setup

1. Edit configuration

Open nixos/configuration.nix and change at minimum:

  • services.nginx.virtualHosts."stream.example.com" → your real hostname
  • security.acme.defaults.email → your email
  • time.timeZone → if you want something other than America/Los_Angeles

2. Initialize Terraform

make init

3. Create the droplet

make create

Takes ~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.

4. Wait for NixOS, then pull hardware config

make wait-for-nixos
make pull-hardware-config

The 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.

5. Generate the streamctl login secret

make bootstrap-secret

Prints a random 32-char secret to your terminal. Save it — that's your streamctl web UI password.

6. First deploy

make deploy

This 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 deploy will fail the first time with vendorHash mismatch because Go module hashes need to be pinned. The error message will show you the correct hash. Copy it into flake.nix (replace vendorHash = null; with the suggested value) and run make deploy again. Once committed, this hash becomes a normal pinned dependency.

7. DNS

Point an A record at the droplet's IP:

make ip
# → 134.209.x.x

In 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.

Day-to-day usage

Schedule a stream:

  1. make upload FILE=~/path/to/event.mp4 (or scp it manually)
  2. Open the web UI, go to Endpoints, add YouTube and X endpoints with their RTMP URLs and stream keys
  3. 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
  4. Save. The streams list shows the next trigger time and live status.

Edit NixOS config and redeploy:

$EDITOR nixos/configuration.nix
make deploy

Update 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 deploy

Watch logs:

make logs                  # streamctl web app
make timers                # list scheduled streams
make stream-logs ID=3      # specific scheduled stream

Tear it all down:

make destroy

Local development

To 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 dir

run-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.

All Make targets

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

Architecture notes

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.

Troubleshooting

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.

A few practical considerations for live use

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.

About

Creates a livestream pusher for bitcoin++ recordings

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors