Self-hosting a streaming video platform

Before the pandemic began, I used to host movie nights. Since that activity became dangerous to do in person, I wanted to come up with a virtual alternative. Having set up a self-hosted video chat system, this would be an additional project that’d work alongside with the VPS-hosted, Dockerized, LDAP-secured environment described in the post linked at the beginning of this unnecessarily long sentence.


What am I trying to accomplish? I’d like to have a way for everyone attending a virtual movie night to watch the same thing at the same time, with reasonable picture and audio quality, and without having to download and install a bunch of stuff. It’d also be nice to be able to chat (by text) during the movie.

The text chat part is easily accomplished. Using the self-hosted Jitsi Meet instance, we usually hang out and talk in there before starting the movie. During the movie we simply turn off video and audio and just use the text chat provided by Jitsi Meet. It’s not fancy by any means, but it’s sufficient.

As for the movie streaming, that gets a bit more interesting. First, let’s start with the obligatory disclaimer: don’t break copyright laws in your country. For the purpose of providing a realistic example in this post, I’m going to use the movie Night of the Living Dead, downloaded from the Internet Archive, as it is in the public domain.

There are a number of popular streaming services that a lot of people use, such as YouTube Live, Twitch, and Facebook streaming. Just like in the previous post, however, I wanted to self-host instead because of privacy and the “can I do it well enough?” experimentation factor.

The tools

It turns out, there are great open source tools available for doing this kind of streaming setup. On the server side, you need an application to receive streaming data and save it appropriately for serving to viewers. A web application for doing the actual serving of the data stream is needed as well. On the data source side, an application must send the stream to the server. (And on the viewer side, just a web browser is needed.)

The server side

A great choice for receiving the stream, saving it, and being able to serve it is everyone’s favorite web server, nginx, along with nginx-rtmp-module (available in Debian/Ubuntu as libnginx-mod-rtmp). Both can be installed easily in a Docker container. RTMP is a technology that hails from the olden days of Flash video, developed by Macromedia. These days, however, it has become the big standard supported by the major streaming platforms for sending audio and video data with minimal latency.

To save and later serve the data sent through RTMP, the aforementioned nginx module offers support for HLS, one of the most popular stream serving technologies today. The module has the ability to save an entire stream for later viewing (useful for playing stored videos) as well as the option to only save a minimal amount of cached data during a live event for immediate transmission. I chose the second option because then I don’t have to worry about available disk space on the host.

For serving the HLS stream from the server to viewers, an HTML/JavaScript player application is needed. There are a number of choices, such as Video.js, MediaElement.js, and Plyr. After using Video.js for a while, I decided to switch to Plyr because of its smoother and more polished-looking interface:

Plyr with a live stream and control bar visible
See the nice control bar on the bottom?

Incidentally, it helps to have a non-movie image or clip playing beforehand to allow everyone to tune in and get comfortable. All a viewer needs is a web browser to enjoy the show.

The data source side

Once an RTMP target is configured, the next logical question is, what’s the source? The answer, at least with my setup, is OBS Studio. This cross-platform, open source software is a popular choice for recording as well as live streaming. It can stream from multiple data sources arranged in a variety of ways on the screen. However, most of its power remains unused in a movie night setup.

The server configuration

Now that all the tools are ready, it’s time to configure them. For nginx and the RTMP plugin, the configuration is a bit complex, but it can be broken down into parts.

