Mustafa Can Yücel
blog-post-24

Streaming RTSP on an HTML Page Part 1

Introduction

If you have a relatively older or a cheaper IP camera or a network capable DVR, you may have noticed that it only supports RTSP streaming. This is a problem because most modern browsers do not support RTSP streaming. It we were in early 2000s, we would have more options; we could have used the dreaded Flash Player, the infamous QuickTime, or even a black-box only-God-knows-what-it-does ActiveX control. But we are in 2025, and all these options are long dead. And the browersers of this modern era do not support RTSP streaming.

In this case, we have to convert the RTSP stream to a format that is supported by the browsers. In this front, the two most commonly used options are WebRTC and HLS (HTTP Live Streaming). WebRTC is a great option; it has very low latency, and it is supported by all modern browsers. However, it is not very easy to set up, and it requires a server that supports WebRTC. Moreover, since it is a P2P protocol that establishes sockets, computers behind firewalls and NATs require a TURN server and a STUN server to relay the data properly. If we are not interested in the low latency and the P2P nature of WebRTC, we can use HLS. HLS is a streaming protocol that is supported by all modern browsers, and it is very easy to set up. It is an adaptive bitrate streaming protocol introduced by Apple in 2009. It works by breaking the overall stream into a sequence of small HTTP-based file downloads, each download loading one short chunk of an overall potentially unbounded transport stream. As the stream is played, the client may select from a number of different alternate streams containing the same material encoded at a variety of data rates, allowing the streaming session to adapt to the available data rate. Since this is a basic media streaming protocol, it is supported by all modern browsers and it does not require any additional special server setup.

Requirements

Sadly, we cannot directly connect the feed source to the browser; we need an intermediary server that will perform the conversion. Luckily, there are several alternatives that can be used for this purpose:

  • AWS gives a free server (EC2), however, it is limited for one year. If you are planning to have a long term solution, you may need to pay for the server.
  • Google Cloud gives a free server (Compute Engine), however, it is also limited for one year. If you are planning to have a long term solution, you may need to again pay for the server.
  • You can use a Raspberry Pi. It is a cheap and low power consuming solution. However, it may not be able to handle high resolution streams. Also you will have to deal with the power supply and the network connection, as well as the operating system.
  • Azure does not even have a free tier. You will have to pay for the server.
  • Oracle Cloud has a forever free tier. It is limited to 2 instances and have limited specs, however, it is more than enough for a medium resolution stream. Therefore in this tutorial, we will use Oracle Cloud.

Setting Up the Camera System

Camera/NVR

You may want to set up RTSP settings to change the port, username, and password.

  • Changing port is for security; even though security by obscurity is not a good policy, it is another layer in the stack.
  • Some devices do not allow setting a password; in this case there is not much to do; but do know that it is possible to scan the internet for these kind of open streams.

If your camera or NVR is behind a firewall or NAT, you may need to set up port forwarding. You should forward the RTSP port to the camera/NVR. Note that this is a security risk, as the camera/NVR will be directly accessible from the internet. If you are planning to use this setup for a long time, you may want to set up a VPN server and connect the camera/NVR to the VPN. This way, the camera/NVR will not be directly accessible from the internet, and you can access it via the VPN.

Setting Up the Server

The creation of the server is kind of outside of the scope of this tutorial. However, I will give a brief overview of the steps:

  1. Create an account on Oracle Cloud, or log in if you already have one.
  2. Go to the Compute section.
  3. Click on Create Instance. We will use the default options except the OS; we will use Ubuntu 22.04.

Once the server is created, ssh into the server. Note that during setup, Oracle gives the option to create ssh keys; this is a more secure option than using password with the caveat that you have to have the key file with you all the time. Note that the default user for ubuntu is ubuntu, not root:

ssh ubuntu@<ip>

Once connected to the server, the first thing to do is to update the package list and upgrade the packages:

sudo apt update
sudo apt upgrade
If there is an OS upgrade, it may ask for a reboot. In this case, reboot the server and wait for it to come back up.

