In an effort to justify this site not just being a static site, and to try to make the site more fun, I wanted to show other visitors' cursors when you're visiting a page.
You can try it right now by opening this same page in a new tab, and move your cursor. You should be able to see the cursor from the other tab.
I wanted to be able to see other people's cursors because reading on the internet can be a lonely place. You might know that there are other people reading the same thing as you're doing now, but you don't see them. If there is a comment section, it's likely static, and doesn't update in real time. BiliBili has improved on this time-stamping comments and letting them scroll across the video while it's playing.
But it can also be too much with the pressure of having to talk with someone. Sometimes, all you want is to see that there is someone there.
I know that there are some video games in which you principally play alone, but every once in a while a shadow of another real player shows up. Usually you can't interact much, and your paths likely diverge as suddenly as you met, but I think there is something special about the occasional reminder that you're not alone: There are other people out there doing the same as you.
That's why I want to implement guest-cursors. The serendipity, anonymity, and limited communication is perfect for what I want.
I thought this would be relatively straight forward build and deploy since I have built out some infrastructure to easily deploy small applications like these on my home-lab.
I thought the home-lab would be a good deploy target as I don't need the a great uptime because in the end it's a very optional feature.
I thought I had scoped out the project to take only a few hours, but it ended up spiralling into a multi day project trying to deploy it.
How guest cursors work
The guest cursors code is surprisingly simple. It's based on a demo made by Dezmerean Robert which has 90% of what I needed.
It works by having a websocket server that each client connects to. When a client connects the server assigns the client an ID and a color to make sure that the colors are shared between all sessions.
If you're not familiar, websockets are great for this kind of 'real-time' communication because it keeps a connection open between the client and the server, and both can send information whenever they have it. This not only reduces the overhead of header traffic having to negotiate a new connection whenever new data is sent, but it also inverts the flow of information.
Instead of having to constantly poll asking the server if there's new information, the server can just send the information whenever there is something new. Drastically reducing the network traffic.
"Do you have something new? Do you have something new? Do you have something new?" becomes "I have something new for you".
Whenever a client's cursor moves, the client sends the new position to the server, and the server broadcasts the new position to all other connected clients. The clients then use that information to display and update the position of the guest cursors. Almost as simple as it gets.
There is some extra code to throttle the number of requests, but that is essentially it.
I have added a few more features to handle scroll-events such that the cursor shows up correctly after scroll and so that the cursor doesn't jump after a visitor having scrolled a bit. I also handle touch devices more gracefully.
Finally, I added support for multiple pages such that you don't see people's cursors from other pages, but most of the work was done by Robert.
So far, everything went as expected without any painful surprises. I was excited that this would just be a small quick project, but right as I was approaching the finish line, things took a turn for the worse.
Painful deployment
The hard part was over now I thought. All there's left to do is to deploy the service like we deployed Plausible with Coolify and Cloudflare tunnels.
I set everything up, but I was met with a cryptic error 400 Bad Request
. I didn't get any logs at the server, so it appears that the request was blocked before it reached the service. However, when I access the status pages with https://
it works just fine, so there is a connection.
Is the problem in my code, with Cloudflare, or with the server?
My code works in the dev environment, so I reasoned the issue had to be somewhere else.
I had noticed that in the Cloudflare admin panel, I couldn't select the websocket protocol for connection, so I thought the issue might lie there.
When I set the protocol to TCP, the client said it is sending data, but the server didn't log anything, and the client didn't receive any data back. Looking at reddit, the opinions seemed mixed if Cloudflare tunnels supports wss://
, but the Cloudflare documentation claims that websockets are supported, by it gives for how to use it with tunnels.
In general, searching for information on this topic proved very difficult. It wasn't until I asked ChatGPT that I got a clue. It claimed that websockets are only supported with locally configured tunnels as opposed to remotely configured ones. After setting up a locally managed tunnel as ChatGPT suggested, I ran it, but got no further than before.
Did ChatGPT lie to me? Did I misconfigure the whole thing? Why can't I get any logs from the application. Only the 400 Bad Request
reported by the client whenever I try to establish a wss://
connection.
At this point, I had already spent more time trying to deploy the thing than I had building it. I was annoyed tired and frustrated that I hadn't made any progress, and decided to leave it for the day.
I was seriously considering canning the project. The overhead of doing it over https://
would be too high, and my VPS explicitly disallow websocket connections, so I wouldn't be able to deploy it there. I had no ideas for what could be wrong, and it seemed impossible to narrow down when I keep getting the same vague error message.
Yet, the next day, I remembered something I didn't think much of. In Coolify, I had to set the domain, but I couldn't change the protocol away from http(s)://
in the GUI. Even when changing the configuration files directly, it would be overwritten when saved.
What if the problem was also with Coolify?
I tried stopping the Coolify service, and run the application directly. To my surprise it worked. I had solved the problem!
Now I just need a robust way to run a locally managed tunnel and the application outside Coolify's management.
In the end, the problem turned out to be both Coolify's non-existent support of websockets and Cloudflare's spotty support. I have documented my exact steps for running the application robustly below if you ever find yourself in a similar situation, but in conclusion. It works, and I think it's pretty cool.
The solution: How to deploy a websocket application with Docker and Cloudflare Tunnels
To set up a locally managed tunnel with cloudflare, we need to run
cloudflared tunnel create [tunnel name]
When you go to ~/.cloudflared
you should see a file with a filename [UUID].json
. The UUID
is the id of the tunnel which we need now.
We need to configure the ingress routes for the tunnel now. We can do that in ~/.cloudflared/config.yml
by adding
tunnel: [tunnel UUID]
credentials-file: /home/[user]/.cloudflared/[tunnel UUID].json
ingress:
 - hostname: your.domain.com
  service: ws://localhost:7171 # add the websocket protocol.
 - service: http_status:404
