<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: byteguard</title>
    <description>The latest articles on DEV Community by byteguard (@byte-guard).</description>
    <link>https://dev.to/byte-guard</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3874887%2Fb1a40b49-24d4-4b18-b170-c858a3a64af7.png</url>
      <title>DEV Community: byteguard</title>
      <link>https://dev.to/byte-guard</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/byte-guard"/>
    <language>en</language>
    <item>
      <title>SSH Hardening — The Ultimate Guide for 2026</title>
      <dc:creator>byteguard</dc:creator>
      <pubDate>Tue, 05 May 2026 23:39:21 +0000</pubDate>
      <link>https://dev.to/byte-guard/ssh-hardening-the-ultimate-guide-for-2026-396</link>
      <guid>https://dev.to/byte-guard/ssh-hardening-the-ultimate-guide-for-2026-396</guid>
      <description>&lt;h1&gt;
  
  
  SSH Hardening — The Ultimate Guide for 2026
&lt;/h1&gt;

&lt;p&gt;If your server has a public IP, it's getting SSH brute-force attempts right now. Not maybe. Not eventually. Right now. Check your auth log:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"Failed password"&lt;/span&gt; /var/log/auth.log | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-20&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You'll see hundreds — sometimes thousands — of failed login attempts from IPs you've never seen. Botnets scan the entire IPv4 space and hammer port 22 with common username/password combinations 24/7.&lt;/p&gt;