http {
        # Basic Settings

        sendfile on;
        tcp_nopush on;
        tcp_nodelay on;
        keepalive_timeout 65;
        types_hash_max_size 2048;

        include /etc/nginx/mime.types;
        default_type application/octet-stream;

        # Logging Settings

        access_log /var/log/nginx/access.log;
        error_log /var/log/nginx/error.log;

        # Gzip Settings

        gzip on;

        # Virtual Host Configs

        server_tokens off;

        server {
                listen 8080;

                location / {
                        root /data/plyr;

                location /livestream {
                        add_header Cache-Control 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0';
                        if_modified_since off;
                        expires off;
                        etag off;
                        types {
                                application/ m3u8;
                                video/mp2t ts;
                        alias /data/hls/livestream;

rtmp {
        server {
                listen 1935;
                chunk_size 2048;


                application stream {
                        live on;
                        record off;
                        meta copy;

                        wait_key on;
                        wait_video on;

                        hls on;
                        hls_path /data/hls/livestream;
                        hls_fragment 4;
                        hls_playlist_length 16;
                        hls_sync 100ms;

That’s a lot of stuff! Let’s go from the top down and analyze this configuration file. On the first line is the beginning of the http block. The http block handles the web server configuration for nginx. After some basic settings (most of them default) we have a server block. The listen 8080; directive instructs nginx to listen on port 8080 for HTTP traffic. The location / block beginning on line 37 configures the root website, e.g.,, to get its data from /data/plyr. That location is where I have my Plyr HTML/JS code stored for loading the web player.

The next block, location /livestream beginning on line 41, is where the HLS data (the playlists and the video streams) will be served, e.g., Inside this block are some directives to prevent caching, a MIME type setup block, and the alias directive. You may have noticed that the previous location block used root instead of alias. The two directives are similar, but there is an important difference: alias will use a directory path as-is, while root will append the location path to the one specified in the root directive. For example:

location /asdf {
	# Files will be served at {domain}/asdf from the /www/asdf directory
	root /www;

location /fdsa {
	# Files will be served at {domain}/fdsa from the /www directory
	alias /www;

Starting on line 55 is the rtmp block. This block comes from the RTMP module and is responsible for nginx’s RTMP and HLS processing. The listen 1935; directive on line 57 tells nginx to listen on port 1935 for RTMP connections. While IANA still defines this port as “macromedia-fcs”, it is in fact the standard RTMP port.

The on_publish directive on line 60 is what allows me to perform authentication on incoming RTMP streams. The way this works is, nginx sends a POST to the specified URL with form data containing connection details. If it gets an HTTP 200 response, then it allows the stream to proceed. If it gets an HTTP 401, then it prevents the stream from being received and processed. I wrote a little Python script to let me authenticate streams against LDAP, and it’s been working well.

The application stream block on line 62 creates an RTMP endpoint named “stream”, such that a tool like OBS will be able to connect to rtmp:// The specifics of the various directives in this block can be found on the module’s GitHub wiki, but there are a few important ones I’d like to point out. First, hls_path /data/hls/livestream; on line 71: as you may have noticed, the path here is the same as the one for the /livestream location up above. This is not a coincidence; you must have a way to access the HLS data being output by the RTMP module via the web.

The other directives I’d like to mention are hls_fragment 4; and hls_playlist_length 16;. I’ve played around with these values a lot in my quest to decrease the latency between starting a movie and everyone actually seeing it. Unfortunately, when the values are too low, nginx seems to have trouble serving the HLS data, and you end up with half-functioning streams and periods of HTTP 404s for no apparent reason. If you search online, you’ll find lots of different values. Cloudflare, for example, uses 1 and 4, respectively. I was not able to get a stable stream with them set that low.

The source side configuration

I am by no means an expert in using OBS, but a basic configuration is pretty easy. For configuring streaming, simply set the server and key (“my-key” as an example) under the Streaming settings:

Screenshot of OBS Settings screen with Stream tab selected and values entered

After that, add your video source(s), arrange them as needed, and you’re ready to go. Here’s a summary GIF of this process:

Animated GIF of the process of starting movie streaming in OBS

Romero’s cult classic is being played using the VLC Video Source in the above GIF. That source becomes available only when VLC is detected on your machine. If you do not have it installed, the Media Source option should work as well.

Once streaming begins, the web player should be able to access the HLS stream, in this example, under

If you run into issues, the first thing to do is check the nginx logs and your browser debug console. Between those two, you will likely be able to find any misconfiguration that may have happened. And once everything is working, you can share a virtual movie night experience, too!