Remember to change the [tunnel UUID]
, your.domain.com
, [user]
and the port to the appropriate values. Note that if you just need a http(s)://
service, you can do this a lot easier by using a managed tunnel. I have written a guide about that as well.
I'm going to assume that you're using Cloudflare for DNS, but it should work with any DNS provider. First, you need to make sure that you have websockets enabled for the domain. You can do so by going to the admin panel and press Network>Websockets>Enabled
. Then you need to add the correct DNS entry to tell Cloudflare to route the appropriate traffic to your tunnel. You should add
Name: your.domain.com
Type: CNAMEÂ
Target: [tunnel UUID].cfargotunnel.com
You are now ready to run the tunnel with cloudflared run [tunnel name]
. If you go the the domain in the config, and have your service running, you should be able to access it.
The problem is, you likely don't want a terminal open at all times, and you would probably also like the tunnel to automatically start up after a reboot. To solve both of these problems in one go, we can add it as a service using systemd
.
systemd
is a system utility available on most linux distros that does a lot of things, but we just need it for starting the tunnel whenever the server after reboots. We can do so by defining a service in /etc/systemd/system/cloudflared_[tunnel name].service
we write:
[Unit]
Description=cloudflared_[tunnel name]
After=network-online.target
Wants=network-online.target
[Service]
TimeoutStartSec=0
Type=notify
ExecStart=/usr/bin/cloudflared --no-autoupdate tunnel --config /home/[user]/.cloudflared/config.yml run [tunnel name]
Restart=on-failure
RestartSec=5s
[Install]
WantedBy=multi-user.target
To run the tunnel, we need to enable the service and start it. This also makes sure that it will automatically start after reboot.
sudo systemctl enable cloudflared_[tunnel name].service
sudo systemctl start cloudflared_[tunnel name].service
Finally, we just need to run the application.
Coolify doesn't work. As far as i can tell it just doesn't support websocket connections. The problem appears to be when you set the domain you have to choose http(s), and even if you manually edit the Traefik files, it will override the protocol when saving.
Instead of using Coolify, we will use Docker directly. This means that whichever service we use needs it's own dedicated port because I don't want to set up and manage another Traefik instance. Luckily this is okay. I don't expect to need many websocket applications.
I'm going to assume you have a Dockerfile for building the project, and a docker-compose.yml
for starting the service. You can find the minimal ones I use for the guest-cursors at the guest-cursor Github.
In the docker-compse.yml
, you just need to add restart: unless-stopped
to each service to make it survive a reboot.
Then when you're ready, you can run docker compose up -d
for starting the service and running it in the background. You only need to run this command once, Docker will automatically start it after any reboots, and you can still access stdout
with docker logs [container name]
.
Finally, everything should work if you go to your.domain.com
. It's more manual to set up and maintain than using the platforms, but it works, and it works quite well for my use case. I hope it can also be helpful to you.