&lt;p&gt;My &lt;a href="https://blog.byte-guard.net/harden-linux-vps-10-minutes/" rel="noopener noreferrer"&gt;basic VPS hardening guide&lt;/a&gt; covers the essentials. This &lt;strong&gt;SSH hardening guide&lt;/strong&gt; goes deeper — every &lt;code&gt;sshd_config&lt;/code&gt; setting that matters, key-based authentication, two-factor auth, and monitoring. By the end, your SSH setup will be hardened against everything from automated bots to targeted attacks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A Linux VPS (Ubuntu 22.04/24.04 or Debian 12) with root or sudo access&lt;/li&gt;
&lt;li&gt;SSH access already working (don't lock yourself out before setting up alternatives)&lt;/li&gt;
&lt;li&gt;A second terminal/session open as a safety net while making changes&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning:&lt;/strong&gt; Always keep a second SSH session open when modifying SSH config. If you make a mistake, the existing session stays connected. Test your new config in a new connection before closing the old one.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  1. Switch to Key-Based SSH Authentication
&lt;/h2&gt;

&lt;p&gt;Password authentication is the weakest link. Even a strong password can be brute-forced given enough time. SSH keys are cryptographically stronger and immune to dictionary attacks.&lt;/p&gt;

&lt;h3&gt;
  
  
  Generate a Key Pair (on your local machine)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-keygen &lt;span class="nt"&gt;-t&lt;/span&gt; ed25519 &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="s2"&gt;"your_email@example.com"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ed25519 is the modern default — faster and more secure than RSA. If you need RSA compatibility (some older systems), use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-keygen &lt;span class="nt"&gt;-t&lt;/span&gt; rsa &lt;span class="nt"&gt;-b&lt;/span&gt; 4096 &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="s2"&gt;"your_email@example.com"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You'll be prompted for a passphrase. &lt;strong&gt;Use one.&lt;/strong&gt; The passphrase encrypts the private key on disk. If someone steals your key file, the passphrase is the last line of defense.&lt;/p&gt;

&lt;h3&gt;
  
  
  Copy the Public Key to Your Server
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-copy-id &lt;span class="nt"&gt;-i&lt;/span&gt; ~/.ssh/id_ed25519.pub user@your-server-ip
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or manually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; ~/.ssh/id_ed25519.pub | ssh user@your-server-ip &lt;span class="s2"&gt;"mkdir -p ~/.ssh &amp;amp;&amp;amp; chmod 700 ~/.ssh &amp;amp;&amp;amp; cat &amp;gt;&amp;gt; ~/.ssh/authorized_keys &amp;amp;&amp;amp; chmod 600 ~/.ssh/authorized_keys"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Test Key-Based Login
&lt;/h3&gt;

&lt;p&gt;Open a &lt;strong&gt;new&lt;/strong&gt; terminal (keep the old one open) and test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh user@your-server-ip
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If it logs in without asking for a password (or asks for your key passphrase instead), key auth is working.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Harden the SSH Configuration
&lt;/h2&gt;

&lt;p&gt;The main SSH server config lives at &lt;code&gt;/etc/ssh/sshd_config&lt;/code&gt;. Every change below goes in this file. After editing, restart SSH to apply:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart sshd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Disable Password Authentication
&lt;/h3&gt;

&lt;p&gt;Once key-based auth works, disable passwords entirely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;PasswordAuthentication no
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This single change eliminates brute-force password attacks completely. Bots can hammer port 22 all day — without the private key, they're not getting in.&lt;/p&gt;

&lt;h3&gt;
  
  
  Disable Root Login
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;PermitRootLogin no
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Even with key auth, there's no reason to allow direct root login. Use a regular user and &lt;code&gt;sudo&lt;/code&gt; for privilege escalation. This adds a layer — an attacker needs both the SSH key and knowledge of which user has sudo access.&lt;/p&gt;

&lt;h3&gt;
  
  
  Change the Default Port
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;Port 2222
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Changing from port 22 won't stop a determined attacker, but it eliminates 99% of automated bot traffic. Most botnets only scan port 22. This is security through obscurity — not a defense by itself, but it reduces noise in your logs dramatically.&lt;/p&gt;

&lt;p&gt;After changing the port, update your firewall:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow 2222/tcp comment &lt;span class="s2"&gt;"SSH custom port"&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw delete allow 22/tcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And connect with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-p&lt;/span&gt; 2222 user@your-server-ip
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Disable Empty Passwords
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;PermitEmptyPasswords no
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This should already be the default, but explicitly set it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Limit Authentication Attempts
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;MaxAuthTries 3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After 3 failed attempts, the connection is dropped. Combined with Fail2Ban, this makes brute-force attacks impractical.&lt;/p&gt;

&lt;h3&gt;
  
  
  Set a Login Grace Time
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;LoginGraceTime 30
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server waits 30 seconds for authentication before disconnecting. The default is 120 seconds — that's too generous. Reduce it to limit resource consumption from idle connections.&lt;/p&gt;

&lt;h3&gt;
  
  
  Disable X11 Forwarding
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;X11Forwarding no
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Unless you're running graphical applications over SSH (unlikely on a server), disable this. It's an unnecessary attack surface.&lt;/p&gt;

&lt;h3&gt;
  
  
  Disable TCP Forwarding (if not needed)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;AllowTcpForwarding no
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you don't use SSH tunnels, disable forwarding. If you use SSH tunnels for things like database access, leave it enabled or restrict to specific users.&lt;/p&gt;

&lt;h3&gt;
  
  
  Restrict SSH to Specific Users
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;AllowUsers yourusername
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a whitelist. Only the listed users can log in via SSH. Everyone else is rejected before authentication even starts.&lt;/p&gt;

&lt;h3&gt;
  
  
  Use Strong Ciphers and Key Exchange Algorithms
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org
Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com
MACs hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com
HostKeyAlgorithms ssh-ed25519,rsa-sha2-512,rsa-sha2-256
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This disables weak algorithms (3DES, SHA1, diffie-hellman-group1). Modern clients support all of these. If you have very old clients that can't connect after this change, they need upgrading — not your server weakening its crypto.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Complete Hardened sshd_config
&lt;/h2&gt;

&lt;p&gt;Here's the full set of changes in one block. Add or modify these lines in &lt;code&gt;/etc/ssh/sshd_config&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Network&lt;/span&gt;
Port 2222
AddressFamily inet                    &lt;span class="c"&gt;# IPv4 only (set to 'any' if you use IPv6)&lt;/span&gt;
ListenAddress 0.0.0.0

&lt;span class="c"&gt;# Authentication&lt;/span&gt;
PermitRootLogin no
PasswordAuthentication no
PermitEmptyPasswords no
PubkeyAuthentication &lt;span class="nb"&gt;yes
&lt;/span&gt;AuthenticationMethods publickey
MaxAuthTries 3
LoginGraceTime 30

&lt;span class="c"&gt;# Access control&lt;/span&gt;
AllowUsers yourusername

&lt;span class="c"&gt;# Security&lt;/span&gt;
X11Forwarding no
AllowTcpForwarding no
AllowAgentForwarding no
PermitTunnel no

&lt;span class="c"&gt;# Crypto&lt;/span&gt;
KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org
Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com
MACs hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com
HostKeyAlgorithms ssh-ed25519,rsa-sha2-512,rsa-sha2-256

&lt;span class="c"&gt;# Logging&lt;/span&gt;
LogLevel VERBOSE

&lt;span class="c"&gt;# Misc&lt;/span&gt;
ClientAliveInterval 300
ClientAliveCountMax 2
MaxSessions 3
Banner none
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Validate the config before restarting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;sshd &lt;span class="nt"&gt;-t&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If there are no errors, restart:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart sshd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Test in a new terminal before closing your current session.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Set Up Fail2Ban for SSH
&lt;/h2&gt;

&lt;p&gt;Fail2Ban monitors your auth logs and temporarily bans IPs that fail authentication too many times. Even with key-based auth, it reduces log noise and blocks scanners.&lt;/p&gt;

&lt;p&gt;Install Fail2Ban:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;fail2ban &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create a local config (don't edit the main config — it gets overwritten on updates):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/fail2ban/jail.local &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
[sshd]
enabled = true
port = 2222
filter = sshd
logpath = /var/log/auth.log
maxretry = 3
bantime = 3600
findtime = 600
banaction = ufw
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This bans an IP for 1 hour after 3 failed attempts within 10 minutes. Start and enable Fail2Ban:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable &lt;/span&gt;fail2ban
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl start fail2ban
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check the status:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;fail2ban-client status sshd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You'll see the number of currently banned IPs and total bans since startup.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Add Two-Factor Authentication (Optional)
&lt;/h2&gt;

&lt;p&gt;For maximum security, add TOTP (Time-based One-Time Password) as a second factor. You'll need both your SSH key &lt;strong&gt;and&lt;/strong&gt; a code from your authenticator app.&lt;/p&gt;

&lt;p&gt;Install the Google Authenticator PAM module:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;libpam-google-authenticator &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the setup as your regular user (not root):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;google-authenticator
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Answer the prompts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Time-based tokens: &lt;strong&gt;yes&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Update .google_authenticator file: &lt;strong&gt;yes&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Disallow multiple uses: &lt;strong&gt;yes&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Rate limiting: &lt;strong&gt;yes&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Scan the QR code with your authenticator app (Google Authenticator, Authy, or any TOTP app). Save the emergency codes in a secure location.&lt;/p&gt;

&lt;p&gt;Configure PAM. Edit &lt;code&gt;/etc/pam.d/sshd&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Add at the end:&lt;/span&gt;
auth required pam_google_authenticator.so
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Update &lt;code&gt;sshd_config&lt;/code&gt; to require both key and TOTP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;AuthenticationMethods publickey,keyboard-interactive
ChallengeResponseAuthentication &lt;span class="nb"&gt;yes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Restart SSH:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart sshd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now login requires: SSH key → TOTP code. Two factors, both under your control.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning:&lt;/strong&gt; If you enable 2FA, make sure you have your emergency codes saved somewhere physically secure. Losing access to your authenticator app AND your emergency codes means you're locked out.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  6. Monitor SSH Access
&lt;/h2&gt;

&lt;p&gt;Hardening is only half the job. You also need to know who's connecting and when.&lt;/p&gt;

&lt;h3&gt;
  
  
  Check Active Sessions
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;who
&lt;/span&gt;w
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Review Recent Logins
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;last &lt;span class="nt"&gt;-20&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Watch Failed Attempts in Real Time
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo tail&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /var/log/auth.log | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"Failed&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;Accepted"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Set Up Login Notifications
&lt;/h3&gt;

&lt;p&gt;Add this to &lt;code&gt;/etc/profile.d/ssh-notify.sh&lt;/code&gt; to get notified on every successful login:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SSH_CONNECTION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nv"&gt;IP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SSH_CONNECTION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $1}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"SSH login: &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;whoami&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; from &lt;/span&gt;&lt;span class="nv"&gt;$IP&lt;/span&gt;&lt;span class="s2"&gt; at &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="se"&gt;\&lt;/span&gt;
        mail &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"SSH Login Alert: &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;hostname&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; your@email.com 2&amp;gt;/dev/null
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Make it executable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo chmod&lt;/span&gt; +x /etc/profile.d/ssh-notify.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This sends you an email every time someone logs in via SSH. If you see a login you don't recognize, investigate immediately.&lt;/p&gt;




&lt;h2&gt;
  
  
  Security Considerations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Key rotation.&lt;/strong&gt; Rotate your SSH keys annually. Generate a new pair, add the new public key, verify it works, then remove the old one from &lt;code&gt;authorized_keys&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agent forwarding risks.&lt;/strong&gt; If you enable SSH agent forwarding (&lt;code&gt;-A&lt;/code&gt;), anyone with root on the intermediate server can use your agent to authenticate to other servers. Use &lt;code&gt;ProxyJump&lt;/code&gt; instead of agent forwarding where possible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authorized keys audit.&lt;/strong&gt; Periodically check &lt;code&gt;~/.ssh/authorized_keys&lt;/code&gt; on all users. Remove any keys you don't recognize.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SSH config on the client side.&lt;/strong&gt; Create &lt;code&gt;~/.ssh/config&lt;/code&gt; entries for your servers to avoid typing ports and usernames:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ssh"&gt;&lt;code&gt;&lt;span class="k"&gt;Host&lt;/span&gt; byteguard
    &lt;span class="k"&gt;HostName&lt;/span&gt; your-server-ip
    &lt;span class="k"&gt;User&lt;/span&gt; yourusername
    &lt;span class="k"&gt;Port&lt;/span&gt; &lt;span class="m"&gt;2222&lt;/span&gt;
    &lt;span class="k"&gt;IdentityFile&lt;/span&gt; ~/.ssh/id_ed25519
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then connect with &lt;code&gt;ssh byteguard&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Troubleshooting
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Problem: Locked out after disabling password auth.&lt;/strong&gt;&lt;br&gt;
Cause: Key-based auth wasn't properly configured before disabling passwords.&lt;br&gt;
Fix: Access your server through your hosting provider's console (Hetzner has a web console). Re-enable &lt;code&gt;PasswordAuthentication yes&lt;/code&gt;, restart SSH, and fix your key setup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Problem: Connection refused after changing the port.&lt;/strong&gt;&lt;br&gt;
Cause: Firewall doesn't allow the new port, or you forgot to update the SSH config.&lt;br&gt;
Fix: Connect via console, check &lt;code&gt;ufw status&lt;/code&gt;, and ensure the new port is allowed. Verify &lt;code&gt;sshd_config&lt;/code&gt; has the right port.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Problem: "Too many authentication failures" error.&lt;/strong&gt;&lt;br&gt;
Cause: SSH agent is offering multiple keys before the right one.&lt;br&gt;
Fix: Specify the key explicitly: &lt;code&gt;ssh -i ~/.ssh/id_ed25519 -p 2222 user@server&lt;/code&gt;. Or set &lt;code&gt;IdentitiesOnly yes&lt;/code&gt; in your SSH client config.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Problem: 2FA prompt doesn't appear.&lt;/strong&gt;&lt;br&gt;
Cause: PAM module not loaded or &lt;code&gt;sshd_config&lt;/code&gt; not updated.&lt;br&gt;
Fix: Verify &lt;code&gt;auth required pam_google_authenticator.so&lt;/code&gt; is in &lt;code&gt;/etc/pam.d/sshd&lt;/code&gt;. Verify &lt;code&gt;ChallengeResponseAuthentication yes&lt;/code&gt; and &lt;code&gt;AuthenticationMethods publickey,keyboard-interactive&lt;/code&gt; in &lt;code&gt;sshd_config&lt;/code&gt;. Restart SSH.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Problem: Fail2Ban not banning IPs.&lt;/strong&gt;&lt;br&gt;
Cause: Wrong log path or port in jail config.&lt;br&gt;
Fix: Check &lt;code&gt;sudo fail2ban-client status sshd&lt;/code&gt; for errors. Verify &lt;code&gt;logpath&lt;/code&gt; matches your system (&lt;code&gt;/var/log/auth.log&lt;/code&gt; on Ubuntu/Debian). Verify the &lt;code&gt;port&lt;/code&gt; matches your custom SSH port.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;SSH is the front door to your server. Every hardening step here stacks — key-based auth eliminates password attacks, Fail2Ban blocks scanners, a custom port reduces noise, and 2FA adds a second factor even if your key is compromised.&lt;/p&gt;

&lt;p&gt;If you haven't done the basics yet, start with my &lt;a href="https://blog.byte-guard.net/harden-linux-vps-10-minutes/" rel="noopener noreferrer"&gt;VPS hardening guide&lt;/a&gt; — it covers UFW, unattended upgrades, and user setup alongside basic SSH config. For protecting other services, check out the &lt;a href="https://blog.byte-guard.net/fail2ban-setup-guide/" rel="noopener noreferrer"&gt;Fail2Ban setup guide&lt;/a&gt; and &lt;a href="https://blog.byte-guard.net/docker-security-best-practices/" rel="noopener noreferrer"&gt;Docker security best practices&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Your server is only as secure as its weakest entry point. Make SSH a strong one.&lt;/p&gt;

</description>
      <category>security</category>
      <category>linux</category>
      <category>ssh</category>
      <category>devops</category>
    </item>
    <item>
      <title>🚨 cPanel auth bypass affects ALL supported versions, patched today.

Fix: update to 11.110.0.97, 11.118.0.63, 11.126.0.54, or 11.132.0.29 depending on your branch. If you're running cPanel and haven't patched, assume the panel is exposed.

#CyberSecurity</title>
      <dc:creator>byteguard</dc:creator>
      <pubDate>Wed, 29 Apr 2026 15:11:35 +0000</pubDate>
      <link>https://dev.to/byte-guard/cpanel-auth-bypass-affects-all-supported-versions-patched-today-fix-update-to-11110097-3bif</link>
      <guid>https://dev.to/byte-guard/cpanel-auth-bypass-affects-all-supported-versions-patched-today-fix-update-to-11110097-3bif</guid>
      <description></description>
      <category>cybersecurity</category>
      <category>infosec</category>
      <category>news</category>
      <category>security</category>
    </item>
    <item>
      <title>Top 10 Self-Hosted Alternatives to SaaS Tools</title>
      <dc:creator>byteguard</dc:creator>
      <pubDate>Sun, 26 Apr 2026 15:40:09 +0000</pubDate>
      <link>https://dev.to/byte-guard/top-10-self-hosted-alternatives-to-saas-tools-508h</link>
      <guid>https://dev.to/byte-guard/top-10-self-hosted-alternatives-to-saas-tools-508h</guid>
      <description>&lt;h1&gt;
  
  
  Top 10 Self-Hosted Alternatives to SaaS Tools
&lt;/h1&gt;

&lt;p&gt;The average person pays for 5-10 SaaS subscriptions. Notion, Google Drive, LastPass, Slack, Trello — the monthly total adds up fast. Worse, your data lives on servers you don't control, subject to terms of service that change without notice and companies that can shut down, get acquired, or jack up prices any time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Self-hosted alternatives&lt;/strong&gt; let you run the same functionality on your own server. You own the data, you control the access, and the only recurring cost is your VPS. I've tested all of these on my Hetzner box — some I use daily, others I evaluated specifically for this list.&lt;/p&gt;

&lt;p&gt;Here's my top 10, ranked by usefulness and ease of deployment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick Comparison Table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;SaaS Tool&lt;/th&gt;
&lt;th&gt;Self-Hosted Alternative&lt;/th&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Docker?&lt;/th&gt;
&lt;th&gt;RAM Usage&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;LastPass / 1Password&lt;/td&gt;
&lt;td&gt;Vaultwarden&lt;/td&gt;
&lt;td&gt;Passwords&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;~30MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google Drive / Dropbox&lt;/td&gt;
&lt;td&gt;Nextcloud&lt;/td&gt;
&lt;td&gt;Cloud Storage&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;~256MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Notion&lt;/td&gt;
&lt;td&gt;Outline&lt;/td&gt;
&lt;td&gt;Wiki/Docs&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;~200MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Slack / Teams&lt;/td&gt;
&lt;td&gt;Matrix (Element)&lt;/td&gt;
&lt;td&gt;Chat&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;~300MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trello&lt;/td&gt;
&lt;td&gt;Planka&lt;/td&gt;
&lt;td&gt;Project Mgmt&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;~150MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google Analytics&lt;/td&gt;
&lt;td&gt;Umami&lt;/td&gt;
&lt;td&gt;Analytics&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;~100MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mailchimp&lt;/td&gt;
&lt;td&gt;Listmonk&lt;/td&gt;
&lt;td&gt;Newsletters&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;~50MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub&lt;/td&gt;
&lt;td&gt;Gitea&lt;/td&gt;
&lt;td&gt;Git Hosting&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;~100MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pingdom&lt;/td&gt;
&lt;td&gt;Uptime Kuma&lt;/td&gt;
&lt;td&gt;Monitoring&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;~80MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plex&lt;/td&gt;
&lt;td&gt;Jellyfin&lt;/td&gt;
&lt;td&gt;Media Server&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;~200MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Total if you ran all 10:&lt;/strong&gt; roughly 1.5GB RAM. A single VPS handles the lot.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Vaultwarden — Replace LastPass / 1Password
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What it does:&lt;/strong&gt; Full Bitwarden-compatible password manager. Works with all Bitwarden apps — browser extensions, mobile, desktop, CLI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why self-host it:&lt;/strong&gt; After the LastPass breaches, trusting a third party with your passwords is a harder sell. Vaultwarden encrypts everything client-side with your master password. Even if your server is compromised, the vault data is encrypted.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;vaultwarden&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vaultwarden/server:latest&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;vw_data:/data&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:8080:80"&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;SIGNUPS_ALLOWED=false&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I wrote a &lt;a href="https://dev.to/self-host-vaultwarden/"&gt;full Vaultwarden setup guide&lt;/a&gt; with backups and security hardening.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; This is the first thing I'd self-host. Everyone needs a password manager, and Vaultwarden is lightweight, reliable, and battle-tested.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Nextcloud — Replace Google Drive / Dropbox
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What it does:&lt;/strong&gt; Cloud storage, file sync, calendar, contacts, document editing, and more. It's the Swiss Army knife of self-hosting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why self-host it:&lt;/strong&gt; Google scans your Drive files for ad targeting. Dropbox has a 3-device limit on the free tier. Nextcloud gives you unlimited storage (limited only by your disk), unlimited devices, and zero data mining.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;nextcloud&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nextcloud:latest&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;nc_data:/var/www/html&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:8081:80"&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MYSQL_HOST=db&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MYSQL_DATABASE=nextcloud&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MYSQL_USER=nextcloud&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MYSQL_PASSWORD=&amp;lt;SECURE_PASSWORD&amp;gt;&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Heads up:&lt;/strong&gt; Nextcloud can be resource-hungry, especially with many users. Give it at least 512MB RAM and enable Redis caching for a responsive experience. I'll have a &lt;a href="https://dev.to/self-host-nextcloud-docker/"&gt;dedicated Nextcloud guide&lt;/a&gt; covering the full stack with MariaDB and performance tuning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; Essential if you want to own your files. The mobile apps and desktop sync clients are solid.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Outline — Replace Notion
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What it does:&lt;/strong&gt; A fast, beautiful wiki and knowledge base. Markdown-based, real-time collaboration, search, and nested document trees.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why self-host it:&lt;/strong&gt; Notion stores everything on their servers in a proprietary format. Exporting is possible but lossy. Outline uses plain Markdown — your documents are portable by default.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;outline&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker.getoutline.com/outlinewiki/outline:latest&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;DATABASE_URL=postgres://outline:pass@db:5432/outline&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;REDIS_URL=redis://redis:6379&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;URL=https://wiki.yourdomain.com&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;SECRET_KEY=&amp;lt;GENERATE_WITH_openssl_rand_-hex_32&amp;gt;&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;db&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;redis&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Heads up:&lt;/strong&gt; Outline requires PostgreSQL and Redis — it's not as lightweight as some tools on this list. It also needs an auth provider (Google, Slack, OIDC, or email).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; Best Notion alternative I've tested. The editor is fast, the search is excellent, and the API is well-documented.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Matrix (Element) — Replace Slack / Teams
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What it does:&lt;/strong&gt; Decentralized, encrypted chat. Matrix is the protocol; Element is the most popular client. Supports rooms, DMs, threads, file sharing, voice/video calls, and bridges to other platforms (Slack, Discord, Telegram).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why self-host it:&lt;/strong&gt; Slack's free tier limits message history. Teams requires Microsoft 365. Matrix gives you unlimited history, end-to-end encryption, and federation — your server can talk to other Matrix servers.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;synapse&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;matrixdotorg/synapse:latest&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;synapse_data:/data&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;SYNAPSE_SERVER_NAME=chat.yourdomain.com&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;SYNAPSE_REPORT_STATS=no&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:8008:8008"&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Heads up:&lt;/strong&gt; Synapse (the reference server) is RAM-hungry — expect 300MB+ even with few users. For a lighter alternative, check out Conduit (written in Rust, much lower resource usage but less mature).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; Excellent for teams and communities. The bridge system lets you consolidate all your messaging into one client.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Planka — Replace Trello
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What it does:&lt;/strong&gt; Kanban boards with lists, cards, labels, due dates, attachments, and user management. Clean UI, fast, and does exactly what Trello does without the feature bloat.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why self-host it:&lt;/strong&gt; Trello's free tier now limits attachments, Power-Ups, and board views. Planka has no artificial limits.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;planka&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/plankanban/planka:latest&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;planka_data:/app/public/user-avatars&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;planka_attachments:/app/private/attachments&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:1337:1337"&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;DATABASE_URL=postgresql://planka:pass@db:5432/planka&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;SECRET_KEY=&amp;lt;YOUR_SECRET&amp;gt;&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;BASE_URL=https://tasks.yourdomain.com&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; If you use Trello just for kanban boards (and most people do), Planka is a drop-in replacement. Lightweight and clean.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. Umami — Replace Google Analytics
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What it does:&lt;/strong&gt; Privacy-focused web analytics. Page views, referrers, devices, countries — all the metrics that matter, without tracking cookies or personal data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why self-host it:&lt;/strong&gt; Google Analytics collects far more data than you need and raises GDPR concerns. Umami is GDPR-compliant by default because it doesn't use cookies or collect personal data. No cookie banners required.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;umami&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/umami-software/umami:postgresql-latest&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:3000:3000"&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;DATABASE_URL=postgresql://umami:pass@db:5432/umami&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;APP_SECRET=&amp;lt;YOUR_SECRET&amp;gt;&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; I switched to Umami for ByteGuard. The dashboard is beautiful, the tracking script is 2KB, and it tells me everything I need to know without the guilt of tracking my readers.&lt;/p&gt;




&lt;h2&gt;
  
  
  7. Listmonk — Replace Mailchimp
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What it does:&lt;/strong&gt; Self-hosted newsletter and mailing list manager. Subscriber management, email templates, campaigns, analytics, and bounce handling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why self-host it:&lt;/strong&gt; Mailchimp's free tier keeps shrinking. Listmonk handles thousands of subscribers with a single binary and PostgreSQL database. Pair it with Amazon SES or a transactional email service for sending.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;listmonk&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;listmonk/listmonk:latest&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:9000:9000"&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;LISTMONK_app__address=0.0.0.0:9000&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;LISTMONK_db__host=db&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;LISTMONK_db__port=5432&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;LISTMONK_db__user=listmonk&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;LISTMONK_db__password=&amp;lt;YOUR_PASSWORD&amp;gt;&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;LISTMONK_db__database=listmonk&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; Best self-hosted newsletter tool by far. The UI is clean, the templating engine is powerful, and the performance is outstanding.&lt;/p&gt;




&lt;h2&gt;
  
  
  8. Gitea — Replace GitHub
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What it does:&lt;/strong&gt; Lightweight Git hosting with pull requests, issues, CI/CD (Gitea Actions), container registry, and package management.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why self-host it:&lt;/strong&gt; GitHub is great, but your code is on Microsoft's servers. For private projects, internal tools, or just principle — self-hosted Git gives you full control.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;gitea&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gitea/gitea:latest&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;gitea_data:/data&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:3001:3000"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2222:22"&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;USER_UID=1000&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;USER_GID=1000&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; Gitea is what I'd recommend over GitLab for most self-hosters. GitLab needs 4GB+ RAM. Gitea runs on 100MB. For small teams and personal projects, it's perfect.&lt;/p&gt;




&lt;h2&gt;
  
  
  9. Uptime Kuma — Replace Pingdom / UptimeRobot
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What it does:&lt;/strong&gt; Monitors your services and alerts you when they go down. HTTP, TCP, DNS, ping, Docker container, and more. Beautiful dashboard and public status pages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why self-host it:&lt;/strong&gt; UptimeRobot's free tier limits you to 50 monitors with 5-minute intervals. Uptime Kuma has no limits and checks as frequently as every 20 seconds.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;uptime-kuma&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;louislam/uptime-kuma:1&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;kuma_data:/app/data&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:3002:3001"&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I run Uptime Kuma at &lt;a href="https://status.byte-guard.net" rel="noopener noreferrer"&gt;status.byte-guard.net&lt;/a&gt; — I'll have a &lt;a href="https://dev.to/uptime-kuma-setup-guide/"&gt;full setup guide&lt;/a&gt; covering notifications, status pages, and advanced monitors.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; One of the best self-hosted tools, period. Setup takes 2 minutes, the UI is gorgeous, and notifications work reliably.&lt;/p&gt;




&lt;h2&gt;
  
  
  10. Jellyfin — Replace Plex
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What it does:&lt;/strong&gt; Media server for movies, TV shows, music, and photos. Streams to your devices with transcoding support.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why self-host it:&lt;/strong&gt; Plex increasingly pushes a paid "Plex Pass" and ad-supported content. Jellyfin is fully free, open-source, and doesn't phone home.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;jellyfin&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jellyfin/jellyfin:latest&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;jellyfin_config:/config&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;jellyfin_cache:/cache&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/path/to/media:/media:ro&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:8096:8096"&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Heads up:&lt;/strong&gt; Transcoding is CPU-intensive. If you plan to stream outside your network, consider a VPS with decent CPU or pass through a GPU. For local network streaming, any hardware works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; If you have a media collection, Jellyfin is the way to go. The clients are available on every platform, and the community is active.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where to Host All This
&lt;/h2&gt;

&lt;p&gt;You'll need a VPS with at least 4GB RAM to run several of these simultaneously. I use &lt;a href="https://www.hetzner.com/cloud/" rel="noopener noreferrer"&gt;Hetzner&lt;/a&gt; — the CPX22 gives you 4 vCPU, 8GB RAM, and 80GB SSD for about €5/month. Check my &lt;a href="https://dev.to/best-vps-self-hosting-hetzner-contabo-vultr/"&gt;VPS comparison&lt;/a&gt; if you're shopping around.&lt;/p&gt;

&lt;p&gt;A reverse proxy handles SSL and routing for all your services. I use &lt;a href="https://dev.to/nginx-proxy-manager-vs-traefik-vs-caddy/"&gt;Nginx Proxy Manager&lt;/a&gt; — one proxy host per service, all on the same server.&lt;/p&gt;

&lt;p&gt;Follow &lt;a href="https://dev.to/docker-security-best-practices/"&gt;Docker security best practices&lt;/a&gt; when running this many containers. Isolate networks, run as non-root where possible, and keep images updated.&lt;/p&gt;




&lt;h2&gt;
  
  
  Troubleshooting
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Problem: Container can't connect to the database.&lt;/strong&gt;&lt;br&gt;
Cause: The database container isn't on the same Docker network, or the credentials don't match.&lt;br&gt;
Fix: Put all services in the same Docker Compose file or use an external Docker network. Double-check usernames and passwords match between app and database configs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Problem: Service works locally but not through the reverse proxy.&lt;/strong&gt;&lt;br&gt;
Cause: Proxy forwarding to wrong port or hostname.&lt;br&gt;
Fix: Use the container name as the hostname and the container's internal port (not the mapped host port).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Problem: Running out of RAM with multiple services.&lt;/strong&gt;&lt;br&gt;
Cause: Too many services for your VPS tier.&lt;br&gt;
Fix: Check usage with &lt;code&gt;docker stats&lt;/code&gt;. Prioritize — not everything needs to run 24/7. Consider upgrading your VPS or splitting services across two servers.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Self-hosting isn't all-or-nothing. Start with one or two tools that replace paid subscriptions, and expand from there. Vaultwarden and Uptime Kuma are my "install on every server" picks. Nextcloud and Gitea come next if you want file storage and Git.&lt;/p&gt;

&lt;p&gt;The total cost of running all 10 is one VPS bill — less than a single Notion team subscription.&lt;/p&gt;

&lt;p&gt;What did I miss? If there's a self-hosted tool you swear by, I'd love to hear about it.&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>opensource</category>
      <category>docker</category>
      <category>privacy</category>
    </item>
    <item>
      <title>How to Self-Host Vaultwarden with Docker</title>
      <dc:creator>byteguard</dc:creator>
      <pubDate>Sun, 26 Apr 2026 15:39:12 +0000</pubDate>
      <link>https://dev.to/byte-guard/how-to-self-host-vaultwarden-with-docker-32j6</link>
      <guid>https://dev.to/byte-guard/how-to-self-host-vaultwarden-with-docker-32j6</guid>
      <description>&lt;h1&gt;
  
  
  How to Self-Host Vaultwarden with Docker
&lt;/h1&gt;

&lt;p&gt;Everyone needs a password manager. If you're still reusing passwords or storing them in a browser's built-in manager, you're one data breach away from a very bad week. Bitwarden is the best open-source option, but the official server is heavy — it needs MSSQL and multiple containers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Vaultwarden&lt;/strong&gt; solves this. It's a lightweight, community-built server that's fully compatible with all Bitwarden clients — browser extensions, mobile apps, desktop apps, CLI. It runs in a single Docker container, uses SQLite by default, and needs about 30MB of RAM. I self-host Vaultwarden with Docker on the same VPS that runs this blog, and it handles my entire password vault without breaking a sweat.&lt;/p&gt;

&lt;p&gt;By the end of this guide, you'll have a working Vaultwarden instance with SSL, auto-backups, and all the security settings configured properly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A Linux VPS with Docker and Docker Compose (&lt;a href="https://dev.to/building-byteguard-from-scratch-hetzner-vps/"&gt;here's how I set mine up&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;A domain name pointed at your server (e.g., &lt;code&gt;vault.yourdomain.com&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;A reverse proxy with SSL — &lt;a href="https://dev.to/nginx-proxy-manager-vs-traefik-vs-caddy/"&gt;Nginx Proxy Manager&lt;/a&gt; or similar&lt;/li&gt;
&lt;li&gt;SSH access to your server (&lt;a href="https://dev.to/harden-linux-vps-10-minutes/"&gt;hardened, ideally&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Deploy Vaultwarden with Docker Compose
&lt;/h2&gt;

&lt;p&gt;Create a directory for Vaultwarden:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /opt/vaultwarden
&lt;span class="nb"&gt;cd&lt;/span&gt; /opt/vaultwarden
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Generate an admin token. This secures the admin panel:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openssl rand &lt;span class="nt"&gt;-base64&lt;/span&gt; 48
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Save that token — you'll need it in the compose file.&lt;/p&gt;

&lt;p&gt;Create the compose file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# docker-compose.yml&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;vaultwarden&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vaultwarden/server:latest&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vaultwarden&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;DOMAIN=https://vault.yourdomain.com&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ADMIN_TOKEN=&amp;lt;YOUR_GENERATED_TOKEN&amp;gt;&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;SIGNUPS_ALLOWED=false&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;INVITATIONS_ALLOWED=true&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;SHOW_PASSWORD_HINT=false&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ROCKET_LIMITS={json=10485760}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;LOG_FILE=/data/vaultwarden.log&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;LOG_LEVEL=warn&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;vw_data:/data&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:8080:80"&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;vw_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key environment variables explained:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;DOMAIN&lt;/code&gt;&lt;/strong&gt; — Must match your actual domain with &lt;code&gt;https://&lt;/code&gt;. Required for WebSocket notifications and email links.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ADMIN_TOKEN&lt;/code&gt;&lt;/strong&gt; — Protects the &lt;code&gt;/admin&lt;/code&gt; panel. Without this, anyone can access admin settings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;SIGNUPS_ALLOWED=false&lt;/code&gt;&lt;/strong&gt; — Disables public registration. You'll create your account first, then lock it down.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;SHOW_PASSWORD_HINT=false&lt;/code&gt;&lt;/strong&gt; — Password hints are a security risk. Disable them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ROCKET_LIMITS&lt;/code&gt;&lt;/strong&gt; — Increases the upload limit to 10MB for file attachments.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; The container binds to &lt;code&gt;127.0.0.1:8080&lt;/code&gt;, not &lt;code&gt;0.0.0.0&lt;/code&gt;. This means it's only accessible from localhost — your reverse proxy handles external traffic and SSL.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Start the container:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Set Up the Reverse Proxy
&lt;/h2&gt;

&lt;p&gt;You need SSL for Vaultwarden — the Bitwarden clients refuse to connect over plain HTTP, and you should never send passwords unencrypted.&lt;/p&gt;

&lt;h3&gt;
  
  
  Using Nginx Proxy Manager
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Log into your NPM dashboard&lt;/li&gt;
&lt;li&gt;Add a new Proxy Host:

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Domain:&lt;/strong&gt; &lt;code&gt;vault.yourdomain.com&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forward Hostname:&lt;/strong&gt; &lt;code&gt;vaultwarden&lt;/code&gt; (or &lt;code&gt;127.0.0.1&lt;/code&gt; if not on the same Docker network)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forward Port:&lt;/strong&gt; &lt;code&gt;80&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enable SSL:&lt;/strong&gt; Yes, request a new Let's Encrypt certificate&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Force SSL:&lt;/strong&gt; Yes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WebSocket Support:&lt;/strong&gt; Yes (required for live sync)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Using Caddy
&lt;/h3&gt;

&lt;p&gt;Add to your Caddyfile:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;vault.yourdomain.com&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;reverse_proxy&lt;/span&gt; &lt;span class="nf"&gt;vaultwarden&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;80&lt;/span&gt;
&lt;span class="err"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Caddy handles SSL automatically.&lt;/p&gt;




&lt;h2&gt;
  
  
  Create Your Account
&lt;/h2&gt;

&lt;p&gt;With signups still technically allowed (we'll disable them after), open &lt;code&gt;https://vault.yourdomain.com&lt;/code&gt; in your browser. Click "Create Account" and set up your master password.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose your master password carefully.&lt;/strong&gt; This is the one password you need to memorize. Make it long (4+ random words), unique, and never used anywhere else. Vaultwarden encrypts your vault with this password on the client side — even if someone compromises the server, they can't read your passwords without the master password.&lt;/p&gt;

&lt;p&gt;After creating your account, immediately disable signups. Edit the compose file and verify &lt;code&gt;SIGNUPS_ALLOWED=false&lt;/code&gt;, then restart:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose down &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you need to add family members or team members later, use the admin panel to send invitations.&lt;/p&gt;




&lt;h2&gt;
  
  
  Configure the Admin Panel
&lt;/h2&gt;

&lt;p&gt;Access the admin panel at &lt;code&gt;https://vault.yourdomain.com/admin&lt;/code&gt; and enter your admin token.&lt;/p&gt;

&lt;p&gt;Here you can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Manage users&lt;/strong&gt; — delete accounts, disable accounts, verify email addresses&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Configure SMTP&lt;/strong&gt; — for email verification and password reset (optional but recommended)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Adjust security settings&lt;/strong&gt; — 2FA enforcement, password length requirements&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;View diagnostics&lt;/strong&gt; — server version, database size, environment status&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Optional: SMTP for Email
&lt;/h3&gt;

&lt;p&gt;If you want email verification and password reset capability, configure SMTP in the admin panel or via environment variables:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;SMTP_HOST=smtp.example.com&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;SMTP_FROM=vault@yourdomain.com&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;SMTP_PORT=587&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;SMTP_SECURITY=starttls&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;SMTP_USERNAME=&amp;lt;YOUR_SMTP_USER&amp;gt;&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;SMTP_PASSWORD=&amp;lt;YOUR_SMTP_PASSWORD&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Set Up Automatic Backups
&lt;/h2&gt;

&lt;p&gt;Your password vault is critical data. Losing it would be catastrophic. Set up automated backups with a simple cron job.&lt;/p&gt;

&lt;p&gt;Create the backup script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /opt/vaultwarden/backup.sh &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;SCRIPT&lt;/span&gt;&lt;span class="sh"&gt;'
#!/bin/bash
BACKUP_DIR="/opt/vaultwarden/backups"
TIMESTAMP=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%Y%m%d_%H%M%S&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;
mkdir -p "&lt;/span&gt;&lt;span class="nv"&gt;$BACKUP_DIR&lt;/span&gt;&lt;span class="sh"&gt;"

# Copy the SQLite database (safely, with WAL checkpoint)
docker exec vaultwarden sqlite3 /data/db.sqlite3 ".backup '/data/backup_&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TIMESTAMP&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;.sqlite3'"
docker cp vaultwarden:/data/backup_&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TIMESTAMP&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;.sqlite3 "&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BACKUP_DIR&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;/db_&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TIMESTAMP&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;.sqlite3"
docker exec vaultwarden rm "/data/backup_&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TIMESTAMP&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;.sqlite3"

# Copy attachments and other data
docker cp vaultwarden:/data/attachments "&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BACKUP_DIR&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;/attachments_&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TIMESTAMP&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;" 2&amp;gt;/dev/null

# Compress
tar -czf "&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BACKUP_DIR&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;/vaultwarden_&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TIMESTAMP&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;.tar.gz" &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="sh"&gt;
    -C "&lt;/span&gt;&lt;span class="nv"&gt;$BACKUP_DIR&lt;/span&gt;&lt;span class="sh"&gt;" "db_&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TIMESTAMP&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;.sqlite3" "attachments_&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TIMESTAMP&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;" 2&amp;gt;/dev/null

# Cleanup loose files
rm -f "&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BACKUP_DIR&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;/db_&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TIMESTAMP&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;.sqlite3"
rm -rf "&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BACKUP_DIR&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;/attachments_&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TIMESTAMP&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"

# Keep only last 7 backups
ls -t "&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BACKUP_DIR&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"/vaultwarden_*.tar.gz | tail -n +8 | xargs rm -f 2&amp;gt;/dev/null

echo "Backup completed: vaultwarden_&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TIMESTAMP&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;.tar.gz"
&lt;/span&gt;&lt;span class="no"&gt;SCRIPT
&lt;/span&gt;&lt;span class="nb"&gt;chmod&lt;/span&gt; +x /opt/vaultwarden/backup.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Schedule it to run daily:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;crontab &lt;span class="nt"&gt;-e&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;0 3 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; /opt/vaultwarden/backup.sh &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /var/log/vaultwarden-backup.log 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; Use SQLite's &lt;code&gt;.backup&lt;/code&gt; command, not a raw file copy. Copying a SQLite database while it's being written to can produce a corrupted backup. The &lt;code&gt;.backup&lt;/code&gt; command handles this safely.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Connect Your Devices
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Browser Extension
&lt;/h3&gt;

&lt;p&gt;Install the Bitwarden browser extension (Chrome, Firefox, Safari, Edge). Before logging in:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Click the gear icon in the extension&lt;/li&gt;
&lt;li&gt;Under "Self-hosted environment," enter &lt;code&gt;https://vault.yourdomain.com&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Save and log in with your credentials&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Mobile App
&lt;/h3&gt;

&lt;p&gt;Install the Bitwarden app (iOS/Android). Tap the gear icon on the login screen, enter your self-hosted URL, save, and log in.&lt;/p&gt;

&lt;h3&gt;
  
  
  Desktop App
&lt;/h3&gt;

&lt;p&gt;Download from &lt;a href="https://bitwarden.com/download/" rel="noopener noreferrer"&gt;bitwarden.com/download&lt;/a&gt;. Same process — set the server URL before logging in.&lt;/p&gt;

&lt;h3&gt;
  
  
  CLI
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; @bitwarden/cli
bw config server https://vault.yourdomain.com
bw login
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CLI is useful for scripting — you can generate passwords, export vaults, and manage entries programmatically.&lt;/p&gt;




&lt;h2&gt;
  
  
  Enable Two-Factor Authentication
&lt;/h2&gt;

&lt;p&gt;After logging in to your web vault, go to &lt;strong&gt;Settings → Security → Two-step Login&lt;/strong&gt;. Enable at least one method:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Authenticator app&lt;/strong&gt; (TOTP) — works with any authenticator app. This should be your minimum.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;YubiKey&lt;/strong&gt; — hardware key support. Best security if you have one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Email&lt;/strong&gt; — sends a code to your email. Better than nothing, but TOTP is preferred.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning:&lt;/strong&gt; If you lose your 2FA device and your recovery code, you're locked out of your vault permanently. Vaultwarden encrypts everything client-side — there's no "forgot my 2FA" reset. Save your recovery code in a physically secure location.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Security Considerations
&lt;/h2&gt;

&lt;p&gt;Self-hosting a password manager means the security is entirely your responsibility.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keep Vaultwarden updated.&lt;/strong&gt; Check for new releases monthly: &lt;code&gt;docker compose pull &amp;amp;&amp;amp; docker compose up -d&lt;/code&gt;. Security patches in a password manager are critical.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restrict admin panel access.&lt;/strong&gt; Consider disabling the admin panel entirely after initial setup by removing the &lt;code&gt;ADMIN_TOKEN&lt;/code&gt; variable. Or restrict it by IP using your reverse proxy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't expose port 8080.&lt;/strong&gt; The compose file binds to &lt;code&gt;127.0.0.1&lt;/code&gt; specifically so the container isn't directly accessible. All traffic should go through your SSL-terminating reverse proxy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server-side encryption isn't the whole picture.&lt;/strong&gt; Vaultwarden stores your vault encrypted with your master password. The server never sees the plaintext. But if someone gets root on your server, they could modify the Vaultwarden binary to capture passwords as you type them. Keep your &lt;a href="https://dev.to/harden-linux-vps-10-minutes/"&gt;server hardened&lt;/a&gt; and &lt;a href="https://dev.to/docker-security-best-practices/"&gt;Docker locked down&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Offline access.&lt;/strong&gt; Bitwarden clients cache an encrypted copy of your vault locally. If your server goes down, you can still access passwords. But new entries won't sync until the server is back.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Troubleshooting
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Problem: Bitwarden client says "Unable to connect to server."&lt;/strong&gt;&lt;br&gt;
Cause: Wrong server URL, SSL issue, or Vaultwarden isn't running.&lt;br&gt;
Fix: Verify the URL includes &lt;code&gt;https://&lt;/code&gt; and matches exactly. Check &lt;code&gt;docker compose logs vaultwarden&lt;/code&gt; for errors. Test with &lt;code&gt;curl -I https://vault.yourdomain.com&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Problem: "Not a valid Bitwarden server" error.&lt;/strong&gt;&lt;br&gt;
Cause: The &lt;code&gt;DOMAIN&lt;/code&gt; environment variable doesn't match the URL you're accessing.&lt;br&gt;
Fix: Ensure &lt;code&gt;DOMAIN&lt;/code&gt; in docker-compose.yml matches the URL in your client settings, including the protocol (&lt;code&gt;https://&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Problem: WebSocket notifications not working (items don't sync instantly).&lt;/strong&gt;&lt;br&gt;
Cause: Your reverse proxy isn't forwarding WebSocket connections.&lt;br&gt;
Fix: In NPM, enable "WebSocket Support" for the proxy host. In Caddy, it works automatically. In Nginx manually, add &lt;code&gt;proxy_set_header Upgrade $http_upgrade;&lt;/code&gt; and &lt;code&gt;proxy_set_header Connection "upgrade";&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Problem: Attachments fail to upload.&lt;/strong&gt;&lt;br&gt;
Cause: Default request body size limit in your reverse proxy.&lt;br&gt;
Fix: Increase the limit. In NPM, add &lt;code&gt;client_max_body_size 25M;&lt;/code&gt; to custom Nginx configuration. In Caddy, it's unlimited by default.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Problem: Admin panel returns 404.&lt;/strong&gt;&lt;br&gt;
Cause: &lt;code&gt;ADMIN_TOKEN&lt;/code&gt; is not set or was removed.&lt;br&gt;
Fix: Add &lt;code&gt;ADMIN_TOKEN&lt;/code&gt; to your environment variables and restart the container.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;You now have a self-hosted, Bitwarden-compatible password manager running on your own server. No subscription fees, no data on someone else's infrastructure, and full control over your security.&lt;/p&gt;

&lt;p&gt;The total resource usage is minimal — about 30MB of RAM and negligible CPU. It runs comfortably alongside other services on even a small VPS.&lt;/p&gt;

&lt;p&gt;From here, I'd recommend:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Setting up &lt;a href="https://dev.to/uptime-kuma-setup-guide/"&gt;Uptime Kuma&lt;/a&gt; to monitor your Vaultwarden instance (it's critical infrastructure now)&lt;/li&gt;
&lt;li&gt;Reviewing my &lt;a href="https://dev.to/docker-security-best-practices/"&gt;Docker security practices&lt;/a&gt; to lock down the container&lt;/li&gt;
&lt;li&gt;Adding &lt;a href="https://dev.to/fail2ban-setup-guide/"&gt;Fail2Ban&lt;/a&gt; to protect against brute-force login attempts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you need a VPS for this, I run my entire stack on &lt;a href="https://www.hetzner.com/cloud/" rel="noopener noreferrer"&gt;Hetzner&lt;/a&gt; — check my &lt;a href="https://dev.to/best-vps-self-hosting-hetzner-contabo-vultr/"&gt;VPS comparison&lt;/a&gt; for options.&lt;/p&gt;

</description>
      <category>vaultwarden</category>
      <category>selfhosted</category>
      <category>docker</category>
      <category>security</category>
    </item>
    <item>
      <title>Setting Up WireGuard VPN on Your Own Server</title>
      <dc:creator>byteguard</dc:creator>
      <pubDate>Sun, 26 Apr 2026 15:39:11 +0000</pubDate>
      <link>https://dev.to/byte-guard/setting-up-wireguard-vpn-on-your-own-server-239h</link>
      <guid>https://dev.to/byte-guard/setting-up-wireguard-vpn-on-your-own-server-239h</guid>
      <description>&lt;h1&gt;
  
  
  Setting Up WireGuard VPN on Your Own Server
&lt;/h1&gt;

&lt;p&gt;Most commercial VPN providers promise "no logs" and "military-grade encryption" while routing your traffic through servers you don't control. You're trusting a marketing claim with your entire internet activity. There's a better option: run your own VPN.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;WireGuard VPS setup&lt;/strong&gt; gives you full control over your traffic, your logs, and your encryption keys. WireGuard is faster than OpenVPN, easier to configure than IPSec, and the entire codebase is around 4,000 lines — small enough to audit. I run WireGuard on the same Hetzner VPS that hosts this blog, and the setup took me about 15 minutes.&lt;/p&gt;

&lt;p&gt;By the end of this post, you'll have a working WireGuard VPN server with client configs for your phone and laptop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A Linux VPS with a public IP (I use &lt;a href="https://www.hetzner.com/cloud/" rel="noopener noreferrer"&gt;Hetzner&lt;/a&gt; — &lt;a href="https://dev.to/building-byteguard-from-scratch-hetzner-vps/"&gt;here's how I set mine up&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;SSH access to your server (&lt;a href="https://dev.to/harden-linux-vps-10-minutes/"&gt;hardened, ideally&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Docker and Docker Compose installed&lt;/li&gt;
&lt;li&gt;A device to connect from (Linux, macOS, Windows, iOS, or Android)&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Why WireGuard Over OpenVPN?
&lt;/h2&gt;

&lt;p&gt;I ran OpenVPN for years before switching. Here's why WireGuard wins for self-hosters:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;WireGuard&lt;/th&gt;
&lt;th&gt;OpenVPN&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Codebase&lt;/td&gt;
&lt;td&gt;~4,000 lines&lt;/td&gt;
&lt;td&gt;~100,000 lines&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Protocol&lt;/td&gt;
&lt;td&gt;UDP only&lt;/td&gt;
&lt;td&gt;TCP or UDP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Speed&lt;/td&gt;
&lt;td&gt;Near wire speed&lt;/td&gt;
&lt;td&gt;20-30% overhead&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Connection time&lt;/td&gt;
&lt;td&gt;~100ms&lt;/td&gt;
&lt;td&gt;5-10 seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Config complexity&lt;/td&gt;
&lt;td&gt;Simple key pairs&lt;/td&gt;
&lt;td&gt;Certificates + PKI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kernel integration&lt;/td&gt;
&lt;td&gt;Built into Linux 5.6+&lt;/td&gt;
&lt;td&gt;Userspace&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;WireGuard is in the Linux kernel since 5.6. It's not a third-party module — it's part of the operating system. That matters for performance and long-term support.&lt;/p&gt;




&lt;h2&gt;
  
  
  Install WireGuard with Docker Compose
&lt;/h2&gt;

&lt;p&gt;You could install WireGuard directly on the host, but I prefer Docker for consistency with the rest of my stack. The &lt;code&gt;linuxserver/wireguard&lt;/code&gt; image handles key generation and config templating automatically.&lt;/p&gt;

&lt;p&gt;Create a directory for your WireGuard config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /opt/wireguard
&lt;span class="nb"&gt;cd&lt;/span&gt; /opt/wireguard
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create the compose file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# docker-compose.yml&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;wireguard&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;lscr.io/linuxserver/wireguard:latest&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;wireguard&lt;/span&gt;
    &lt;span class="na"&gt;cap_add&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;NET_ADMIN&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;SYS_MODULE&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;PUID=1000&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;PGID=1000&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;TZ=Europe/Helsinki&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;SERVERURL=&amp;lt;YOUR_SERVER_IP&amp;gt;&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;SERVERPORT=51820&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;PEERS=laptop,phone,tablet&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;PEERDNS=1.1.1.1&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;INTERNAL_SUBNET=10.13.13.0&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ALLOWEDIPS=0.0.0.0/0&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./config:/config&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/lib/modules:/lib/modules&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;51820:51820/udp&lt;/span&gt;
    &lt;span class="na"&gt;sysctls&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;net.ipv4.conf.all.src_valid_mark=1&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replace &lt;code&gt;&amp;lt;YOUR_SERVER_IP&amp;gt;&lt;/code&gt; with your VPS's public IP address. The &lt;code&gt;PEERS&lt;/code&gt; variable creates client configs automatically — one for each name you list.&lt;/p&gt;

&lt;p&gt;Start the container:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;WireGuard generates all the keys and configs on first boot. Check the logs to confirm:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose logs wireguard
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see lines like &lt;code&gt;[#] ip link add wg0 type wireguard&lt;/code&gt; and config generation for each peer.&lt;/p&gt;




&lt;h2&gt;
  
  
  Configure Your Firewall for WireGuard
&lt;/h2&gt;

&lt;p&gt;If you followed my &lt;a href="https://dev.to/harden-linux-vps-10-minutes/"&gt;VPS hardening guide&lt;/a&gt;, you have UFW running. You need to allow WireGuard's UDP port:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow 51820/udp comment &lt;span class="s2"&gt;"WireGuard VPN"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You also need IP forwarding enabled. Check if it's already on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sysctl net.ipv4.ip_forward
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the output is &lt;code&gt;0&lt;/code&gt;, enable it permanently:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"net.ipv4.ip_forward=1"&lt;/span&gt; | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; /etc/sysctl.d/99-wireguard.conf
&lt;span class="nb"&gt;sudo &lt;/span&gt;sysctl &lt;span class="nt"&gt;-p&lt;/span&gt; /etc/sysctl.d/99-wireguard.conf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; The Docker container sets &lt;code&gt;src_valid_mark&lt;/code&gt; via sysctls, but IP forwarding must be enabled on the host. Without it, traffic enters the VPN tunnel but never leaves.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Grab Your Client Configs
&lt;/h2&gt;

&lt;p&gt;The container auto-generated configs for each peer you listed. Find them in the config directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; /opt/wireguard/config/peer_laptop/
&lt;span class="nb"&gt;ls&lt;/span&gt; /opt/wireguard/config/peer_phone/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each peer folder contains:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;peer_laptop.conf&lt;/code&gt; — the full WireGuard config file&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;peer_laptop.png&lt;/code&gt; — a QR code for mobile setup&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;View the laptop config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /opt/wireguard/config/peer_laptop/peer_laptop.conf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expected output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Interface]&lt;/span&gt;
&lt;span class="py"&gt;Address&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;10.13.13.2/32&lt;/span&gt;
&lt;span class="py"&gt;PrivateKey&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;auto-generated-private-key&amp;gt;&lt;/span&gt;
&lt;span class="py"&gt;ListenPort&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;51820&lt;/span&gt;
&lt;span class="py"&gt;DNS&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;1.1.1.1&lt;/span&gt;

&lt;span class="nn"&gt;[Peer]&lt;/span&gt;
&lt;span class="py"&gt;PublicKey&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;server-public-key&amp;gt;&lt;/span&gt;
&lt;span class="py"&gt;PresharedKey&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;auto-generated-psk&amp;gt;&lt;/span&gt;
&lt;span class="py"&gt;Endpoint&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;YOUR_SERVER_IP&amp;gt;:51820&lt;/span&gt;
&lt;span class="py"&gt;AllowedIPs&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;0.0.0.0/0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Connect Your Devices
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Linux
&lt;/h3&gt;

&lt;p&gt;Install the WireGuard client:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;wireguard
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Copy the config file to your client machine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;scp user@&amp;lt;YOUR_SERVER_IP&amp;gt;:/opt/wireguard/config/peer_laptop/peer_laptop.conf /etc/wireguard/wg0.conf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Start the tunnel:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;wg-quick up wg0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify you're connected:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl ifconfig.me
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This should return your VPS IP, not your home IP. To disconnect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;wg-quick down wg0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To start WireGuard automatically on boot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable &lt;/span&gt;wg-quick@wg0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  macOS and Windows
&lt;/h3&gt;

&lt;p&gt;Download the official WireGuard app from &lt;a href="https://www.wireguard.com/install/" rel="noopener noreferrer"&gt;wireguard.com/install&lt;/a&gt;. Import the &lt;code&gt;.conf&lt;/code&gt; file and click "Activate." That's it.&lt;/p&gt;

&lt;h3&gt;
  
  
  iOS and Android
&lt;/h3&gt;

&lt;p&gt;Install the WireGuard app from the App Store or Play Store. Then scan the QR code directly from your terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;wireguard /app/show-peer phone
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This displays the QR code in your terminal. Scan it with the mobile app and toggle the connection on.&lt;/p&gt;




&lt;h2&gt;
  
  
  Adding More Peers Later
&lt;/h2&gt;

&lt;p&gt;Need to add a new device? Update the &lt;code&gt;PEERS&lt;/code&gt; environment variable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;PEERS=laptop,phone,tablet,work-laptop&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then restart the container:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose down &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;WireGuard generates the new peer config without touching existing ones. Your current devices stay connected.&lt;/p&gt;




&lt;h2&gt;
  
  
  Split Tunnel vs Full Tunnel
&lt;/h2&gt;

&lt;p&gt;The config above routes &lt;strong&gt;all traffic&lt;/strong&gt; through the VPN (&lt;code&gt;AllowedIPs = 0.0.0.0/0&lt;/code&gt;). This is a full tunnel — everything goes through your server.&lt;/p&gt;

&lt;p&gt;For a &lt;strong&gt;split tunnel&lt;/strong&gt; (only route specific traffic), edit the client config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Peer]&lt;/span&gt;
&lt;span class="py"&gt;AllowedIPs&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;10.13.13.0/24, 192.168.1.0/24&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This only routes traffic to your VPN subnet and home network through the tunnel. Everything else uses your local internet connection. Split tunneling is better for performance when you only need access to services on your server.&lt;/p&gt;




&lt;h2&gt;
  
  
  Security Considerations
&lt;/h2&gt;

&lt;p&gt;WireGuard is secure by design, but your setup can still have weak points:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Key management.&lt;/strong&gt; The private keys in &lt;code&gt;/opt/wireguard/config/&lt;/code&gt; are the keys to your VPN. If someone gets them, they're on your network. Protect this directory: &lt;code&gt;chmod 700 /opt/wireguard/config/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DNS leaks.&lt;/strong&gt; The config sets DNS to &lt;code&gt;1.1.1.1&lt;/code&gt; (Cloudflare). If you want full privacy, run your own DNS resolver (Pi-hole or Unbound) and point &lt;code&gt;PEERDNS&lt;/code&gt; at your server's WireGuard IP instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exposed UDP port.&lt;/strong&gt; Port 51820 is visible to scanners. WireGuard is silent by design — it doesn't respond to unauthenticated packets — but you can change the port to something less obvious if you prefer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Container capabilities.&lt;/strong&gt; The &lt;code&gt;NET_ADMIN&lt;/code&gt; and &lt;code&gt;SYS_MODULE&lt;/code&gt; capabilities are required for WireGuard to create network interfaces. This is more permissive than a typical container — review the &lt;a href="https://dev.to/docker-security-best-practices/"&gt;Docker security best practices&lt;/a&gt; to lock down the rest of your stack.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Troubleshooting
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Problem: Client connects but can't reach the internet.&lt;/strong&gt;&lt;br&gt;
Cause: IP forwarding is disabled on the host.&lt;br&gt;
Fix: Run &lt;code&gt;sysctl net.ipv4.ip_forward=1&lt;/code&gt; and make it permanent in &lt;code&gt;/etc/sysctl.d/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Problem: Connection times out.&lt;/strong&gt;&lt;br&gt;
Cause: UDP port 51820 is blocked by your firewall or hosting provider.&lt;br&gt;
Fix: Check &lt;code&gt;sudo ufw status&lt;/code&gt; and your Hetzner firewall rules in the cloud console. Both must allow 51820/udp.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Problem: QR code doesn't display in terminal.&lt;/strong&gt;&lt;br&gt;
Cause: Terminal doesn't support the character encoding.&lt;br&gt;
Fix: Use a modern terminal emulator, or copy the &lt;code&gt;.conf&lt;/code&gt; file manually instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Problem: Handshake completes but no data flows.&lt;/strong&gt;&lt;br&gt;
Cause: NAT or routing issue on the server. The &lt;code&gt;src_valid_mark&lt;/code&gt; sysctl isn't set.&lt;br&gt;
Fix: Verify with &lt;code&gt;sysctl net.ipv4.conf.all.src_valid_mark&lt;/code&gt; — should return &lt;code&gt;1&lt;/code&gt;. The Docker compose file sets this, but check it's applied.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Problem: Existing peers break after adding new ones.&lt;/strong&gt;&lt;br&gt;
Cause: You recreated the container without preserving the config volume.&lt;br&gt;
Fix: The configs are stored in &lt;code&gt;./config&lt;/code&gt; which is bind-mounted. As long as you don't delete that directory, existing peers survive restarts. If you lost them, clients need new configs.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;You now have a self-hosted WireGuard VPN that you fully control. No subscription fees, no trust-me-bro logging policies, and connection speeds that barely dip below your raw bandwidth.&lt;/p&gt;

&lt;p&gt;From here, consider:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Adding &lt;strong&gt;Pi-hole&lt;/strong&gt; behind WireGuard for ad-blocking on all your devices&lt;/li&gt;
&lt;li&gt;Using a split tunnel for accessing your self-hosted services remotely&lt;/li&gt;
&lt;li&gt;Setting up &lt;a href="https://dev.to/fail2ban-setup-guide/"&gt;Fail2Ban&lt;/a&gt; to monitor your server logs&lt;/li&gt;
&lt;li&gt;Reviewing your overall &lt;a href="https://dev.to/harden-linux-vps-10-minutes/"&gt;VPS hardening&lt;/a&gt; if you haven't already&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you need a VPS to run this on, I use &lt;a href="https://www.hetzner.com/cloud/" rel="noopener noreferrer"&gt;Hetzner&lt;/a&gt; for all my projects — solid performance, fair pricing, and Helsinki data center latency is great for Europe.&lt;/p&gt;

</description>
      <category>vpn</category>
      <category>wireguard</category>
      <category>selfhosted</category>
      <category>security</category>
    </item>
    <item>
      <title>Nginx Proxy Manager vs Traefik vs Caddy: Which Reverse Proxy Should You Pick in 2026?</title>
      <dc:creator>byteguard</dc:creator>
      <pubDate>Thu, 23 Apr 2026 20:17:44 +0000</pubDate>
      <link>https://dev.to/byte-guard/nginx-proxy-manager-vs-traefik-vs-caddy-which-reverse-proxy-should-you-pick-in-2026-gl3</link>
      <guid>https://dev.to/byte-guard/nginx-proxy-manager-vs-traefik-vs-caddy-which-reverse-proxy-should-you-pick-in-2026-gl3</guid>
      <description>&lt;p&gt;Every self-hosted stack needs a reverse proxy. It's the front door — it terminates SSL, routes traffic to the right container, and keeps your services from fighting over port 443. But which one should you pick?&lt;/p&gt;

&lt;p&gt;I've used all three. &lt;strong&gt;Nginx Proxy Manager&lt;/strong&gt; runs this blog. I've deployed &lt;strong&gt;Traefik&lt;/strong&gt; on client projects. And &lt;strong&gt;Caddy&lt;/strong&gt; has quietly become my favorite for small stacks. This comparison covers the differences that actually matter: setup time, Docker integration, SSL handling, and the learning curve.&lt;/p&gt;

&lt;p&gt;If you're choosing a reverse proxy for your &lt;strong&gt;self-hosting&lt;/strong&gt; setup, this post will help you make the right call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick Comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Nginx Proxy Manager&lt;/th&gt;
&lt;th&gt;Traefik&lt;/th&gt;
&lt;th&gt;Caddy&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Config style&lt;/td&gt;
&lt;td&gt;Web UI&lt;/td&gt;
&lt;td&gt;Labels / YAML&lt;/td&gt;
&lt;td&gt;Caddyfile / JSON&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auto SSL&lt;/td&gt;
&lt;td&gt;Yes (Let's Encrypt)&lt;/td&gt;
&lt;td&gt;Yes (Let's Encrypt)&lt;/td&gt;
&lt;td&gt;Yes (Let's Encrypt + ZeroSSL)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Docker integration&lt;/td&gt;
&lt;td&gt;Manual (UI)&lt;/td&gt;
&lt;td&gt;Native (labels)&lt;/td&gt;
&lt;td&gt;API / Caddyfile&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Web dashboard&lt;/td&gt;
&lt;td&gt;Full GUI&lt;/td&gt;
&lt;td&gt;Read-only dashboard&lt;/td&gt;
&lt;td&gt;None (API only)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Learning curve&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Performance&lt;/td&gt;
&lt;td&gt;Excellent (Nginx core)&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wildcard certs&lt;/td&gt;
&lt;td&gt;Yes (DNS challenge)&lt;/td&gt;
&lt;td&gt;Yes (DNS challenge)&lt;/td&gt;
&lt;td&gt;Yes (DNS challenge)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Active community&lt;/td&gt;
&lt;td&gt;Large&lt;/td&gt;
&lt;td&gt;Very large&lt;/td&gt;
&lt;td&gt;Growing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Config as code&lt;/td&gt;
&lt;td&gt;No (DB-backed)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Resource usage&lt;/td&gt;
&lt;td&gt;~50MB RAM&lt;/td&gt;
&lt;td&gt;~80MB RAM&lt;/td&gt;
&lt;td&gt;~30MB RAM&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Nginx Proxy Manager — The Visual Approach
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://nginxproxymanager.com/" rel="noopener noreferrer"&gt;Nginx Proxy Manager&lt;/a&gt; (NPM) wraps Nginx in a web interface. You click through forms to add proxy hosts, and it generates the Nginx config behind the scenes. I chose it for the &lt;a href="https://blog.byte-guard.net/building-byteguard-from-scratch-hetzner-vps/" rel="noopener noreferrer"&gt;ByteGuard stack&lt;/a&gt; because I wanted to get Ghost, Uptime Kuma, and the blog itself online in an afternoon.&lt;/p&gt;

&lt;h3&gt;
  
  
  Setup with Docker Compose
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;npm&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jc21/nginx-proxy-manager:latest&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;80:80"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;443:443"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;81:81"&lt;/span&gt;  &lt;span class="c1"&gt;# Admin UI&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;npm_data:/data&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;npm_letsencrypt:/etc/letsencrypt&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;npm_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;npm_letsencrypt&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Start it with &lt;code&gt;docker compose up -d&lt;/code&gt;, open &lt;code&gt;http://&amp;lt;YOUR_IP&amp;gt;:81&lt;/code&gt;, and log in with the default credentials (&lt;code&gt;admin@example.com&lt;/code&gt; / &lt;code&gt;changeme&lt;/code&gt;). From there, adding a proxy host is four fields: domain, forward hostname (container name), forward port, and a toggle for SSL.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pros
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fastest time to first proxy.&lt;/strong&gt; If you've never touched Nginx config files, NPM gets you from zero to SSL-terminated proxy in under five minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Visual certificate management.&lt;/strong&gt; You can see every certificate, its expiry date, and renewal status in one screen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Access lists and custom Nginx config.&lt;/strong&gt; The UI supports basic auth, IP restrictions, and raw Nginx directives for advanced users.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Cons
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Not config-as-code.&lt;/strong&gt; The config lives in a SQLite database. You can't version-control your proxy rules or reproduce the setup from a file. If the database corrupts, you're rebuilding from scratch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Port 81 exposure.&lt;/strong&gt; The admin UI runs on a separate port. You need to either firewall it or put it behind itself (which is awkward).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker-unaware.&lt;/strong&gt; NPM doesn't know about your Docker containers. When you add a new service, you manually create a proxy host. It won't auto-discover anything.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Who Should Use NPM
&lt;/h3&gt;

&lt;p&gt;You want a working reverse proxy today and don't care about infrastructure-as-code. You're running a small stack (under 10 services) and prefer clicking over editing YAML. This is where most self-hosters start, and honestly, many never need to leave.&lt;/p&gt;




&lt;h2&gt;
  
  
  Traefik — The Docker-Native Option
&lt;/h2&gt;

&lt;p&gt;Traefik was built for containerized environments. Instead of editing config files or clicking through a UI, you define routing rules as &lt;strong&gt;Docker labels&lt;/strong&gt; on your containers. Traefik watches the Docker socket, detects new containers, and configures itself automatically.&lt;/p&gt;

&lt;h3&gt;
  
  
  Setup with Docker Compose
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;traefik&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;traefik:v3.0&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;traefik&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--api.dashboard=true"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--providers.docker=true"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--providers.docker.exposedbydefault=false"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--entrypoints.web.address=:80"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--entrypoints.websecure.address=:443"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--certificatesresolvers.letsencrypt.acme.email=you@example.com"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;80:80"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;443:443"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/var/run/docker.sock:/var/run/docker.sock:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;traefik_letsencrypt:/letsencrypt&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;traefik_letsencrypt&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now, instead of configuring the proxy separately, you add labels to your application containers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ghost&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghost:5&lt;/span&gt;
    &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.enable=true"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.ghost.rule=Host(`blog.example.com`)"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.ghost.entrypoints=websecure"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.ghost.tls.certresolver=letsencrypt"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.services.ghost.loadbalancer.server.port=2368"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deploy the container, and Traefik picks it up automatically. No manual proxy host creation. No UI interaction. Remove the container, and the route disappears.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pros
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;True auto-discovery.&lt;/strong&gt; Add a container with the right labels, and it's proxied. No extra step.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Config as code.&lt;/strong&gt; Everything is in your Docker Compose files. Version-control the whole stack, &lt;code&gt;docker compose up -d&lt;/code&gt; on a new server, and you have an identical setup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Middleware ecosystem.&lt;/strong&gt; Rate limiting, basic auth, header manipulation, circuit breakers — all configured as labels. No custom Nginx snippets.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scales well.&lt;/strong&gt; Traefik handles Docker Swarm, Kubernetes, and multi-provider setups out of the box.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Cons
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Steep learning curve.&lt;/strong&gt; The concepts (entrypoints, routers, middlewares, services, providers) take time to internalize. The documentation is comprehensive but dense.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker socket exposure.&lt;/strong&gt; Traefik needs read access to the Docker socket (&lt;code&gt;/var/run/docker.sock&lt;/code&gt;). This is a security risk — a compromised Traefik container could inspect and manipulate all your containers. Mitigate this with a &lt;a href="https://github.com/Tecnativa/docker-socket-proxy" rel="noopener noreferrer"&gt;Docker socket proxy&lt;/a&gt; or follow the guidelines in my &lt;a href="https://blog.byte-guard.net/docker-security-best-practices/" rel="noopener noreferrer"&gt;Docker security post&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verbose labels.&lt;/strong&gt; A single service might need 5-8 labels. Multiply that by 10 services and your compose file gets noisy.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Who Should Use Traefik
&lt;/h3&gt;

&lt;p&gt;You're running 10+ services, you add and remove containers frequently, and you want everything defined in code. You're comfortable reading documentation and debugging label syntax. Traefik pays back the learning investment when your stack grows.&lt;/p&gt;




&lt;h2&gt;
  
  
  Caddy — The Quiet Winner
&lt;/h2&gt;

&lt;p&gt;Caddy is a web server written in Go that does automatic HTTPS by default. No config required for SSL — it requests and renews certificates for every domain you define. The config format (Caddyfile) is the simplest of the three.&lt;/p&gt;

&lt;h3&gt;
  
  
  Setup with Docker Compose
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;caddy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;caddy:2&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;caddy&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;80:80"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;443:443"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./Caddyfile:/etc/caddy/Caddyfile&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;caddy_data:/data&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;caddy_config:/config&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;caddy_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;caddy_config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create a &lt;code&gt;Caddyfile&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;blog.example.com {
    reverse_proxy ghost:2368
}

status.example.com {
    reverse_proxy uptime-kuma:3001
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the entire reverse proxy config. Two lines per service. SSL happens automatically — Caddy requests certificates from Let's Encrypt (or ZeroSSL) on first request, stores them in &lt;code&gt;/data&lt;/code&gt;, and renews them before expiry. No &lt;code&gt;certresolver&lt;/code&gt; config. No toggle. It's the default behavior.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pros
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Automatic HTTPS with zero config.&lt;/strong&gt; This is Caddy's killer feature. Every site gets SSL by default. No ACME setup, no certificate resolver blocks, no toggles.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Minimal config.&lt;/strong&gt; A Caddyfile for 10 services is maybe 30 lines. Compare that to 80+ lines of Traefik labels or 10 manual proxy hosts in NPM.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Low resource usage.&lt;/strong&gt; Caddy consistently uses less RAM than Traefik and comparable CPU to NPM.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Config as code.&lt;/strong&gt; The Caddyfile is a plain text file. Version control it, template it, ship it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hot reload.&lt;/strong&gt; &lt;code&gt;caddy reload&lt;/code&gt; applies config changes without dropping connections. No container restart needed.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Cons
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No web UI.&lt;/strong&gt; If you want a dashboard, you're looking at the Caddyfile in a text editor. There's an admin API, but no official GUI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Smaller community.&lt;/strong&gt; Fewer Stack Overflow answers and fewer blog posts compared to Nginx or Traefik. When you hit an edge case, you might be reading source code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker integration isn't native.&lt;/strong&gt; Unlike Traefik, Caddy doesn't watch Docker labels out of the box. You edit the Caddyfile and reload. There are third-party plugins for Docker integration, but they're not first-party.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Who Should Use Caddy
&lt;/h3&gt;

&lt;p&gt;You want the simplest possible config, you're comfortable editing text files, and you don't need a GUI. Caddy is perfect for stacks under 20 services where you don't add/remove containers hourly. It's also excellent as a dev proxy on your local machine.&lt;/p&gt;




&lt;h2&gt;
  
  
  My Pick: It Depends (Honestly)
&lt;/h2&gt;

&lt;p&gt;I hate cop-out answers, so here's my actual decision tree:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;First self-hosted project, want it running today?&lt;/strong&gt; → Nginx Proxy Manager. The GUI removes all friction. I used it for &lt;a href="https://blog.byte-guard.net/building-byteguard-from-scratch-hetzner-vps/" rel="noopener noreferrer"&gt;this very blog&lt;/a&gt; and it's been rock-solid.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Growing stack, everything in Docker Compose, want auto-discovery?&lt;/strong&gt; → Traefik. The learning curve is real, but the payoff at scale is worth it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Want the simplest config with automatic SSL and don't need a GUI?&lt;/strong&gt; → Caddy. If I were starting ByteGuard from scratch today, I'd probably pick Caddy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The good news: switching later isn't hard. Your application containers don't change — only the proxy layer does. Pick the one that matches your comfort level now.&lt;/p&gt;




&lt;h2&gt;
  
  
  Security Considerations
&lt;/h2&gt;

&lt;p&gt;Whichever proxy you choose, keep these in mind:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;TLS versions.&lt;/strong&gt; Disable TLS 1.0 and 1.1. All three support TLS 1.2+ by default, but verify your config. Caddy enforces TLS 1.2+ out of the box.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTTP to HTTPS redirect.&lt;/strong&gt; All three support automatic redirects. Make sure it's enabled — you don't want any service accessible over plain HTTP.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker socket access.&lt;/strong&gt; Only Traefik needs it. If you use Traefik, mount the socket read-only (&lt;code&gt;:ro&lt;/code&gt;) and consider a socket proxy. NPM and Caddy don't need socket access at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Admin interfaces.&lt;/strong&gt; NPM exposes port 81, Traefik has an optional dashboard. Restrict access with firewall rules or put them behind authentication. Caddy's admin API listens on localhost only by default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limiting.&lt;/strong&gt; Traefik has built-in rate limiting middleware. For NPM, you'd add custom Nginx directives. For Caddy, use the &lt;code&gt;rate_limit&lt;/code&gt; plugin. All three can do it, but Traefik makes it easiest.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Troubleshooting
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Problem: SSL certificate not issuing.&lt;/strong&gt;&lt;br&gt;
Cause: Port 80 is blocked, DNS doesn't point to your server, or you've hit Let's Encrypt rate limits.&lt;br&gt;
Fix: Verify &lt;code&gt;dig +short yourdomain.com&lt;/code&gt; returns your server IP and port 80 is open (&lt;code&gt;sudo ufw allow 80/tcp&lt;/code&gt;). Check rate limits at &lt;a href="https://letsencrypt.org/docs/rate-limits/" rel="noopener noreferrer"&gt;letsencrypt.org/docs/rate-limits&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Problem: 502 Bad Gateway.&lt;/strong&gt;&lt;br&gt;
Cause: The proxy can't reach the backend container. Wrong hostname or port.&lt;br&gt;
Fix: Ensure the proxy and backend are on the same Docker network. Use container names as hostnames (e.g., &lt;code&gt;ghost&lt;/code&gt;, not &lt;code&gt;localhost&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Problem: Traefik labels are ignored.&lt;/strong&gt;&lt;br&gt;
Cause: &lt;code&gt;exposedbydefault=false&lt;/code&gt; is set but you forgot &lt;code&gt;traefik.enable=true&lt;/code&gt;.&lt;br&gt;
Fix: Add the &lt;code&gt;traefik.enable=true&lt;/code&gt; label to every container you want proxied.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Problem: Caddy shows "default" page instead of your site.&lt;/strong&gt;&lt;br&gt;
Cause: The domain in your Caddyfile doesn't match the request's Host header.&lt;br&gt;
Fix: Verify the domain is spelled correctly and DNS resolves to your server.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;All three are solid choices. NPM gets you started fast with a GUI. Traefik scales best for dynamic container environments. Caddy gives you the cleanest config with automatic SSL.&lt;/p&gt;

&lt;p&gt;I wrote a full walkthrough of the NPM setup when I &lt;a href="https://blog.byte-guard.net/building-byteguard-from-scratch-hetzner-vps/" rel="noopener noreferrer"&gt;built ByteGuard from scratch&lt;/a&gt;, and the &lt;a href="https://blog.byte-guard.net/docker-security-best-practices/" rel="noopener noreferrer"&gt;Docker security post&lt;/a&gt; covers how to lock down whichever proxy you choose. If you're still deciding on a platform, my &lt;a href="https://blog.byte-guard.net/ghost-vs-wordpress-technical-blog/" rel="noopener noreferrer"&gt;Ghost vs WordPress comparison&lt;/a&gt; might help too.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>nginx</category>
      <category>traefik</category>
      <category>selfhosted</category>
    </item>
    <item>
      <title>Ghost vs WordPress in 2026: Which Is Better for Technical Blogs?</title>
      <dc:creator>byteguard</dc:creator>
      <pubDate>Mon, 20 Apr 2026 10:39:03 +0000</pubDate>
      <link>https://dev.to/byte-guard/ghost-vs-wordpress-in-2026-which-is-better-for-technical-blogs-4caj</link>
      <guid>https://dev.to/byte-guard/ghost-vs-wordpress-in-2026-which-is-better-for-technical-blogs-4caj</guid>
      <description>&lt;p&gt;Every technical blogger eventually faces this decision: Ghost or WordPress? WordPress powers 43% of the internet and has a plugin for everything. Ghost is the lean, fast alternative that was literally built for publishing. Both can be self-hosted. Both are open source. Both will run a perfectly good technical blog.&lt;/p&gt;

&lt;p&gt;I chose Ghost for byte-guard.net, and I've been running it in production for two weeks on a &lt;a href="https://blog.byte-guard.net/building-byteguard-from-scratch-hetzner-vps/" rel="noopener noreferrer"&gt;self-hosted Hetzner VPS&lt;/a&gt;. Before that, I spent time with WordPress on various projects. This isn't a theoretical comparison — it's what I've actually experienced using both platforms for technical content with code blocks, tutorials, and long-form writing.&lt;/p&gt;

&lt;p&gt;Here's the honest breakdown to help you decide which one fits your workflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;p&gt;This comparison assumes you're a technical user who:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Wants to write tutorials, how-to guides, or technical articles&lt;/li&gt;
&lt;li&gt;Is comfortable self-hosting (or at least considering it)&lt;/li&gt;
&lt;li&gt;Cares about performance, SEO, and writing experience&lt;/li&gt;
&lt;li&gt;Doesn't need e-commerce, forums, or complex multi-author workflows&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you need a full-featured CMS with WooCommerce, LMS plugins, and 50 contributors — WordPress wins by default. That's not the comparison I'm making here.&lt;/p&gt;




&lt;h2&gt;
  
  
  Quick Comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Ghost&lt;/th&gt;
&lt;th&gt;WordPress&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Built for&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Publishing &amp;amp; newsletters&lt;/td&gt;
&lt;td&gt;Everything (CMS, e-commerce, forums...)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Core language&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Node.js&lt;/td&gt;
&lt;td&gt;PHP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Database&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;SQLite or MySQL&lt;/td&gt;
&lt;td&gt;MySQL/MariaDB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Editor&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Native block editor (Koenig)&lt;/td&gt;
&lt;td&gt;Gutenberg block editor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Code blocks&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Built-in with syntax highlighting&lt;/td&gt;
&lt;td&gt;Requires plugin (SyntaxHighlighter, Prisma)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Themes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~100 official/community&lt;/td&gt;
&lt;td&gt;10,000+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Plugins/Extensions&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;None (by design)&lt;/td&gt;
&lt;td&gt;60,000+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Built-in newsletters&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes (native)&lt;/td&gt;
&lt;td&gt;No (requires Mailchimp, etc.)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Membership/subscriptions&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Native, with Stripe integration&lt;/td&gt;
&lt;td&gt;Requires plugins&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;REST API&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Content + Admin API&lt;/td&gt;
&lt;td&gt;REST + GraphQL (via plugin)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Self-hosting difficulty&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Docker Compose, one file&lt;/td&gt;
&lt;td&gt;LAMP/LEMP stack or Docker&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Managed hosting cost&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$9-199/month (Ghost Pro)&lt;/td&gt;
&lt;td&gt;$3-50/month (shared hosting)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Self-hosted cost&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Free + VPS (~$5-9/month)&lt;/td&gt;
&lt;td&gt;Free + VPS (~$5-9/month)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RAM usage (idle)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~350 MB&lt;/td&gt;
&lt;td&gt;~80-150 MB (depends on plugins)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Page speed (default theme)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;95-100 Lighthouse&lt;/td&gt;
&lt;td&gt;60-85 Lighthouse (varies wildly)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Writing Experience
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Ghost
&lt;/h3&gt;

&lt;p&gt;Ghost's editor (Koenig) is clean, fast, and distraction-free. It feels like writing in a modern document editor — you type, the text appears, and there's nothing between you and the content. No sidebars, no widget panels, no "would you like to install Yoast?" prompts.&lt;/p&gt;

&lt;p&gt;For technical writing specifically, Ghost handles code blocks natively. You type &lt;code&gt;&lt;/code&gt;`&lt;code&gt;&lt;/code&gt;, pick a language, and get syntax-highlighted code that renders correctly on publish. No plugin needed. No configuration. It works out of the box.&lt;/p&gt;

&lt;p&gt;Markdown support is first-class. You can write in Ghost's visual editor or paste raw markdown — it converts seamlessly. For a technical writer who thinks in markdown (and if you're writing tutorials, you probably do), this is a genuine workflow advantage.&lt;/p&gt;

&lt;p&gt;What Ghost's editor doesn't do: custom layouts, multi-column content, or complex media embeds beyond the basics (images, video, audio, galleries, bookmarks, and code). If you need a full page builder, Ghost isn't it.&lt;/p&gt;

&lt;h3&gt;
  
  
  WordPress
&lt;/h3&gt;

&lt;p&gt;Gutenberg (WordPress's block editor) is more powerful but also more complex. You can build elaborate page layouts, embed custom HTML, use reusable block patterns, and install editor plugins that add new block types.&lt;/p&gt;

&lt;p&gt;For code blocks, you need a plugin. The default code block exists but doesn't do syntax highlighting. Most technical bloggers install something like SyntaxHighlighter Evolved, Prisma, or Code Block Pro. Each comes with its own settings page, its own CSS, and its own maintenance burden. They work fine — but it's configuration you don't have to do on Ghost.&lt;/p&gt;

&lt;p&gt;The WordPress editor has improved dramatically since the early Gutenberg days, but it still carries the weight of being a general-purpose CMS editor. Writing a technical tutorial in WordPress feels like using a Swiss Army knife to tighten a screw — it works, but you're carrying tools you don't need.&lt;/p&gt;




&lt;h2&gt;
  
  
  Performance
&lt;/h2&gt;

&lt;p&gt;This is where the gap is most obvious.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ghost with the default Casper theme&lt;/strong&gt; scores 95-100 on Google Lighthouse for performance. The pages are small, the JavaScript is minimal, and the server-side rendering is fast. My byte-guard.net homepage loads in under 1 second with a cold cache.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;WordPress with a default theme&lt;/strong&gt; (Twenty Twenty-Five) scores reasonably well — mid-80s to low-90s. But the moment you install plugins, add analytics scripts, enable comment systems, and load a third-party theme, that score drops fast. A typical WordPress blog with 10-15 plugins and a premium theme scores 60-75 on Lighthouse.&lt;/p&gt;

&lt;p&gt;The difference isn't WordPress itself — it's the ecosystem. WordPress's strength (plugins for everything) is also its performance weakness. Every plugin adds database queries, CSS files, and JavaScript. Ghost avoids this entirely by not having plugins.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Real numbers from my stack:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Ghost on a Hetzner CPX22 (3 vCPUs, 8 GB RAM):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Idle memory: ~350 MB (Ghost + Node.js)&lt;/li&gt;
&lt;li&gt;Time to first byte: ~80ms&lt;/li&gt;
&lt;li&gt;Full page load: ~0.8s&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A comparable WordPress install (PHP-FPM + MySQL + a popular theme):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Idle memory: ~80-150 MB (PHP itself is lighter, but MySQL adds overhead)&lt;/li&gt;
&lt;li&gt;Time to first byte: ~200-400ms (depends heavily on caching plugin)&lt;/li&gt;
&lt;li&gt;Full page load: ~1.5-3s (without caching plugin, much worse)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Ghost is faster out of the box. WordPress can match Ghost's speed, but you need a caching plugin (WP Super Cache, W3 Total Cache, or LiteSpeed Cache), an image optimizer, and careful plugin management. It's achievable — it's just not the default.&lt;/p&gt;




&lt;h2&gt;
  
  
  SEO
&lt;/h2&gt;

&lt;p&gt;Both platforms can rank well. The question is how much work it takes to get there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ghost includes SEO essentials by default:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Clean URLs with configurable slugs&lt;/li&gt;
&lt;li&gt;Meta titles and descriptions on every post&lt;/li&gt;
&lt;li&gt;Automatic XML sitemap&lt;/li&gt;
&lt;li&gt;Structured data (JSON-LD) for articles&lt;/li&gt;
&lt;li&gt;Open Graph and Twitter Card meta tags&lt;/li&gt;
&lt;li&gt;Canonical URL support&lt;/li&gt;
&lt;li&gt;Fast page speed (which Google cares about)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You don't install anything. It's all there from the first post.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;WordPress needs Yoast, Rank Math, or All in One SEO&lt;/strong&gt; to get the same feature set. These plugins are excellent — Yoast in particular is arguably the best SEO tool available for any platform — but they're plugins you have to install, configure, and keep updated. Without them, WordPress's built-in SEO is bare-bones.&lt;/p&gt;

&lt;p&gt;If you're serious about SEO, WordPress with Yoast or Rank Math gives you more control than Ghost (content analysis, keyword tracking, redirect management, schema markup customization). Ghost covers the fundamentals well, but power users will miss the granularity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My take:&lt;/strong&gt; Ghost's built-in SEO is enough for a technical blog that focuses on writing good content. If SEO is your primary growth strategy and you want tools that analyze your content as you write, WordPress + Rank Math is hard to beat.&lt;/p&gt;




&lt;h2&gt;
  
  
  Self-Hosting
&lt;/h2&gt;

&lt;p&gt;Both are open source and free to self-host. The experience differs significantly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Ghost Self-Hosting
&lt;/h3&gt;

&lt;p&gt;Ghost runs on Node.js and can use SQLite (the default for small sites) or MySQL. The simplest way to self-host Ghost in 2026 is Docker Compose:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;`yaml&lt;br&gt;
services:&lt;br&gt;
  ghost:&lt;br&gt;
    image: ghost:5&lt;br&gt;
    ports:&lt;br&gt;
      - "2368:2368"&lt;br&gt;
    environment:&lt;br&gt;
      url: https://blog.yourdomain.com&lt;br&gt;
    volumes:&lt;br&gt;
      - ghost_data:/var/lib/ghost/content&lt;br&gt;
`&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;That's a working Ghost instance. Add a reverse proxy (&lt;a href="https://blog.byte-guard.net/building-byteguard-from-scratch-hetzner-vps/" rel="noopener noreferrer"&gt;like Nginx Proxy Manager&lt;/a&gt;) for SSL, and you have a production blog. The entire ByteGuard stack — Ghost, NPM, Uptime Kuma — runs on a single $9/month VPS.&lt;/p&gt;

&lt;p&gt;Ghost's update process is also simple: &lt;code&gt;docker compose pull &amp;amp;&amp;amp; docker compose up -d&lt;/code&gt;. No database migration scripts, no plugin compatibility checks.&lt;/p&gt;

&lt;h3&gt;
  
  
  WordPress Self-Hosting
&lt;/h3&gt;

&lt;p&gt;WordPress needs PHP, a web server (Nginx or Apache), and MySQL/MariaDB. You can Docker-ize it, but the typical setup has more moving parts:&lt;/p&gt;

&lt;p&gt;`&lt;code&gt;&lt;/code&gt;yaml&lt;br&gt;
services:&lt;br&gt;
  wordpress:&lt;br&gt;
    image: wordpress:latest&lt;br&gt;
    ports:&lt;br&gt;
      - "8080:80"&lt;br&gt;
    environment:&lt;br&gt;
      WORDPRESS_DB_HOST: db&lt;br&gt;
      WORDPRESS_DB_USER: wp&lt;br&gt;
      WORDPRESS_DB_PASSWORD: ${WP_DB_PASS}&lt;br&gt;
    volumes:&lt;br&gt;
      - wp_data:/var/www/html&lt;/p&gt;

&lt;p&gt;db:&lt;br&gt;
    image: mariadb:11&lt;br&gt;
    environment:&lt;br&gt;
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASS}&lt;br&gt;
      MYSQL_DATABASE: wordpress&lt;br&gt;
      MYSQL_USER: wp&lt;br&gt;
      MYSQL_PASSWORD: ${WP_DB_PASS}&lt;br&gt;
    volumes:&lt;br&gt;
      - db_data:/var/lib/mysql&lt;br&gt;
&lt;code&gt;&lt;/code&gt;`&lt;/p&gt;

&lt;p&gt;Two containers minimum (WordPress + database). Updates are more complex — WordPress core updates through the admin panel, plugins update independently, and theme updates can break if you've customized PHP templates. The "update everything and pray" experience is real.&lt;/p&gt;

&lt;p&gt;WordPress is not harder to self-host. It's just more to manage over time.&lt;/p&gt;




&lt;h2&gt;
  
  
  Membership and Newsletters
&lt;/h2&gt;

&lt;p&gt;Ghost has native membership and newsletter support built in. You can gate content behind free or paid tiers, collect email subscribers, send newsletters, and process payments via Stripe — all without a single plugin or third-party integration.&lt;/p&gt;

&lt;p&gt;For a technical blog, this means you can offer a free newsletter (like a weekly security roundup) and optionally add paid-only content later. The subscriber management, email templates, and analytics are all inside Ghost's admin panel.&lt;/p&gt;

&lt;p&gt;WordPress needs plugins for all of this. Mailchimp, ConvertKit, or Buttondown for newsletters. MemberPress or Restrict Content Pro for memberships. WooCommerce Subscriptions for payments. Each plugin has its own dashboard, its own settings, and its own subscription cost.&lt;/p&gt;

&lt;p&gt;If newsletters and memberships are part of your plan, Ghost saves you significant setup time and ongoing plugin management.&lt;/p&gt;




&lt;h2&gt;
  
  
  Customization and Extensibility
&lt;/h2&gt;

&lt;p&gt;This is WordPress's strongest advantage and it's not close.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;WordPress:&lt;/strong&gt; 60,000+ plugins. 10,000+ themes. Custom post types, taxonomies, hooks, filters. You can build almost anything — an LMS, a job board, a social network, a SaaS dashboard. The ecosystem is massive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ghost:&lt;/strong&gt; No plugins. Around 100 themes. You can inject custom code via the admin panel (header/footer), build custom themes with Handlebars templates, and use the Content API to build a headless frontend. But the customization ceiling is intentionally lower — Ghost is a publishing platform, not a general-purpose CMS.&lt;/p&gt;

&lt;p&gt;For a technical blog, you rarely need WordPress's full extensibility. You need good code blocks, clean layouts, fast rendering, and maybe a newsletter. Ghost covers all of that. But if you want to add a forum, a course platform, a custom tool, or interactive widgets — WordPress gives you an ecosystem that Ghost simply doesn't have.&lt;/p&gt;




&lt;h2&gt;
  
  
  Pricing
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Self-hosted (both free):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Ghost: free + VPS cost ($5-9/month for a small blog)&lt;/li&gt;
&lt;li&gt;WordPress: free + VPS cost ($5-9/month for a small blog)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Managed hosting:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Ghost Pro: $9/month (Starter) to $199/month (Business). Includes hosting, email, CDN.&lt;/li&gt;
&lt;li&gt;WordPress.com: $4-45/month. Or use shared hosting (Bluehost, SiteGround) at $3-15/month.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you self-host, the cost is identical — just the VPS. If you go managed, WordPress hosting is significantly cheaper at the low end. Ghost Pro's $9/month starter plan includes features (newsletters, membership) that would cost extra as WordPress plugins.&lt;/p&gt;




&lt;h2&gt;
  
  
  Who Should Use Ghost
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Technical bloggers who want to write and publish without managing plugins&lt;/li&gt;
&lt;li&gt;Solo creators who want built-in newsletters and memberships&lt;/li&gt;
&lt;li&gt;Self-hosters who value simplicity (one container, SQLite, done)&lt;/li&gt;
&lt;li&gt;Anyone who prioritizes page speed and doesn't want to fight for Lighthouse scores&lt;/li&gt;
&lt;li&gt;Writers who think in markdown&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Who Should Use WordPress
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Teams with multiple authors and complex editorial workflows&lt;/li&gt;
&lt;li&gt;Sites that need e-commerce, forums, LMS, or custom post types&lt;/li&gt;
&lt;li&gt;Anyone who needs a specific plugin that only exists in the WordPress ecosystem&lt;/li&gt;
&lt;li&gt;Bloggers on a tight budget who want $3/month shared hosting&lt;/li&gt;
&lt;li&gt;Developers who want to build custom functionality with PHP hooks and filters&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  My Pick
&lt;/h2&gt;

&lt;p&gt;I run Ghost on byte-guard.net and I'd make the same choice again. For a technical blog focused on writing tutorials, comparisons, and security content, Ghost removes friction that WordPress adds. The editor is faster, the default performance is better, code blocks work without plugins, and the admin panel doesn't ask me to update 12 plugins every time I log in.&lt;/p&gt;

&lt;p&gt;WordPress is the better choice if you need its ecosystem — and many people genuinely do. But for "I want to write technical posts and have them load fast," Ghost is the more focused tool.&lt;/p&gt;

&lt;p&gt;If you want to try self-hosting Ghost, here's &lt;a href="https://blog.byte-guard.net/building-byteguard-from-scratch-hetzner-vps/" rel="noopener noreferrer"&gt;how I set up the entire stack from scratch on a Hetzner VPS&lt;/a&gt;. Once it's running, &lt;a href="https://blog.byte-guard.net/harden-linux-vps-10-minutes/" rel="noopener noreferrer"&gt;harden the server&lt;/a&gt; and &lt;a href="https://blog.byte-guard.net/docker-security-best-practices/" rel="noopener noreferrer"&gt;lock down the containers&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Need a VPS? I run everything on &lt;a href="https://www.hetzner.com/cloud/" rel="noopener noreferrer"&gt;Hetzner&lt;/a&gt; — here's &lt;a href="https://blog.byte-guard.net/best-vps-self-hosting-hetzner-contabo-vultr/" rel="noopener noreferrer"&gt;how the major providers compare&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Troubleshooting
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;"Ghost uses too much RAM for my small VPS"&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cause:&lt;/strong&gt; Node.js has a larger memory footprint than PHP. Ghost idles at ~350 MB.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix:&lt;/strong&gt; Use a VPS with at least 2 GB RAM for Ghost. On 1 GB plans, Ghost will run but leave little room for anything else. WordPress is lighter on RAM if you're on a very small server.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;"WordPress is slow even with caching"&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cause:&lt;/strong&gt; Too many plugins, unoptimized images, or a heavy theme.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix:&lt;/strong&gt; Audit plugins (disable what you don't need), install an image optimizer (ShortPixel or Imagify), and switch to a lightweight theme (GeneratePress, Astra). Run Lighthouse and fix what it flags.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;"Ghost doesn't have a plugin I need"&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cause:&lt;/strong&gt; Ghost intentionally doesn't support plugins.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix:&lt;/strong&gt; Check if you can achieve it with Ghost's code injection (header/footer scripts), a custom integration via the API, or an external service (Zapier, n8n). If the functionality is core to your site, WordPress is the better fit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;"I want to migrate from WordPress to Ghost"&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cause:&lt;/strong&gt; You've decided Ghost is the better fit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix:&lt;/strong&gt; Ghost has a built-in WordPress importer. Export your WordPress content as XML, upload it in Ghost Admin &amp;gt; Labs &amp;gt; Import. Posts, tags, and images transfer. Custom fields, shortcodes, and plugin-specific content won't — you'll need to clean those up manually.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;"I can't decide"&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fix:&lt;/strong&gt; Spin up both on a cheap VPS and write three posts on each. The writing experience will tell you more than any comparison article — including this one.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ghost</category>
      <category>wordpress</category>
      <category>blogging</category>
      <category>selfhosted</category>
    </item>
    <item>
      <title>Docker Security Best Practices for Self-Hosters in 2026</title>
      <dc:creator>byteguard</dc:creator>
      <pubDate>Sat, 18 Apr 2026 11:29:06 +0000</pubDate>
      <link>https://dev.to/byte-guard/docker-security-best-practices-for-self-hosters-in-2026-35k3</link>
      <guid>https://dev.to/byte-guard/docker-security-best-practices-for-self-hosters-in-2026-35k3</guid>
      <description>&lt;p&gt;Docker makes self-hosting feel effortless. Pull an image, write a compose file, run &lt;code&gt;docker compose up -d&lt;/code&gt;, and you have a production service in minutes. That's exactly how I built the entire ByteGuard stack — &lt;a href="https://blog.byte-guard.net/building-byteguard-from-scratch-hetzner-vps/" rel="noopener noreferrer"&gt;Ghost, Nginx Proxy Manager, and Uptime Kuma on a single Hetzner VPS&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;But here's what most "how to self-host X" guides never tell you: &lt;strong&gt;the defaults are not secure.&lt;/strong&gt; Docker out of the box runs containers as root, puts every container on the same network, exposes ports to the entire internet, and gives containers more Linux capabilities than they need. If you &lt;a href="https://blog.byte-guard.net/harden-linux-vps-10-minutes/" rel="noopener noreferrer"&gt;hardened your VPS at the OS level&lt;/a&gt; but left Docker wide open, you locked the front door and left the windows up.&lt;/p&gt;

&lt;p&gt;This post covers 10 Docker security practices I use on the same Hetzner box that runs this blog. Every snippet is real, every recommendation is tested.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;p&gt;Before you start, you should have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A Linux VPS with Docker and Docker Compose v2 installed (&lt;a href="https://blog.byte-guard.net/building-byteguard-from-scratch-hetzner-vps/" rel="noopener noreferrer"&gt;here's how I set mine up&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Basic familiarity with &lt;code&gt;docker-compose.yml&lt;/code&gt; syntax&lt;/li&gt;
&lt;li&gt;SSH access to your server (&lt;a href="https://blog.byte-guard.net/harden-linux-vps-10-minutes/" rel="noopener noreferrer"&gt;hardened, ideally&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;A running Docker stack you want to secure (even a single container counts)&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  1. Never Run Containers as Root
&lt;/h2&gt;

&lt;p&gt;This is the single highest-impact change you can make. By default, the process inside a Docker container runs as root — UID 0. If an attacker exploits a vulnerability in your application and escapes the container, they land on the host as root. Game over.&lt;/p&gt;

&lt;p&gt;The fix is straightforward. In your &lt;code&gt;docker-compose.yml&lt;/code&gt;, set the &lt;code&gt;user&lt;/code&gt; field:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ghost&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghost:5&lt;/span&gt;
    &lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1000:1000"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ghost_data:/var/lib/ghost/content&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This tells Docker to run the Ghost process as UID 1000 instead of root. The container still starts, Ghost still works — but a compromised process now has unprivileged access.&lt;/p&gt;

&lt;p&gt;A few things to watch for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;File permissions on volumes.&lt;/strong&gt; If your volume data was created by root, a non-root container can't write to it. Fix this with &lt;code&gt;chown 1000:1000&lt;/code&gt; on the host directory before switching.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Some images expect root.&lt;/strong&gt; Official Nginx, for example, needs root to bind to port 80. Inside a compose stack where a reverse proxy handles external traffic, your backend containers don't need to bind privileged ports at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rootless Docker mode&lt;/strong&gt; goes further — the Docker daemon itself runs without root. This is a bigger architectural change and adds complexity around networking and storage drivers. For most self-hosters, running containers as non-root (the &lt;code&gt;user:&lt;/code&gt; field) gives you 90% of the security benefit with 10% of the friction.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  2. Use Read-Only Filesystems
&lt;/h2&gt;

&lt;p&gt;A container with a writable filesystem lets an attacker drop binaries, modify config files, install tools, and persist across restarts. Remove that option entirely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;uptime-kuma&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;louislam/uptime-kuma:1&lt;/span&gt;
    &lt;span class="na"&gt;read_only&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;tmpfs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/tmp&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;kuma_data:/app/data&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;read_only: true&lt;/code&gt;, the container's root filesystem is mounted read-only. The container can only write to explicitly mounted volumes and &lt;code&gt;tmpfs&lt;/code&gt; mounts. If an attacker gets code execution, they can't modify the application, can't install packages, can't drop a reverse shell binary into &lt;code&gt;/usr/local/bin&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;tmpfs&lt;/code&gt; mount for &lt;code&gt;/tmp&lt;/code&gt; gives the application a writable scratch space in memory — many apps need this for temporary files, PID files, or socket files. It disappears on container restart, so nothing persists.&lt;/p&gt;

&lt;p&gt;Most self-hosted applications work fine with read-only filesystems once you identify which directories actually need writes. Ghost needs &lt;code&gt;/var/lib/ghost/content&lt;/code&gt;. Uptime Kuma needs &lt;code&gt;/app/data&lt;/code&gt;. NPM needs its data and letsencrypt directories. Everything else can be locked down.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Set Resource Limits
&lt;/h2&gt;

&lt;p&gt;Without resource limits, a single misbehaving container can consume all available RAM and CPU, taking down every other service on your VPS. This isn't theoretical — a memory leak, a log file growing without bounds, or a fork bomb in a compromised container will OOM-kill your entire host.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ghost&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghost:5&lt;/span&gt;
    &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;512M&lt;/span&gt;
          &lt;span class="na"&gt;cpus&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1.0"&lt;/span&gt;
    &lt;span class="na"&gt;pids_limit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's what each limit does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;memory: 512M&lt;/code&gt;&lt;/strong&gt; — the container gets killed if it tries to use more than 512 MB of RAM. Docker sends a SIGKILL, not a gentle shutdown.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;cpus: "1.0"&lt;/code&gt;&lt;/strong&gt; — the container can use at most one CPU core. Prevents a single container from starving everything else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;pids_limit: 100&lt;/code&gt;&lt;/strong&gt; — caps the number of processes inside the container. This is your fork bomb insurance.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For reference, here's what the ByteGuard stack actually uses on our Hetzner CPX22 (8 GB RAM):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Typical RAM&lt;/th&gt;
&lt;th&gt;Suggested Limit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Ghost&lt;/td&gt;
&lt;td&gt;~350 MB&lt;/td&gt;
&lt;td&gt;512M&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nginx Proxy Manager&lt;/td&gt;
&lt;td&gt;~120 MB&lt;/td&gt;
&lt;td&gt;256M&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Uptime Kuma&lt;/td&gt;
&lt;td&gt;~80 MB&lt;/td&gt;
&lt;td&gt;256M&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; Set limits based on observation, not guesswork. Run &lt;code&gt;docker stats&lt;/code&gt; for a few days to see what your containers actually consume, then set the limit at roughly 1.5x the peak.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  4. Manage Secrets Properly
&lt;/h2&gt;

&lt;p&gt;Secrets in Docker stacks are one of those things everyone knows they should handle correctly and almost nobody does. Here's the hierarchy from worst to best:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Worst — hardcoded in docker-compose.yml:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# DON'T DO THIS&lt;/span&gt;
&lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;database__connection__password=mysecretpassword123&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This password is in your compose file, probably in a git repo, possibly public.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Better — .env file:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# docker-compose.yml&lt;/span&gt;
&lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;database__connection__password=${GHOST_DB_PASSWORD}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# .env&lt;/span&gt;
&lt;span class="nv"&gt;GHOST_DB_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;a-real-strong-password-here
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;.env&lt;/code&gt; file keeps secrets out of the compose file. But it's still plaintext on disk. Lock it down:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;chmod &lt;/span&gt;600 .env
&lt;span class="nb"&gt;chown &lt;/span&gt;root:root .env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add &lt;code&gt;.env&lt;/code&gt; to your &lt;code&gt;.gitignore&lt;/code&gt; and &lt;code&gt;.dockerignore&lt;/code&gt; so it never ends up in a repo or inside an image.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; Environment variables set this way are visible via &lt;code&gt;docker inspect&lt;/code&gt;. Anyone with access to the Docker socket can read every environment variable in every running container.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Best for single-host self-hosting:&lt;/strong&gt; The &lt;code&gt;.env&lt;/code&gt; approach with proper file permissions is honestly fine for most self-hosters. Docker Secrets (the swarm-mode feature) adds encryption and mounts secrets as files inside containers, but it requires swarm mode — overhead most single-server setups don't need. A locked-down &lt;code&gt;.env&lt;/code&gt; file is the pragmatic choice.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Scan Your Images
&lt;/h2&gt;

&lt;p&gt;Every time you pull a Docker image, you're running code that someone else built. You trust that &lt;code&gt;ghost:5&lt;/code&gt; is safe because it's an official image — but official images contain operating system packages, and those packages have CVEs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Docker Scout&lt;/strong&gt; (built into Docker CLI):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker scout cves ghost:5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This scans the image and lists known CVEs by severity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trivy&lt;/strong&gt; (open source, more thorough):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Install&lt;/span&gt;
curl &lt;span class="nt"&gt;-sfL&lt;/span&gt; https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nt"&gt;-b&lt;/span&gt; /usr/local/bin

&lt;span class="c"&gt;# Scan&lt;/span&gt;
trivy image ghost:5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Pin your image versions.&lt;/strong&gt; This is as much a security practice as a reliability one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Don't do this — you get whatever "latest" means today&lt;/span&gt;
&lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghost:latest&lt;/span&gt;

&lt;span class="c1"&gt;# Do this — you know exactly what you're running&lt;/span&gt;
&lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghost:5.118.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pinned versions mean you choose when to update. &lt;code&gt;latest&lt;/code&gt; means Docker pulls whatever the maintainer pushed most recently — and if that image has a supply-chain compromise, you've auto-deployed it.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. Isolate Your Docker Networks
&lt;/h2&gt;

&lt;p&gt;Docker's default bridge network puts every container on the same subnet. Any container can reach any other container by IP. If an attacker compromises one service, they pivot to everything else without touching the external network.&lt;/p&gt;

&lt;p&gt;Create purpose-specific networks and only connect containers that need to talk to each other:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ghost&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghost:5&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;frontend&lt;/span&gt;

  &lt;span class="na"&gt;nginx-proxy-manager&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jc21/nginx-proxy-manager:latest&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;frontend&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;80:80"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;443:443"&lt;/span&gt;

  &lt;span class="na"&gt;uptime-kuma&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;louislam/uptime-kuma:1&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;monitoring&lt;/span&gt;

&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;frontend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;driver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bridge&lt;/span&gt;
  &lt;span class="na"&gt;monitoring&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;driver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bridge&lt;/span&gt;
    &lt;span class="na"&gt;internal&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key patterns:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Only the reverse proxy exposes ports.&lt;/strong&gt; Ghost doesn't need port 2368 open to the internet — NPM proxies traffic to it internally.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bind to localhost when you need host access:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:3001:3001"&lt;/span&gt;  &lt;span class="c1"&gt;# Only accessible from the host&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning:&lt;/strong&gt; Docker manipulates iptables directly, which means &lt;strong&gt;UFW and firewalld rules don't apply to Docker-published ports.&lt;/strong&gt; You can have a perfectly configured firewall and Docker will punch right through it. Binding to localhost is the reliable fix.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;internal: true&lt;/code&gt;&lt;/strong&gt; creates a network with no outbound internet access. Use this for backend services that have no reason to make outbound connections.&lt;/p&gt;




&lt;h2&gt;
  
  
  7. Keep Docker and Images Updated
&lt;/h2&gt;

&lt;p&gt;Docker itself — the engine, containerd, runc — has had serious CVEs. &lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2024-21626" rel="noopener noreferrer"&gt;CVE-2024-21626&lt;/a&gt; (runc container escape) and &lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2024-23651" rel="noopener noreferrer"&gt;CVE-2024-23651&lt;/a&gt; (BuildKit race condition) are recent examples where an unpatched Docker installation was directly exploitable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Update the Docker engine:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;apt upgrade docker-ce docker-ce-cli containerd.io
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Update your images manually:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Pull new versions&lt;/span&gt;
docker compose pull

&lt;span class="c"&gt;# Recreate containers with new images&lt;/span&gt;
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;

&lt;span class="c"&gt;# Clean up old images&lt;/span&gt;
docker image prune &lt;span class="nt"&gt;-f&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Watchtower&lt;/strong&gt; automates image updates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;watchtower&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;containrrr/watchtower&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/var/run/docker.sock:/var/run/docker.sock&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;WATCHTOWER_CLEANUP=true&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;WATCHTOWER_SCHEDULE=0 0 4 * * *&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The convenience is real, but so is the risk: Watchtower will auto-deploy a broken update at 4 AM while you're asleep. For a personal blog, that's probably fine. For anything you can't afford downtime on, update manually. At minimum, pin major versions (&lt;code&gt;ghost:5&lt;/code&gt; not &lt;code&gt;ghost:latest&lt;/code&gt;) so you get patch updates but not breaking major bumps.&lt;/p&gt;




&lt;h2&gt;
  
  
  8. Limit Container Capabilities
&lt;/h2&gt;

&lt;p&gt;Linux capabilities split root's power into pieces like &lt;code&gt;NET_BIND_SERVICE&lt;/code&gt; (bind to ports below 1024), &lt;code&gt;SYS_ADMIN&lt;/code&gt; (mount filesystems), and &lt;code&gt;NET_RAW&lt;/code&gt; (use raw sockets). Docker grants about 14 capabilities by default. Most containers don't need most of them.&lt;/p&gt;

&lt;p&gt;Drop everything and add back only what's required:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ghost&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghost:5&lt;/span&gt;
    &lt;span class="na"&gt;cap_drop&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ALL&lt;/span&gt;
    &lt;span class="na"&gt;cap_add&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;CHOWN&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;SETUID&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;SETGID&lt;/span&gt;
    &lt;span class="na"&gt;security_opt&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;no-new-privileges:true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;cap_drop: ALL&lt;/code&gt;&lt;/strong&gt; removes every capability.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;cap_add&lt;/code&gt;&lt;/strong&gt; gives back only what the application needs. You find out which ones by dropping all and reading the error messages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;no-new-privileges: true&lt;/code&gt;&lt;/strong&gt; prevents any process inside the container from gaining additional privileges through setuid binaries. One of the highest-value single lines you can add.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning:&lt;/strong&gt; Never mount the Docker socket (&lt;code&gt;/var/run/docker.sock&lt;/code&gt;) into a container unless you absolutely must. Access to the Docker socket is equivalent to root access on the host. If you must mount it (Watchtower requires it), treat that container as part of your trusted computing base.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  9. Configure Logging and Monitoring
&lt;/h2&gt;

&lt;p&gt;If a container gets compromised and you have no logs, you'll never know. Docker's default logging driver (&lt;code&gt;json-file&lt;/code&gt;) writes to JSON files on the host — until those files grow unbounded and fill your disk.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Configure log rotation:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ghost&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghost:5&lt;/span&gt;
    &lt;span class="na"&gt;logging&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;driver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;json-file&lt;/span&gt;
      &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;max-size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10m"&lt;/span&gt;
        &lt;span class="na"&gt;max-file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This caps each container's log at 30 MB total. You can also set this globally in &lt;code&gt;/etc/docker/daemon.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"log-driver"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"json-file"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"log-opts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"max-size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"10m"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"max-file"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"3"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Add health checks&lt;/strong&gt; so you monitor application health, not just port availability:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ghost&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghost:5&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;curl"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-f"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:2368/ghost/api/admin/site/"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;30s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
      &lt;span class="na"&gt;start_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;30s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This pings Ghost's API every 30 seconds. If it fails three times, Docker marks the container as unhealthy. &lt;a href="https://blog.byte-guard.net/building-byteguard-from-scratch-hetzner-vps/" rel="noopener noreferrer"&gt;Uptime Kuma&lt;/a&gt; can then alert you based on health status rather than just TCP connectivity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch container events&lt;/strong&gt; for anything unexpected:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker events &lt;span class="nt"&gt;--filter&lt;/span&gt; &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;container
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a self-hosted VPS where you're the only operator, any container event you didn't initiate is worth investigating.&lt;/p&gt;




&lt;h2&gt;
  
  
  10. The Compose Security Checklist
&lt;/h2&gt;

&lt;p&gt;Here's everything above condensed into a checklist for every new service you deploy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;□ Container runs as non-root (user: field or USER in Dockerfile)
□ Filesystem is read-only (read_only: true + explicit volume mounts)
□ Memory and CPU limits set (deploy.resources.limits)
□ PID limit set (pids_limit)
□ Capabilities dropped and selectively added (cap_drop: ALL)
□ no-new-privileges enabled (security_opt)
□ Secrets in .env with 600 permissions, not in compose file
□ Image version pinned (tag, not :latest)
□ Image scanned for CVEs (docker scout or trivy)
□ Container on a purpose-specific network, not default bridge
□ Only necessary ports exposed, bound to 127.0.0.1 if host-only
□ Log rotation configured (max-size + max-file)
□ Health check defined
□ Docker socket NOT mounted (unless required and justified)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A fully hardened compose service looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;myapp&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;myapp:1.2.3&lt;/span&gt;
    &lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1000:1000"&lt;/span&gt;
    &lt;span class="na"&gt;read_only&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;tmpfs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/tmp&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;app_data:/data&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;SECRET_KEY=${APP_SECRET_KEY}&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;backend&lt;/span&gt;
    &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;256M&lt;/span&gt;
          &lt;span class="na"&gt;cpus&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.5"&lt;/span&gt;
    &lt;span class="na"&gt;pids_limit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;50&lt;/span&gt;
    &lt;span class="na"&gt;cap_drop&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ALL&lt;/span&gt;
    &lt;span class="na"&gt;security_opt&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;no-new-privileges:true&lt;/span&gt;
    &lt;span class="na"&gt;logging&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;driver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;json-file&lt;/span&gt;
      &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;max-size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10m"&lt;/span&gt;
        &lt;span class="na"&gt;max-file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3"&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;curl"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-f"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:8080/health"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;30s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
      &lt;span class="na"&gt;start_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;15s&lt;/span&gt;

&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;driver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bridge&lt;/span&gt;
    &lt;span class="na"&gt;internal&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compare that to the typical self-hosting tutorial compose file — &lt;code&gt;image&lt;/code&gt;, &lt;code&gt;ports&lt;/code&gt;, &lt;code&gt;volumes&lt;/code&gt;, done. The difference is about 15 lines of YAML and a dramatically smaller attack surface.&lt;/p&gt;




&lt;h2&gt;
  
  
  Troubleshooting
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Container won't start after adding &lt;code&gt;user: "1000:1000"&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cause:&lt;/strong&gt; The volume data is owned by root and the non-root user can't write to it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix:&lt;/strong&gt; Run &lt;code&gt;sudo chown -R 1000:1000 /path/to/volume&lt;/code&gt; on the host before restarting.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Container crashes with &lt;code&gt;read_only: true&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cause:&lt;/strong&gt; The application tries to write to a directory that isn't mounted as a volume or tmpfs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix:&lt;/strong&gt; Check the container logs (&lt;code&gt;docker logs &amp;lt;container&amp;gt;&lt;/code&gt;) for "read-only file system" errors. Add the needed path as a &lt;code&gt;tmpfs&lt;/code&gt; mount or a named volume.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;cap_drop: ALL&lt;/code&gt; breaks the application&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cause:&lt;/strong&gt; The app needs specific Linux capabilities you haven't added back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix:&lt;/strong&gt; Start with &lt;code&gt;cap_drop: ALL&lt;/code&gt;, then add capabilities one at a time based on the error messages. Common ones: &lt;code&gt;CHOWN&lt;/code&gt;, &lt;code&gt;SETUID&lt;/code&gt;, &lt;code&gt;SETGID&lt;/code&gt;, &lt;code&gt;NET_BIND_SERVICE&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Docker bypasses UFW — port is open despite firewall rules&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cause:&lt;/strong&gt; Docker manipulates iptables directly, bypassing UFW/firewalld.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix:&lt;/strong&gt; Bind ports to localhost (&lt;code&gt;127.0.0.1:3001:3001&lt;/code&gt; instead of &lt;code&gt;3001:3001&lt;/code&gt;), or configure Docker to respect iptables by setting &lt;code&gt;"iptables": false&lt;/code&gt; in &lt;code&gt;/etc/docker/daemon.json&lt;/code&gt; (but this breaks Docker networking unless you add manual rules).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Health check keeps failing&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cause:&lt;/strong&gt; The health check command runs inside the container, which may not have &lt;code&gt;curl&lt;/code&gt; installed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix:&lt;/strong&gt; Use &lt;code&gt;wget -q --spider&lt;/code&gt; instead of &lt;code&gt;curl&lt;/code&gt;, or for minimal images use a language-native health endpoint check.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Docker security isn't a separate project — it's a layer in the same stack you're already building. If you &lt;a href="https://blog.byte-guard.net/harden-linux-vps-10-minutes/" rel="noopener noreferrer"&gt;hardened your Linux VPS&lt;/a&gt; with SSH keys, firewalls, and automatic updates, these ten practices are the natural next step: lock down the containers that actually run your services.&lt;/p&gt;

&lt;p&gt;Start with the three highest-impact changes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Run containers as non-root&lt;/strong&gt; — eliminates the most dangerous default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Isolate your networks&lt;/strong&gt; — stops lateral movement between services.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scan your images&lt;/strong&gt; — catches known vulnerabilities before they're running in production.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The stack powering this blog — &lt;a href="https://blog.byte-guard.net/building-byteguard-from-scratch-hetzner-vps/" rel="noopener noreferrer"&gt;the same one from Post #1&lt;/a&gt; — runs with these practices in place. It's not paranoia. It's the difference between self-hosting and self-pwning.&lt;/p&gt;

&lt;p&gt;If you're setting up a new Docker stack and need a VPS, I run all my projects on &lt;a href="https://www.hetzner.com/cloud/" rel="noopener noreferrer"&gt;Hetzner&lt;/a&gt; — here's &lt;a href="https://blog.byte-guard.net/best-vps-self-hosting-hetzner-contabo-vultr/" rel="noopener noreferrer"&gt;how the three major providers compare&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>security</category>
      <category>selfhosted</category>
      <category>devops</category>
    </item>
    <item>
      <title>How to Harden Your Linux VPS in 10 Minutes</title>
      <dc:creator>byteguard</dc:creator>
      <pubDate>Sun, 12 Apr 2026 12:55:49 +0000</pubDate>
      <link>https://dev.to/byte-guard/how-to-harden-your-linux-vps-in-10-minutes-5dgo</link>
      <guid>https://dev.to/byte-guard/how-to-harden-your-linux-vps-in-10-minutes-5dgo</guid>
      <description>&lt;p&gt;The moment you spin up a fresh Linux VPS, the clock starts ticking. Within hours — sometimes minutes — your IP shows up in scanner logs and bots begin trying default credentials, common SSH usernames, and known web exploits. I've watched a brand-new server log over four thousand brute-force SSH attempts in its first 24 hours of life.&lt;/p&gt;

&lt;p&gt;Most of those attacks are stoppable in 10 minutes of work. Here's the no-fluff checklist I run on every new VPS — the same one I used when I built &lt;a href="https://blog.byte-guard.net/building-byteguard-from-scratch-hetzner-vps/" rel="noopener noreferrer"&gt;byte-guard.net itself&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What You'll Need
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A fresh VPS running &lt;strong&gt;Ubuntu 22.04+&lt;/strong&gt; or &lt;strong&gt;Debian 11+&lt;/strong&gt; (most steps work on any modern distro)&lt;/li&gt;
&lt;li&gt;Root SSH access — ideally a just-provisioned server, before you've done anything else&lt;/li&gt;
&lt;li&gt;10 minutes&lt;/li&gt;
&lt;li&gt;An SSH key on your local machine (we'll generate one if you don't have it)&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; these commands assume &lt;code&gt;apt&lt;/code&gt;-based distros. If you're on Rocky, Alma, or RHEL, swap &lt;code&gt;apt&lt;/code&gt; for &lt;code&gt;dnf&lt;/code&gt; and &lt;code&gt;ufw&lt;/code&gt; for &lt;code&gt;firewalld&lt;/code&gt; — the principles are identical.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step 1 — Update Everything
&lt;/h2&gt;

&lt;p&gt;Bots love unpatched systems. The first thing to do on any new server is apply outstanding updates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;apt update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apt upgrade &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This pulls down the package index and installs every available update. On a fresh VPS this typically takes 1-2 minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 — Create a Non-Root User
&lt;/h2&gt;

&lt;p&gt;You should never SSH in as root for daily work. If your root account gets compromised, you've handed an attacker complete control. A regular user with &lt;code&gt;sudo&lt;/code&gt; access gives you the same power but keeps an audit trail and adds a small barrier between mistakes and disaster.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;adduser amine
usermod &lt;span class="nt"&gt;-aG&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;amine
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replace &lt;code&gt;amine&lt;/code&gt; with your username. &lt;code&gt;adduser&lt;/code&gt; will prompt you for a password — make it strong (a passphrase from &lt;code&gt;pwgen -s 32 1&lt;/code&gt; is excellent), but you'll mostly be using SSH keys after the next step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3 — Set Up SSH Key Authentication
&lt;/h2&gt;

&lt;p&gt;Passwords get brute-forced. Ed25519 SSH keys don't, in any practical sense. If you don't have one yet, generate it on your &lt;strong&gt;local machine&lt;/strong&gt;, not the server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-keygen &lt;span class="nt"&gt;-t&lt;/span&gt; ed25519 &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="s2"&gt;"your_email@example.com"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why &lt;code&gt;ed25519&lt;/code&gt; over &lt;code&gt;rsa&lt;/code&gt;? It's faster, smaller, and more modern. The default &lt;code&gt;rsa&lt;/code&gt; 3072-bit key is also fine, but &lt;code&gt;ed25519&lt;/code&gt; is the current best practice.&lt;/p&gt;

&lt;p&gt;Then copy it to the server, replacing the placeholder with your user and IP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-copy-id amine@your-server-ip
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now test it from a new terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh amine@your-server-ip
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should log in without being asked for a password. If that works, you're ready to lock down SSH itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4 — Lock Down SSH
&lt;/h2&gt;

&lt;p&gt;This is the single biggest security win. Open the SSH server config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;vim /etc/ssh/sshd_config
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Find and change these lines (uncomment them if needed):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ssh"&gt;&lt;code&gt;&lt;span class="k"&gt;PermitRootLogin&lt;/span&gt; &lt;span class="no"&gt;no&lt;/span&gt;
&lt;span class="k"&gt;PasswordAuthentication&lt;/span&gt; &lt;span class="no"&gt;no&lt;/span&gt;
&lt;span class="k"&gt;PubkeyAuthentication&lt;/span&gt; &lt;span class="no"&gt;yes&lt;/span&gt;
&lt;span class="k"&gt;ChallengeResponseAuthentication&lt;/span&gt; &lt;span class="no"&gt;no&lt;/span&gt;
&lt;span class="k"&gt;UsePAM&lt;/span&gt; &lt;span class="no"&gt;no&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What each does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;PermitRootLogin no&lt;/code&gt; — root cannot SSH in at all&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PasswordAuthentication no&lt;/code&gt; — only SSH keys work, no passwords&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PubkeyAuthentication yes&lt;/code&gt; — explicitly enable SSH keys (usually default but be explicit)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ChallengeResponseAuthentication no&lt;/code&gt; and &lt;code&gt;UsePAM no&lt;/code&gt; — close fallback authentication paths&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Don't close your current session yet.&lt;/strong&gt; Test that you can log in via key from a &lt;em&gt;new&lt;/em&gt; terminal first. If you've made a config mistake, you'll need that working session to fix it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Save and reload SSH:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl reload sshd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open a brand new terminal and SSH in as your user. If it works, your server is now key-only. Now you can safely close the old root session.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5 — Set Up UFW (the Firewall)
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ufw&lt;/code&gt; is Ubuntu's user-friendly firewall. It ships with most modern distros and just needs to be enabled with a sensible default policy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;ufw &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw default deny incoming
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw default allow outgoing
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow OpenSSH
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw &lt;span class="nb"&gt;enable&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify the rules:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw status verbose
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see only port 22 (SSH) open. If you're running a web server, also allow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow 80/tcp
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow 443/tcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Don't allow ports you're not actually using.&lt;/strong&gt; Every open port is a potential attack surface.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6 — Install fail2ban
&lt;/h2&gt;

&lt;p&gt;Even with key-only SSH, your logs will fill up with rejected brute-force attempts. &lt;code&gt;fail2ban&lt;/code&gt; watches the auth log and bans IPs that repeatedly fail to authenticate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;fail2ban &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; fail2ban
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Out of the box, the default config protects SSH. Check that the SSH jail is active:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;fail2ban-client status sshd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Status for the jail: sshd
|- Filter
|  |- Currently failed: 0
|  |- Total failed:     0
|  `- File list:        /var/log/auth.log
`- Actions
   |- Currently banned: 0
   |- Total banned:     0
   `- Banned IP list:
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To tighten the defaults (out of the box: 5 attempts, 10-minute ban), create a local override:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;vim /etc/fail2ban/jail.local
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[sshd]&lt;/span&gt;
&lt;span class="py"&gt;enabled&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;maxretry&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;3&lt;/span&gt;
&lt;span class="py"&gt;findtime&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;10m&lt;/span&gt;
&lt;span class="py"&gt;bantime&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;1h&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then reload:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart fail2ban
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three failed attempts in 10 minutes now earns a one-hour ban. Aggressive enough to deter bots, lenient enough that you can recover from your own typos.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7 — Enable Unattended Upgrades
&lt;/h2&gt;

&lt;p&gt;Security patches matter most when they actually get installed. Unattended upgrades automatically apply security updates so you don't have to remember to log in and &lt;code&gt;apt upgrade&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;unattended-upgrades &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;dpkg-reconfigure &lt;span class="nt"&gt;--priority&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;low unattended-upgrades
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Choose &lt;strong&gt;Yes&lt;/strong&gt; when prompted. This installs a systemd timer that runs daily and applies security updates only — not feature upgrades, so you won't get surprise breaking changes.&lt;/p&gt;

&lt;p&gt;Verify it's running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl status unattended-upgrades
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see &lt;code&gt;active (running)&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 8 — Sanity Check
&lt;/h2&gt;

&lt;p&gt;Run these to verify everything is in place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# SSH config — both should say "no"&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;sshd &lt;span class="nt"&gt;-T&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"permitrootlogin|passwordauthentication"&lt;/span&gt;

&lt;span class="c"&gt;# Firewall — should show only the ports you opened&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw status

&lt;span class="c"&gt;# fail2ban — should show the sshd jail as active&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;fail2ban-client status sshd

&lt;span class="c"&gt;# Unattended upgrades — should be active&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl is-active unattended-upgrades
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If everything checks out, your VPS is hardened against the most common automated attacks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bonus — Change the SSH Port (Optional)
&lt;/h2&gt;

&lt;p&gt;Moving SSH off port 22 doesn't add real security (it's security through obscurity), but it does massively cut log noise from drive-by scanners. Edit &lt;code&gt;/etc/ssh/sshd_config&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ssh"&gt;&lt;code&gt;&lt;span class="k"&gt;Port&lt;/span&gt; &lt;span class="m"&gt;2222&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then update UFW:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw delete allow OpenSSH
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow 2222/tcp
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl reload sshd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Connect with &lt;code&gt;ssh -p 2222 amine@your-server-ip&lt;/code&gt;. Add it to your &lt;code&gt;~/.ssh/config&lt;/code&gt; so you never type the port again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ssh"&gt;&lt;code&gt;&lt;span class="k"&gt;Host&lt;/span&gt; my-vps
    &lt;span class="k"&gt;HostName&lt;/span&gt; your-server-ip
    &lt;span class="k"&gt;User&lt;/span&gt; amine
    &lt;span class="k"&gt;Port&lt;/span&gt; &lt;span class="m"&gt;2222&lt;/span&gt;
    &lt;span class="k"&gt;IdentityFile&lt;/span&gt; ~/.ssh/id_ed25519
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now you can just type &lt;code&gt;ssh my-vps&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Does NOT Cover
&lt;/h2&gt;

&lt;p&gt;10 minutes gets you the essentials. It does not cover:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Application-layer security&lt;/strong&gt; — if you're running a web app, you still need to harden Nginx, your reverse proxy, your CMS, and so on&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Intrusion detection&lt;/strong&gt; — tools like AIDE or Wazuh for filesystem integrity and behavioral monitoring&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Centralized logging&lt;/strong&gt; — shipping logs to a separate server so an attacker who lands on the box can't quietly cover their tracks&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backups&lt;/strong&gt; — hardening means nothing if you can't restore after an incident&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I'll cover those in future posts. For now, you've blocked the overwhelming majority of automated attacks that hit any new VPS.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;If you're spinning up a VPS for self-hosting, check out the full build: &lt;a href="https://blog.byte-guard.net/building-byteguard-from-scratch-hetzner-vps/" rel="noopener noreferrer"&gt;How I Built byte-guard.net from Scratch on a Hetzner VPS&lt;/a&gt;. It uses every step in this post and adds Docker, a reverse proxy, and monitoring on top.&lt;/p&gt;

&lt;p&gt;I also wrote a deep dive on &lt;a href="https://blog.byte-guard.net/docker-security-best-practices/" rel="noopener noreferrer"&gt;Docker Security Best Practices&lt;/a&gt; — the container-level companion to this guide.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Quick recap — the 10-minute checklist:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;code&gt;apt update &amp;amp;&amp;amp; apt upgrade&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Create non-root user with sudo&lt;/li&gt;
&lt;li&gt;SSH key auth set up&lt;/li&gt;
&lt;li&gt;Root login + password auth disabled in &lt;code&gt;sshd_config&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;UFW firewall enabled, only the ports you need&lt;/li&gt;
&lt;li&gt;fail2ban watching the SSH jail&lt;/li&gt;
&lt;li&gt;Unattended security updates running&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Run this on every new server you build. After a few times you'll be doing it in closer to 5 minutes than 10.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://blog.byte-guard.net/harden-linux-vps-10-minutes/" rel="noopener noreferrer"&gt;byte-guard.net&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>linux</category>
      <category>security</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
