A Glimpse into my Self-Hosting Setup

Edward Wibowo,

Self-hosting is one of my favorite pastimes.

It allows me to discover and maintain a wide range of services on my personal server, giving me the freedom and control I crave. From managing my own VPN and authentication service to hosting my own git instance, self-hosting empowers me to customize these services to my liking and have full control over my data.

But self-hosting is not a one-time setup — it’s an ongoing process. It involves continuous tweaks and changes to various configuration files. Over the time I have spent self-hosting, my setup has undergone significant transformations. It’s a journey that is both incredibly enjoyable and time-consuming.

In this post, my goal is to offer you a snapshot into my current self-hosting setup, some of the services I host, and how I secure my system.

Server Setup


My self-hosting setup revolves around a single Hetzner Virtual Private Server (VPS). I currently pay monthly for one of the cheaper plans (CPX 11) which costs me around €5 a month.

Here are the specs:

DistributionDebian 12 (bookworm)
Disk Space40 GB
Traffic Bandwidth20 TB

It’s a relatively modest setup. It won’t be serving out widely popular SaaS apps or running the latest LLMs anytime soon, but it works for my hobbyist purposes.

I used to maintain multiple physical servers — well, more like an old laptop from an e-waste bin and a Raspberry Pi 4 Model B. Hosting everything on my own hardware provided a greater sense of autonomy and control, but it’s more difficult to do so from a college dorm.

I do miss it though. There’s something incredibly satisfying about hosting stuff on your own hardware. I do hope to run my own self-hosting hardware in the future, perhaps even in a college dorm by leveraging WireGuard forwarding.

For now, the convenience of a VPS is hard to beat. The hardware is out of sight and out of mind. I really only have to worry about the software side of things.


All the services I self-host are deployed with Docker and Docker Compose. Containerizing each of my services trivializes setting up, starting, and removing services to a single command. I also don’t have to concern myself with outdated or missing Debian packages when building third-party applications.

Here are the services I currently run:

  ~ ssh altaria
Linux altaria 6.1.0-10-amd64
  [claby2@altaria] ~ ls -1 docker/


Some of the services I host on the web are exclusively for my personal use. To ensure that only authorized individuals (aka me) can access these services, I rely on Authelia as an authentication server.


Services without built-in authentication mechanisms are proxied with an authentication forwarding middleware (using voltproxy). This middleware directs clients to Authelia, which then redirects to the service if authenticaton is successful.

I defined a rule in my Authelia configuration to bypass authentication for devices on my WireGuard VPN network.

Authelia IP Bypass Configuration

Example authelia configuration.yaml

  default_policy: deny
    - name: internal
        - # WireGuard subnet.
    - domain: "*.example.com"
      policy: bypass
        - internal
    - domain: "*.example.com"
      policy: two_factor

This allows VPN-connected devies to access services without 2FA.


Blocky is a DNS proxy and ad-blocker that I rely on for all of my WireGuard clients. It helps me proxy DNS queries and also blocks any annoying ads that might pop up.


cabbot is a Discord bot I wrote in Rust using serenity-rs.


docs (MkDocs) is my personal documentation. I use it to document technical issues, jot down random ideas, and keep a general journal of my thoughts. All of the notes are written in Markdown and beautifully rendered for the web. It’s my own private knowledge base.



Dozzle is a Docker log viewer accessible from the web.



Filebrowser is a web-based file managing interface. I primarily use this to share files with other people and store documents.



FreshRSS is an RSS feed aggregator and is one of my main sources of media consumption. Through my instance, I am able to subscribe to personal blogs, news outlets, software changelogs (via GitHub Atom feeds), and other link aggregators.


I have an app on my phone that syncs seamlessly with my FreshRSS instance. This means that whether I’m at home or on the go, I can always stay up to date with the latest updates and news.

When I’m on the terminal, I rely on Newsboat. It’s a fantastic terminal user-interface that I configured to sync effortlessly with my FreshRSS instance. This means that not only are the feeds synced, but also the read status and starred posts. It’s a seamless experience that keeps me organized and ensures that I never miss out on any important information. If you’re interested in learning more about how to set it up, you can find detailed documentation to guide you through the process.


Gitea serves as my personal Git instance, providing the ideal platform for storing all my miscellaneous one-off repositories that will likely never be shared with the world.


Theme Repo

Theme Repo is a color scheme repository I made. I wrote a blog post about Theme Repo when I initially released it.

Theme Repo


IT-Tools is a website containing a collection of helpful developer tools.

IT Tools


And lastly but definitely not least (because I wrote it haha), voltproxy.


voltproxy is a reverse proxy that proxies all of the aforementioned services. Since my setup is primarily Docker-based, voltproxy makes it extremely easy to setup reverse proxying along with authentication forwarding and load balancing. Proxying a service is simply a matter of identifying the Docker container name, network, and port and specifying it all in a single YAML file.

voltproxy GitHub Card


Next, I would like to go over some of the ways I secure my setup.

General SSH Security

One of the essential measures I implement is disabling SSH password login. By doing this, I limit SSH access to only those users who possess the appropriate public/private key pair. This effectively eliminates the vulnerability that arises from weak passwords.

