<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>self-hosting · wleeaf field notes</title><link>https://articles.wleeaf.dev/tags/self-hosting/</link><description>Hands-on guides and build logs from the wleeaf studio: self-hosting, Linux, networking, Docker, the command line and reverse engineering.</description><language>en-us</language><lastBuildDate>Thu, 26 Mar 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://articles.wleeaf.dev/tags/self-hosting/feed.xml" rel="self" type="application/rss+xml"/><item><title>Self-Hosting Matrix: Federation, Calls, and What Nobody Tells You</title><link>https://articles.wleeaf.dev/posts/matrix-federation-self-hosted/</link><pubDate>Thu, 26 Mar 2026 00:00:00 +0000</pubDate><guid>https://articles.wleeaf.dev/posts/matrix-federation-self-hosted/</guid><description>Setting up a Matrix homeserver with Element Web, voice calls, and federation. The documentation leaves out some important details.</description><category>matrix</category><category>self-hosting</category><category>chat</category><content:encoded><![CDATA[<p>I wanted a self-hosted chat platform that my friends could use without installing anything weird. Matrix with Element Web checked every box: it works in the browser, supports voice and video calls, has end-to-end encryption, and federates with other Matrix servers. The setup was mostly straightforward, but there were a few things that cost me hours because the documentation glosses over them.</p>
<h2 id="server-name-is-permanent">Server Name Is Permanent<a class="anchor" href="#server-name-is-permanent" aria-label="Link to “Server Name Is Permanent”">#</a></h2>
<p>The most important decision you make is <code>server_name</code> in the Synapse config. This is the domain that appears in every user ID. If you set it to <code>matrix.example.com</code>, your users will be <code>@name:matrix.example.com</code>. If you set it to <code>example.com</code>, they will be <code>@name:example.com</code>.</p>
<p>You want the bare domain. Shorter IDs look better and are easier to share. But here is the catch: once you set the server name and create the first user, you cannot change it. Ever. The signing keys, room IDs, and event hashes are all tied to that name. Changing it means starting over.</p>
<p>I went with the bare domain and use well-known delegation to tell other servers that the actual homeserver lives at a subdomain.</p>
<h2 id="well-known-delegation">Well-Known Delegation<a class="anchor" href="#well-known-delegation" aria-label="Link to “Well-Known Delegation”">#</a></h2>
<p>When another Matrix server wants to talk to yours, it looks up your server name. If your server name is <code>example.com</code> but Synapse runs on <code>matrix.example.com</code>, the remote server needs a way to discover that.</p>
<p>You serve two JSON endpoints on the bare domain:</p>
<p>The server endpoint returns which host and port handles federation. The client endpoint returns where the client API lives. Both are tiny JSON responses that Caddy can serve inline with the <code>respond</code> directive. No files, no separate service.</p>
<p>The important detail that the docs barely mention: the client well-known response needs a CORS header. Specifically <code>Access-Control-Allow-Origin: *</code>. Without it, Element Web running in a browser cannot discover the homeserver. You will stare at a login screen that says it cannot reach the server and wonder what you did wrong.</p>
<h2 id="cloudflare-and-federation">Cloudflare and Federation<a class="anchor" href="#cloudflare-and-federation" aria-label="Link to “Cloudflare and Federation”">#</a></h2>
<p>If your domain is behind Cloudflare, federation works fine as long as you set up the well-known endpoints correctly. Federation traffic goes through port 443, which Cloudflare proxies without issues.</p>
<p>The one thing to watch: Cloudflare has a 100-second timeout on the free plan. For a small personal server this is irrelevant, but if you ever end up in a large federated room, the initial sync could theoretically hit that limit.</p>
<p>I have been in rooms with several hundred members and never hit the timeout. But it is worth knowing about.</p>
<h2 id="voice-and-video-calls">Voice and Video Calls<a class="anchor" href="#voice-and-video-calls" aria-label="Link to “Voice and Video Calls”">#</a></h2>
<p>Element supports one-to-one and group calls, but they need a TURN server to relay media when direct peer-to-peer connections fail. This happens more often than you would think. Corporate firewalls, symmetric NAT, and mobile carriers all block direct WebRTC connections.</p>
<p>I already had a coturn server running for another project. Adding Matrix support was just a matter of generating a shared secret and putting it in the Synapse config. Synapse generates time-limited TURN credentials for each call using HMAC, which is more secure than static credentials.</p>
<p>The config in Synapse points to the TURN server by IP address, not hostname. This avoids DNS resolution issues. You list both UDP and TCP transport options so clients behind restrictive firewalls can fall back to TCP.</p>
<p>Testing calls is annoying because you need two separate accounts on two separate devices. You cannot call yourself. I created a test account, logged in on my phone, and called from my laptop. The first call had one-way audio because the TURN server was binding relay ports on Docker internal IPs instead of the public IP. Adding <code>--relay-ip</code> and <code>--listening-ip</code> flags to coturn fixed it immediately.</p>
<h2 id="registration-and-invites">Registration and Invites<a class="anchor" href="#registration-and-invites" aria-label="Link to “Registration and Invites”">#</a></h2>
<p>I started with invite-only registration using tokens. Synapse has a built-in token system where you generate a token, give it to someone, and they use it during registration. Each token can be limited to a single use.</p>
<p>Then I realized I was the only one creating accounts, so I just opened registration entirely. Synapse will complain about open registration without verification, but you can silence that with a config flag. For a small server with friends, the spam risk is minimal.</p>
<p>If spam does become a problem, switching back to token-based registration is a one-line config change. No need to over-engineer it upfront.</p>
<h2 id="password-policy">Password Policy<a class="anchor" href="#password-policy" aria-label="Link to “Password Policy”">#</a></h2>
<p>Synapse has a built-in password strength checker that rejects most passwords. It is aggressive. Passwords that I considered perfectly fine were being rejected. The policy can be disabled in the config, which I did immediately.</p>
<p>Element Web also has its own client-side password strength check that is separate from the server policy. Even after disabling the server policy, Element still complained. That one I could not disable through config. It is more of an annoyance than a blocker since it warns but does not prevent you from proceeding.</p>
<h2 id="resource-usage">Resource Usage<a class="anchor" href="#resource-usage" aria-label="Link to “Resource Usage”">#</a></h2>
<p>Synapse is the heaviest thing on my server. At idle with a handful of users, it uses about 300 megabytes. During federation sync or when someone joins a large room, it can spike to a gigabyte.</p>
<p>The PostgreSQL database backing it uses another 70 megabytes. Element Web is negligible since it is just static files served by nginx in a container.</p>
<p>The config has a <code>caches.global_factor</code> setting that controls memory usage. The default is 0.5. Turning it down to 0.3 reduces memory at the cost of more database queries. For a small server the difference is not noticeable.</p>
<h2 id="was-it-worth-the-effort">Was It Worth the Effort<a class="anchor" href="#was-it-worth-the-effort" aria-label="Link to “Was It Worth the Effort”">#</a></h2>
<p>Absolutely. My friends and I have a chat platform that we fully control. Voice calls work, screen sharing works, file sharing works. The web client means nobody has to install anything. And because it federates, anyone on any Matrix server can reach us if they know our addresses.</p>
<p>The setup took about two hours. Most of that was the TURN server configuration and testing calls. If you skip voice and video, you could have it running in thirty minutes.</p>
]]></content:encoded></item><item><title>Running 15 Services on One VPS With Docker Compose</title><link>https://articles.wleeaf.dev/posts/docker-compose-single-server/</link><pubDate>Sun, 22 Mar 2026 00:00:00 +0000</pubDate><guid>https://articles.wleeaf.dev/posts/docker-compose-single-server/</guid><description>How a single compose file, two networks, and some naming discipline keeps everything manageable.</description><category>docker</category><category>infrastructure</category><category>self-hosting</category><content:encoded><![CDATA[<p>At some point I looked at my server and counted fifteen containers. A reverse proxy, a git server, a CI runner, a game server, a mail server, a chat platform, a webmail client, a database, a VPN, an auth portal, an admin panel, a music bot, a TURN server, and a couple of static site builders. All on one four-core VPS with eight gigs of RAM.</p>
<p>It sounds like a mess, but it is actually pretty clean. The trick is structure: one compose file, two networks and some naming discipline.</p>
<h2 id="one-compose-file-two-networks">One Compose File, Two Networks<a class="anchor" href="#one-compose-file-two-networks" aria-label="Link to “One Compose File, Two Networks”">#</a></h2>
<p>Every service lives in a single docker-compose.yml. I tried splitting it into multiple compose files early on and it was a nightmare. Services that need to talk to each other end up on different networks, you forget which file defines what, and debugging becomes archaeology.</p>
<p>One file means one place to look. The trade-off is that the file is about 200 lines long, but that is way better than hunting through five separate files.</p>
<p>The networking is simple: two Docker bridge networks. The first one connects Caddy to the internet. The second one connects Caddy to every backend service. Services that need to be reached by Caddy join the backend network. Services that need direct port access to the internet get explicit port mappings.</p>
<p>The WireGuard container is the exception. It runs with host networking because it needs raw access to the network stack for the VPN tunnel. Everything else uses the bridge networks.</p>
<h2 id="container-naming">Container Naming<a class="anchor" href="#container-naming" aria-label="Link to “Container Naming”">#</a></h2>
<p>I name every container explicitly with <code>container_name</code>. Docker Compose generates names like <code>wleeaf-caddy-1</code> by default, which is ugly in logs and hard to type in commands. Explicit names mean I can do <code>docker logs caddy</code> instead of <code>docker logs wleeaf-caddy-1</code>.</p>
<p>This also matters for DNS resolution inside Docker networks. When you give a container a name, other containers on the same network can reach it by that name. So my Caddy config just says <code>reverse_proxy gitea:3000</code> and Docker handles the resolution.</p>
<h2 id="resource-reality">Resource Reality<a class="anchor" href="#resource-reality" aria-label="Link to “Resource Reality”">#</a></h2>
<p>People overestimate how much RAM you need. Here is what my containers actually use:</p>
<p>The reverse proxy sits at 15 megabytes. The git server uses about 110. The mail server is the hungriest at 160. The Matrix homeserver varies between 100 and 500 depending on federation activity. The database backing it uses 70. Everything else is under 50 each.</p>
<p>Total memory usage hovers around 2.5 gigabytes. That leaves five gigs free for spikes, builds, and the occasional agent session eating a gig for a few minutes.</p>
<p>CPU is basically idle. The load average rarely goes above 0.1 unless someone is building a Docker image or running a CI job. Four cores is overkill for this workload.</p>
<h2 id="secrets-management">Secrets Management<a class="anchor" href="#secrets-management" aria-label="Link to “Secrets Management”">#</a></h2>
<p>I keep secrets in a .env file next to the compose file. Docker Compose reads it automatically. Database passwords, API tokens, and hashed passwords all live there. The compose file references them with the dollar-sign-curly-brace syntax.</p>
<p>The .env file is not in any git repo. I back it up manually. Some people use Docker secrets or Vault for this, but for a single-server personal setup, a .env file is fine.</p>
<h2 id="restart-policies">Restart Policies<a class="anchor" href="#restart-policies" aria-label="Link to “Restart Policies”">#</a></h2>
<p>Every service has <code>restart: unless-stopped</code>. This means containers come back after a reboot or a crash, but stay down if I explicitly stop them. I used to use <code>always</code> but that fights you when you are debugging, because the container keeps restarting while you are trying to read the logs.</p>
<p>The one exception is the CI runner. It has <code>restart: unless-stopped</code> too, but I sometimes need to stop it to free resources during heavy builds. With <code>unless-stopped</code>, it stays down until I bring it back up.</p>
<h2 id="volume-strategy">Volume Strategy<a class="anchor" href="#volume-strategy" aria-label="Link to “Volume Strategy”">#</a></h2>
<p>I mount named directories from the host filesystem, not Docker volumes. Every service gets a directory under the main project folder. Caddy gets <code>./caddy</code>, Gitea gets <code>./gitea</code>, the mail server gets <code>./stalwart</code>, and so on.</p>
<p>This makes backups trivial. I can tar the entire project directory and I have everything: configs, data, and the compose file itself. Docker volumes are fine, but they hide the data in a location you have to look up every time.</p>
<h2 id="the-upgrade-dance">The Upgrade Dance<a class="anchor" href="#the-upgrade-dance" aria-label="Link to “The Upgrade Dance”">#</a></h2>
<p>Upgrading a service usually means changing the image tag and running <code>docker compose up -d</code>. For services using <code>latest</code>, I pull first with <code>docker compose pull</code> and then bring them up.</p>
<p>I do not use Watchtower or any automatic update tool. I want to know exactly when things change. An automatic update at 3 AM that breaks the mail server is not a problem I want to have.</p>
<p>The whole setup has been running for months with essentially zero unplanned downtime. The only time services go down is when I am actively working on them. For a personal infrastructure project, that is more than good enough.</p>
]]></content:encoded></item><item><title>What I Learned Setting Up My Own Mail Server in 2026</title><link>https://articles.wleeaf.dev/posts/self-hosting-mail-server/</link><pubDate>Sun, 15 Mar 2026 00:00:00 +0000</pubDate><guid>https://articles.wleeaf.dev/posts/self-hosting-mail-server/</guid><description>Running your own SMTP/IMAP server is supposedly a nightmare. Here is what actually happened when I tried it.</description><category>self-hosting</category><category>email</category><category>infrastructure</category><content:encoded><![CDATA[<p>Everyone tells you not to run your own mail server. I did it anyway.</p>
<p>The usual advice goes something like this: spam filters will eat your messages, IP reputation is impossible to build, and you will spend your weekends debugging DKIM signatures instead of doing anything useful. After a couple weeks of running Stalwart on a cheap Hetzner VPS, I can say that some of those warnings are legitimate but most of them are outdated.</p>
<h2 id="the-setup">The Setup<a class="anchor" href="#the-setup" aria-label="Link to “The Setup”">#</a></h2>
<p>I went with Stalwart, which is a relatively new mail server written in Rust. It handles SMTP, IMAP, and JMAP out of the box, and the admin UI is surprisingly decent for a self-hosted project. The whole thing runs in a single Docker container with a RocksDB backend, which means no separate database to manage.</p>
<p>The stack looks like this:</p>
<ul>
<li>Stalwart for mail handling on ports 25, 465, 587, and 993</li>
<li>Caddy as the reverse proxy with a wildcard TLS cert</li>
<li>SnappyMail for webmail access</li>
<li>Cloudflare for DNS on the free plan</li>
</ul>
<p>The VPS itself is nothing special. 4 cores, 8 gigs of RAM, running Ubuntu 24.04. Stalwart barely touches the resources and sits at around 150MB of RAM even after a few thousand messages.</p>
<h2 id="dns-was-the-easy-part">DNS Was the Easy Part<a class="anchor" href="#dns-was-the-easy-part" aria-label="Link to “DNS Was the Easy Part”">#</a></h2>
<p>Setting up DNS records is the part that scares most people, but it is genuinely straightforward if you take it step by step. You need an A record for your mail subdomain, an MX record pointing to it, SPF to declare which IPs can send for your domain, DKIM for cryptographic signature verification, and DMARC to set a policy for failed checks.</p>
<p>The Cloudflare API makes all of this scriptable. I wrote a shell script that creates all five records in about 30 seconds. The DKIM key was the only tricky part because Stalwart generates it internally and you have to copy it out of the admin panel and paste it into DNS.</p>
<h2 id="the-reputation-problem">The Reputation Problem<a class="anchor" href="#the-reputation-problem" aria-label="Link to “The Reputation Problem”">#</a></h2>
<p>This is the real challenge. When you spin up a brand new IP and start sending mail, every major provider treats you with suspicion. Gmail sent my first few messages straight to spam even though SPF, DKIM, and DMARC all passed perfectly.</p>
<p>The fix was not technical, it was behavioral. I marked those messages as not spam in my Gmail account, added the addresses to my contacts, and started having normal conversations. Within a few days, Gmail stopped flagging my messages. Google Postmaster Tools confirmed that the domain reputation was climbing.</p>
<p>One thing that helped: my VPS provider has decent IP reputation to begin with. If you are on a provider known for abuse, you will have a much harder time. Also make sure your reverse DNS is set correctly because Gmail checks that the PTR record for your IP matches your mail hostname.</p>
<h2 id="tls-certificate-sharing">TLS Certificate Sharing<a class="anchor" href="#tls-certificate-sharing" aria-label="Link to “TLS Certificate Sharing”">#</a></h2>
<p>I did not want to manage separate certificates for the mail server. Caddy already handles TLS for all my subdomains using a wildcard cert from Lets Encrypt via DNS challenge. I just mounted the certificate directory read-only into the Stalwart container.</p>
<p>The only catch is that Caddy stores the cert in a specific directory structure and you need to figure out the exact path. Once you find that, a volume mount in Docker Compose is all you need.</p>
<h2 id="was-it-worth-it">Was It Worth It<a class="anchor" href="#was-it-worth-it" aria-label="Link to “Was It Worth It”">#</a></h2>
<p>Honestly, yes. The total ongoing cost is zero beyond the VPS I was already paying for. I have full control over my email, the data never touches a third party, and the whole thing took an afternoon to set up.</p>
<p>The one thing I would do differently: I would have registered my domain and set up the mail server on the same day. Domain age matters for reputation, and the sooner you start sending from a domain, the sooner it builds trust with the big providers.</p>
<p>If you are already running a VPS for other things, adding a mail server is a no-brainer. If you are starting from scratch just for email, you might want to think about whether the learning experience is worth the first week of dealing with spam folders.</p>
]]></content:encoded></item></channel></rss>