If there are additional packages, install them too. For me, I install:

  • Nala: A package manager wrapper for apt. It is a very useful tool that allows parallel downloads and a bunch of UI, UX, and QoL improvements.
  • neovim: A modern text editor that is a fork of vim. It has a lot of improvements over vim, and it is easier to use.
It might be useful to setup tmux or screen, as well as zsh and oh-my-zsh. However, these are optional.

Setting Up ufw

We all know that the internet is a dangerous place. Therefore, we have to secure our server. The first thing to do is to set up a firewall. The default firewall for Ubuntu is ufw; it should come preinstalled but inactive. Before activating it, we have to allow some connections (ports) so that we do not lock ourselves out:

sudo ufw allow ssh
sudo ufw allow http
sudo ufw allow https
If you are planning to use FTP or any other service, you have to allow them too. Once the necessary ports are allowed, activate the firewall:
sudo ufw enable
If you are connected via ssh, you may get disconnected (though you really shouldn't). In this case, you have to reconnect. If everything is fine, you can check the status of the firewall:
sudo ufw status

Installing FFMPEG

ffmpeg is the swiss army knife of media processing. It can convert, stream, and do a lot of other things, provided you can solve the mystery of the command line arguments:

ffmpeg

To install ffmpeg, we can use the default Debian package:

sudo apt install ffmpeg

Connecting a Domain

You may just use the IP address of the server to connect to it. However, it is more user friendly to use a domain name; it also allows to use SSL certificates. For this tutorial, we will use the domain test.stream.example.com; so we need to add an A record to our DNS provider with test.stream pointing to the IP address of the server.

Installing Caddy

Caddy is a web server that is very easy to set up. It is a modern web server that is designed to be easy to use. It is also very secure, and it has a lot of plugins. It may not be found in the default package list, so we have to add the repository manually. Just follow the instructions on the official website, it is quite straightforward.

Once Caddy is installed, it will have a default Caddyfile that contains a simple hello-world page. Before doing anything further, we need to verify that the server can, well, serve pages. To connect our domain to the server, we have to edit the Caddyfile; it is under /etc/caddy by default:

sudo nvim /etc/caddy/Caddyfile
The default Caddyfile should look like this:
# The Caddyfile is an easy way to configure your Caddy web server.
#
# Unless the file starts with a global options block, the first
# uncommented line is always the address of your site.
#
# To use your own domain name (with automatic HTTPS), first make
# sure your domain's A/AAAA DNS records are properly pointed to
# this machine's public IP, then replace ":80" below with your
# domain name.

:80 {
    # Set this path to your site's directory.
    root * /usr/share/caddy

    # Enable the static file server.
    file_server

    # Another common task is to set up a reverse proxy:
    # reverse_proxy localhost:8080

    # Or serve a PHP site through php-fpm:
    # php_fastcgi localhost:9000
}

# Refer to the Caddy docs for more information:
# https://caddyserver.com/docs/caddyfile
We only change the :80 part to our domain:
test.stream.example.com {
    root * /usr/share/caddy
    file_server
}
Then we restart reload config or restart caddy completely:
sudo caddy reload
or
sudo systemctl restart caddy
Note that for the first command to work, you must be in the same directory as the Caddyfile.

If everything is fine, you should be able to connect to the server using the domain name. However, things are rarely this easy in real life. It took me two days to make this work in the Oracle server. Why? The reasons are two-fold:

  1. You need to allow incoming traffic from the Oracle Cloud console, within Menu > Networking > Virtual Cloud Networks. Then select your VCN, then Security Lists, then Default Security List. Add a new Ingress Rule with the following parameters:
    • Source CIDR: 0.0.0.0/0
    • IP Protocol: TCP
    • Source Port Range: All (leave blank)
    • Destination Port Range: 80, 443
    • Note that this will be a stateful rule (default).
  2. Now the most obscure part for me; in the default configuration, in the iptables, the INPUT chain policy was set to DROP with no explicit rules for HTTP/HTTPS:
    sudo iptables -L -n -v
    returns a long response that contains
    Chain INPUT (policy DROP 0 packets, 0 bytes)

    Even if the ufw was configured correctly, the base iptables rules were dropping all traffic, and sadly it took me two days to find this out. I don't know if this is a default configuration for Oracle Ubuntu; in the standard Debian installations I worked with in the past, the default policy was ACCEPT. So we modify the iptables, but be very careful while modifying iptables, as it is quite easy to lock yourself out or create security vulnerabilities. First we create a backup of the original rules:

    sudo iptables-save > ~/iptables_backup
    Then we add our new rules before the DROP rule:
    sudo iptables -I INPUT 6 -p tcp --dport 80 -j ACCEPT
    sudo iptables -I INPUT 6 -p tcp --dport 443 -j ACCEPT

    Note that we are using -I INPUT 6 to insert the rules before the DROP rule, but after SSH and other essential rules. If you are using a different configuration, you may have to change the number. If you are not sure, you can use sudo iptables -L -n -v --line-numbers to see the line numbers. If everything is fine, you can save the rules:

    sudo netfilter-persistent save
    With the firewall properly configured to allow HTTP/HTTPS traffic, Let's Encrypt should now be able to reach your server to validate domain ownership and issue certificates.

Architecture Decision Time

Now that the server is set up, we have to decide on the architecture. What I mean by architecture is, will we continuously perform the conversion on the server (even when noone is connected), or will we only convert the stream when a client requests it? The first option is more resource consuming, but it is far more easier to implement. If you are planning to connect the output of the server to a TV or a monitoring screen that is almost always open, then the first option is better. However, if you are planning to connect the output to a browser, then the second option is better as there is no point of transforming while no client is connected. In this tutorial, we will start with the first option, then in the next one we will implement the second option.

Always-On System Implementation

The first option is quite easy to implement. We will use ffmpeg to convert the RTSP stream to HLS, then we will use Caddy to serve the HLS stream.

Once we have verified that the Caddy is serving files correctly (and we can access them), let's prepare some of the directories that we will use:

sudo mkdir -p /opt/stream-service
sudo mkdir -p /var/www/hls  # For the HLS segments
sudo chmod 755 /var/www/hls
sudo chmod +x /opt/stream-service/stream.sh

Then we create a simple test webpage that can consume the HLS stream:

sudo nvim /var/www/hls/test.html
The content of the file should be:
<!DOCTYPE html>
    <html>
    <head>
        <title>HLS Test</title>
        <script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
        <style>
            .container {
                max-width: 1280px;
                margin: 20px auto;
            }
            video {
                width: 100%;
                background: #000;
            }
        </style>
    </head>
    <body>
        <div class="container">
            <video id="video" controls muted playsinline></video>
            <div id="status"></div>
        </div>
    
        <script>
          const video = document.getElementById('video');
          const status = document.getElementById('status');
    
          if(Hls.isSupported()) {
            const hls = new Hls({
              debug: true,
              enableWorker: true,
              lowLatencyMode: true,
              backBufferLength: 90
            });
    
            hls.loadSource('playlist.m3u8');
            hls.attachMedia(video);
    
            hls.on(Hls.Events.MANIFEST_PARSED, function() {
              status.textContent = 'Manifest loaded, starting playback...';
              video.play().catch(e => {
                status.textContent = 'Playback error: ' + e.message;
                console.error('Playback error:', e);
              });
            });
    
            hls.on(Hls.Events.ERROR, function(event, data) {
              console.log('HLS Error:', data);
              status.textContent = 'Error: ' + data.type + ' - ' + data.details;
              if (data.fatal) {
                switch(data.type) {
                  case Hls.ErrorTypes.NETWORK_ERROR:
                    console.log("Network error, attempting recovery...");
                    status.textContent = 'Network error, attempting recovery...';
                    hls.startLoad();
                    break;
                  case Hls.ErrorTypes.MEDIA_ERROR:
                    console.log("Media error, attempting recovery...");
                    status.textContent = 'Media error, attempting recovery...';
                    hls.recoverMediaError();
                    break;
                }
              }
            });
          }
        </script>
    </body>
    </html>

Updating Caddyfile

We update our Caddyfile to serve the content, but we addd some additional options:

test.stream.example.com {
    root * /var/www/hls
    file_server
    header {
            Access-Control-Allow-Origin *
            Cache-Control "no-cache, no-store, must-revalidate"
    }
}

Creating the Conversion/Transformation Script

We can put the script itself under /opt/stream-service/stream.sh. Later we will convert this to a system service,so remember the location. The content of the script will contain several parameters that needs to be decided:

  • The output resolution: The higher it is, the crispier the image will be, but the more CPU power and bandwidth it will consume.
  • Audio: Do you want to include audio in the stream? If yes, what is the audio codec?
  • Transcoding: It is very likely that the browser will not be able to play HEVC (H.265), so we will have to transcode the video to H.264. This will consume more CPU power, but it is necessary.
If you are planning to keep the audio, you have to include the audio codec in the command:
#!/bin/bash
# Clean existing files
rm -f /var/www/hls/*.ts /var/www/hls/*.m3u8

ffmpeg -loglevel info \
    -fflags nobuffer \
    -flags low_delay \
    -rtsp_transport tcp \
    -i "rtsp://[cameraip]/user=[user]&password=[password]&channel=[channel]&stream=0.sdp" \
    -c:v libx264 \
    -preset ultrafast \
    -tune zerolatency \
    -profile:v baseline \
    -c:a aac \
    -ar 8000 \
    -f hls \
    -hls_time 4 \
    -hls_list_size 5 \
    -hls_flags delete_segments+independent_segments \
    -hls_segment_type mpegts \
    -flush_packets 1 \
    -hls_segment_filename /var/www/hls/%d.ts \
    /var/www/hls/playlist.m3u8
If you want to drop audio, it will simplify things a lot:
#!/bin/bash
# Clean existing files
rm -f /var/www/hls/*.ts /var/www/hls/*.m3u8

ffmpeg -loglevel info \
    -fflags nobuffer \
    -flags low_delay \
    -rtsp_transport tcp \
    -i "rtsp://[cameraip]/user=[user]&password=[password]&channel=[channel]&stream=0.sdp" \
    -an \
    -c:v libx264 \
    -preset ultrafast \
    -tune zerolatency \
    -profile:v baseline \
    -f hls \
    -hls_time 4 \
    -hls_list_size 5 \
    -hls_flags delete_segments+independent_segments \
    -hls_segment_type mpegts \
    -flush_packets 1 \
    -hls_segment_filename /var/www/hls/%d.ts \
    /var/www/hls/playlist.m3u8
Don't forget to change the [cameraip], [user], [password], and [channel] with the correct values.

Now the script is ready, we can make it executable:

sudo chmod +x /opt/stream-service/stream.sh
Then we can run the script:
sudo /opt/stream-service/stream.sh
If you want to check the output, you can create a new window in tmux and run the script there. If everything is fine, you should be able to connect to the server using the domain name and see the stream.

If you want to run the script in the background, you can use nohup, or better you can use systemd to create a service. We will create a service:

sudo nvim /etc/systemd/system/stream.service
The content of the file should be:
[Unit]
Description=RTSP to HLS Streaming Service
After=network.target

[Service]
Type=simple
User=root
Group=root
ExecStart=/opt/stream-service/stream.sh
Restart=always
RestartSec=5
StandardOutput=append:/var/log/stream-service/service.log
StandardError=append:/var/log/stream-service/service.log

[Install]
WantedBy=multi-user.target
Then we enable the service:
sudo systemctl enable stream.service
And start the service:
sudo systemctl start stream.service

Conclusions

In this tutorial, we have set up a server that converts an RTSP stream to HLS and serves it to the clients. This is a very basic setup, and it can be improved in many ways. In the next tutorial we will implement a system that only converts the stream when a client requests it. This will save a lot of resources, as the server will not have to convert the stream when noone is watching.