Firewall Management

I rely on Hetzner’s in-built firewall manager to fortify my server’s network.

Hetzner Firewall Interface

I can control which ports are open for publicly running services all from the web dashboard. By doing so, I minimize the potential attack surface and reduce risk of unauthorized access. I also generally try to avoid unnecessarily binding services to

HTTPS Enforcement

All of my publicly-accessible services are served with HTTPS. I rely on voltproxy’s automatic ACME-based certificate generation and renewal. It’s incredibly convenient because all I have to do is specify the domain in the configuration file, and voltproxy takes care of the rest.

Hardware Authentication

To add an extra layer of security, I use hardware authentication using YubiKey’s PIV module.


I currently own a YubiKey 4 (pictured above).

The key allows me to easily connect to my server from any machine in a secure manner. I have a separate SSH public key associated with the YubiKey to easily disable it in the case it gets lost, stolen, or damaged.

Here is a helper script I wrote to quickly provision keys:


# Default path for the generated key
# YubiKey slot to use for key generation

error() {
	echo "[ykssh ERROR]: $1" >&2
	exit 1

prompt() {
	while true; do
		read -rp "$1" yn
		case $yn in
		[Yy]*) eval "$2" && break ;;
		[Nn]*) break ;;
		*) echo "Please answer yes or no." ;;

command -v ykman >/dev/null || error "ykman is not installed"

prompt "Change PIN? [Y/n] " "ykman piv access change-pin"
prompt "Change PUK? [Y/n] " "ykman piv access change-puk"
prompt "Change Management Key? [Y/n] " "ykman piv access change-management-key"

echo "Testing pkcs11:"
pkcs11-tool --login --test || \
    error "PKCS#11 not supported, have you installed OpenSC?"

read -rp "Choose key path [blank for default $default_key_path] " key_path
[ -z "$key_path" ] && key_path="$default_key_path"

echo "Generating key..." &&
	ykman piv keys generate "$slot" "$key_path" &&
	echo "Generaeting certificate..." &&
	ykman piv certificates generate -s "CN=SSH-key" "$slot" "$key_path"


# Find the appropriate PKCS#11 library and print the generated SSH public key
for pkcs11 in "$pkcs11_debian" "$pkcs11_macos" "$pkcs11_other"; do
	[ -f "$pkcs11" ] && \
        echo "Printing generated SSH public key" && \
        ssh-keygen -D "$pkcs11" -e && exit 0

echo "Could not find PKCS#11 library"
exit 1

WireGuard Pre-shared Symmetric Keys

Although WireGuard is already considered to be highly secure, I take additional measures to enhance its security by implementing an extra layer of protection in the form of Pre-shared Symmetric Keys (PSKs). These PSKs serve as an additional barrier that adds an extra level of difficulty for anyone attempting to decrypt the traffic. This becomes particularly crucial in the event that a quantum computer manages to break Curve25519, which is the cryptographic algorithm used for key exchange in WireGuard. By incorporating PSKs, I ensure that even if key exchange were to be intercepted, the encrypted traffic remains safeguarded.

Regularly Updating Docker Images

To stay ahead of potential vulnerabilities, I make it a priority to regularly update my Docker container images. I considered using a service like watchtower to automatically update container images, but I prefer to read release notes and upgrade manually. I believe it’s important to be vigilant about what changes are being made in the software I use and serve.

I wrote a helper script to check if any running containers are using outdated image versions. It also attempts to suggest the latest version to use:


This script queries the Docker socket, meaning it requires privileged access!

# Iterate through all current containers and check if container image's version matches latest version.

digests() {
	curl -s "https://hub.docker.com/v2/namespaces/$1/repositories/$2/tags/$3" | jq -r ".images[].digest"

suggestlatest() {
	tags="$(curl -s "https://hub.docker.com/v2/repositories/$1/$2/tags?page_size=20" | jq -r -c ".results[]")"
	echo "$tags" | while read -r tag; do
		digest="$(echo "$tag" | jq -r ".images[].digest")"
		name="$(echo "$tag" | jq -r ".name")"
		[ "$name" != "latest" ] && [ "$digest" = "$3" ] && echo "$1/$2:$name" && break

images="$(curl -s --unix-socket /var/run/docker.sock http://localhost/containers/json | jq -r ".[].Image")"

echo "$images" | while read -r image; do
	if echo "$image" | grep -q ":"; then
		namespace="$(echo "$image" | grep -q "/" && echo "$image" | rev | cut -d"/" -f2 | rev || echo "library")"
		repository="$(echo "$image" | cut -d":" -f1 | rev | cut -d"/" -f1 | rev)"
		tag="$(echo "$image" | cut -d":" -f2)"
		current="$(digests "$namespace" "$repository" "$tag")"
		latest="$(digests "$namespace" "$repository" "latest")"
		if [ "$current" != "$latest" ]; then
			printf "%s -> %s\n" "$image" "$(suggestlatest "$namespace" "$repository" "$latest")"


Self-hosting is a great hobby time sink 💕.