15 Sep 2026
Cloudflare and Deploying on Homeserver
I Needed To Do This Because I Live On Intern Salary
The other day I accidentally burnt all my google cloud credits. So all my web app projects broke. To solve the issue I got the idea to repurpose a low-end PC (Dell Latititude 3420) to make a home server.
For the low price of $3 a month, I can now deploy my projects so they're accessible to the world. Within the week the server was set up it became the host for:
- This website
- A telegram bot for my close friend (formerly on GCP)
- A sqlite database for a pdf parser for my mom (formerly on supabase for the database)
In its previous life as a hand-me-down PC from a failed startup of my dad's friend, its fan would spin even when it was idle. I didn't let that slide, so to save power and money I deleted Windows (as you generally always should) and put on Debian 13 minimal.
This means when I turn on the laptop there's no GUI and I just see a bash terminal. I think thats pretty cool. The heat generation also went significantly down, so now its just quietly plugged in a corner of the living room, generally being useful.
Deployment Methodology (for dhelmy.stream)
Most of the changes I make to the website are on a personal-website branch. When I want the state of the code deployed, I push to personal-website-deploy. When this happens, I configure a github actions to...
Push to personal_website_deploy
→ run tests
→ build docker image
→ hoist onto Github Container Registry (GHCR)
→ Create a Github release containing image.tar.gz, source.tar.gz, release manifest, checksum
An image.tar.gz is a compressed docker image for easier transfer, and if you run docker load on it you can get the image application. Tar is a file format bundling multiple files together. Gz refers to gzp, and gzip compresses the archive.
Then a source.tar.gz uses the same tar + gzip compression and contains the actual source files in the repo. This means the website has access to yaml and configuration files, or even database migrations for the new version of the app.
Finaly, a checksum is a hash for the files which changes when files are modified or corrupted. Theres one for the files before transfer, and one for the files after. If checksum(before_transfer) == checksum(after_transfer) you can ensure your deployment was not corrupted.
Meanwhile, the homeserver runs the following CRON job every minute...
CRON job
→ check GHCR every minute
→ download a new release
→ check GHCR every minute
Docker and Compose
Tl;dr Containers are separate boxes for programs and their dependencies. Compose is the list explaining which boxes belong together and how they connect.
The website's Compose project is named dhelmy-stream and contains three services:
| Service | Job |
|---|---|
| Caddy | Receives requests from tunnel and forwards them to the application. |
| Django | Runs the website, accounts, articles, annotations, and review behavior. |
| PostgreSQL | Stores the application's structured data. |
The database and application communicate inside the Compose network. PostgreSQL is not published on a host port. Persistent volumes keep database contents and uploaded media outside disposable application containers.
Compose projects also provides the structure for running this website against the telegram bot and the database. It give each application its own Compose project, configuration, volumes, and any required host ports. They might share the computer's CPU and memory, but a github action deployment can only target its corresponding Compose.
Web Dev Concepts
Domain Name System (DNS) & CNAME
DNS is the internet's address book. It tells the internet where to send requests for dhelmy.stream.
Usually, each DNS record has a CNAME. A simple common example of this is
www.google.com → google.com
Websites point to other website and visiting www.google.com brings to you google.com.
For this project, because the domain was purchased through Cloudflare. We used Cloudflare toc connect dhelmy.stream to the website running at home.
The pieces are:
Root hostname: dhelmy.stream
Website tunnel: a secure Cloudflare connection to the home server
And finally, proxied CNAME
Proxied CNAME
Type: CNAME
Name: @
Target: <WEBSITE_TUNNEL_ID>.cfargotunnel.com
Proxy: Enabled
A proxied CNAME means DNS forces you to connect to a different server first. In this case, that server is Cloudflare, and proxied CNAME tells you "connect to a cloudflare server" instead of redirecting you first. The browser will still show https://dhelmy.stream.
More explicitly
visitor asks for dhelmy.stream
↓ owing to proxied cname...
Browser connects to Cloudflare IP address
After that...
↓
Cloudflare finds that dhelmy.stream is associated with a tunnel
___________ ___________
/ /| / /|
/ //| / //|
====/==========///==================/==========///====
( ((/ ( ((/
) ) ) )
( ( ( (
====\==========\====================\==========\======
\__________\ \__________\
Cloudflare sends a request through the tunnel.
The end of the tunnel leads to request away from cloudflare's server, on to my server
And then on the cloudflare server....
cloudflared receives the request from the other end of the tunnel
↓
forward to a configured local IP (e.g. 127.0.0.1:8080).
↓
Caddy
↓
Django app does stuff
At the end of this fountain of request, it travels in the reverse order to send it back to visitor
The tunnel ID identifies the destination tunnel. It must match the tunnel connected to the Debian server.
Benefits Of Cloudflare
- HTTPS termination
- DDoS protection
- caching and CDN features
- firewall rules
- origin IP hiding
- Cloudflare Tunnel routing
Tunnels
Tunnels are effectively secure connections from two servers to keep them always available.
Cloudflare can sends website requests through that connection. Homeserver can send website responses through the tunnel too.
cloudflared
The program maintaining that connection is cloudflared. We configured it as a systemd service on the Debian server. This arrangement avoids opening inbound website ports on the router or arranging a fixed public IP. Cloudflare's Tunnel overview explains the outbound connection model.
Imagine your home server is a shop inside a locked building.
Normally, to let people visit your website, you would need to:
- Open a door in your router (“open port 80/443”).
- Tell people the building’s street address (“public IP address”).
- Get a permanent address if your ISP keeps changing it (“fixed public IP”).
Cloudflare Tunnel works differently:
Visitor → Cloudflare → existing outgoing connection → your server
Your server runs a small Cloudflare program that continuously makes an outgoing connection to Cloudflare—like the shop calling Cloudflare and saying, “I’m available.”
Because the connection starts from inside your network:
- You do not need to open an inbound router port.
- You do not need port forwarding.
- You do not need a fixed public IP.
- If your home IP changes, the tunnel can reconnect automatically.
If you want to do this, Open Networking → Tunnels, select the website tunnel, and run its setup commands for debian.
Route
ELI5: The tunnel gets a request to the right building. A route tells it which room contains the website. You have to configure this on top of the tunnel.
We configured the website's published application route as follows:
| Field | Value |
|---|---|
| Subdomain | Empty |
| Domain | dhelmy.stream |
| Path | Empty |
| Service type | HTTP |
| Service address | 127.0.0.1:8080 |
If the interface combines the last two fields, the value is http://127.0.0.1:8080.
Here, 127.0.0.1 is the IP address you use to refer to yourself. In this case, the computer running cloudflared program at the end of the tunnel: the Debian server. Caddy's host port is bound to 127.0.0.1, so it accepts requests if and only if they are local connections. Since those requests come from cloudflared and cloudflared is local, then only cloudflared can talk to caddy.
Finally, Caddy then forwards requests to web:8000 inside Docker. web is Docker Compose service name. Docker runs its own internal DNS server for each Docker network it creates. When containers are attached to the same Docker network, Docker automatically registers each container's service name (from docker-compose.yml) so other containers on that same network can resolve it by name. web gets resolved to whatever internal IP Docker assigned that container (something like 172.18.0.3, invisible outside Docker)