<?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.us-east-2.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>How to Set Up Nginx Proxy Manager with Docker</title>
      <dc:creator>byteguard</dc:creator>
      <pubDate>Tue, 23 Jun 2026 20:18:02 +0000</pubDate>
      <link>https://dev.to/byte-guard/how-to-set-up-nginx-proxy-manager-with-docker-5757</link>
      <guid>https://dev.to/byte-guard/how-to-set-up-nginx-proxy-manager-with-docker-5757</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://blog.byte-guard.net/nginx-proxy-manager-setup/" rel="noopener noreferrer"&gt;byte-guard.net&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Nginx Proxy Manager (NPM) is a free, open-source web GUI for Nginx that gives every self-hosted app a clean domain and an auto-renewing Let's Encrypt certificate without touching a config file. Run it as one Docker container, open ports 80 and 443, point your DNS at the server, and you can have your first HTTPS proxy host live in under five minutes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The moment you self-host more than one thing — Vaultwarden, a dashboard, a photo server — you hit the same wall: everything wants port 443, you're juggling certificates by hand, and exposing raw &lt;code&gt;IP:port&lt;/code&gt; to the internet is both ugly and unsafe. &lt;strong&gt;Nginx Proxy Manager&lt;/strong&gt; solves all three. It puts a friendly web UI on top of Nginx and Let's Encrypt, so you map &lt;code&gt;vault.yourdomain.com&lt;/code&gt; to an internal container in a few clicks and get a valid TLS certificate that renews itself.&lt;/p&gt;

&lt;p&gt;I run NPM in front of every public service on byte-guard.net, and on my Hetzner box the whole stack idles at about &lt;strong&gt;95 MB of RAM&lt;/strong&gt;. By the end of this guide you'll have it running in Docker, your first app behind HTTPS, and the admin panel locked down the way it should be. Tested on Ubuntu 24.04 LTS with Docker Engine 27.x and the &lt;code&gt;jc21/nginx-proxy-manager&lt;/code&gt; v2.12 image on &lt;strong&gt;23 June 2026&lt;/strong&gt;.&lt;/p&gt;

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

&lt;p&gt;Before you start, you'll need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A VPS or home server with a public IP (a small one is plenty — see my &lt;a href="https://blog.byte-guard.net/best-vps-self-hosting-hetzner-contabo-vultr/" rel="noopener noreferrer"&gt;VPS comparison&lt;/a&gt; for what I'd pick today).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker and Docker Compose&lt;/strong&gt; installed.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;domain name&lt;/strong&gt; with an &lt;code&gt;A&lt;/code&gt; record pointing at your server's IP (and a wildcard or per-app subdomain record for each service).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ports 80 and 443 open&lt;/strong&gt; on the server firewall.&lt;/li&gt;
&lt;li&gt;A hardened base server — if this is a fresh box, run through &lt;a href="https://blog.byte-guard.net/harden-linux-vps-10-minutes/" rel="noopener noreferrer"&gt;hardening your Linux VPS&lt;/a&gt; and &lt;a href="https://blog.byte-guard.net/ssh-hardening-guide/" rel="noopener noreferrer"&gt;SSH hardening&lt;/a&gt; first.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What Is Nginx Proxy Manager and Why Use It?
&lt;/h2&gt;

&lt;p&gt;Nginx Proxy Manager is a Dockerized reverse proxy that wraps the Nginx engine in a point-and-click dashboard with built-in Let's Encrypt automation. Instead of hand-writing &lt;code&gt;server&lt;/code&gt; blocks and running &lt;code&gt;certbot&lt;/code&gt;, you fill in a form: domain in, forward host and port out, tick "request a new SSL certificate," done.&lt;/p&gt;

&lt;p&gt;It's genuinely free and open source — there's no paid tier, and your only cost is the server it runs on. The trade-off versus a config-driven proxy is flexibility: if you want infrastructure-as-code or label-based routing, you'll prefer Traefik or Caddy. I break down exactly when to pick each one in &lt;a href="https://blog.byte-guard.net/nginx-proxy-manager-vs-traefik-vs-caddy/" rel="noopener noreferrer"&gt;Nginx Proxy Manager vs Traefik vs Caddy&lt;/a&gt;. For most self-hosters who want a GUI and zero config files, NPM is the fastest path to clean HTTPS.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Do You Install Nginx Proxy Manager with Docker?
&lt;/h2&gt;

&lt;p&gt;You install Nginx Proxy Manager by running a single Docker Compose service with two persistent volumes. Create a project folder and a &lt;code&gt;docker-compose.yml&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;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /opt/npm &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; /opt/npm
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# /opt/npm/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;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;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;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;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="c1"&gt;# HTTP — needed for sites + Let's Encrypt HTTP-01 challenge&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="c1"&gt;# HTTPS&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 (we lock this down in the Security section)&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;./data:/data&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./letsencrypt:/etc/letsencrypt&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Recent NPM versions ship with an embedded SQLite database, so you don't need a separate MariaDB container the way older tutorials show — fewer moving parts, less RAM. The two volumes are the only state that matters: &lt;code&gt;./data&lt;/code&gt; holds your hosts, users, and the SQLite file; &lt;code&gt;./letsencrypt&lt;/code&gt; holds your certificates. &lt;strong&gt;Back up those two folders and you've backed up everything.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Bring it up:&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;
docker compose logs &lt;span class="nt"&gt;-f&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first start takes 20–30 seconds while it initialises the database. When the logs settle, the container is ready.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; If you're on an older install with a &lt;code&gt;version:&lt;/code&gt; line at the top of the Compose file, drop it — the Compose spec ignores it now and newer Docker prints a warning.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  How Do You Log In and Add Your First Proxy Host?
&lt;/h2&gt;

&lt;p&gt;You log in at &lt;code&gt;http://&amp;lt;YOUR_SERVER_IP&amp;gt;:81&lt;/code&gt; with the default credentials, then create a Proxy Host pointing at your app. Open that URL in a browser and sign in with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Email:&lt;/strong&gt; &lt;code&gt;admin@example.com&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Password:&lt;/strong&gt; &lt;code&gt;changeme&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;NPM immediately forces you to set a real email and a strong password — do it now, because this panel controls every certificate and route on the box.&lt;/p&gt;

&lt;p&gt;To expose your first app, go to &lt;strong&gt;Hosts → Proxy Hosts → Add Proxy Host&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Domain Names:&lt;/strong&gt; &lt;code&gt;vault.yourdomain.com&lt;/code&gt; (the subdomain whose DNS already points at this server).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scheme:&lt;/strong&gt; &lt;code&gt;http&lt;/code&gt;, &lt;strong&gt;Forward Hostname / IP:&lt;/strong&gt; the container name or internal IP, &lt;strong&gt;Forward Port:&lt;/strong&gt; the app's internal port (for example &lt;code&gt;vaultwarden&lt;/code&gt; and &lt;code&gt;80&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Tick &lt;strong&gt;Block Common Exploits&lt;/strong&gt; and, for apps that need it, &lt;strong&gt;Websockets Support&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Switch to the &lt;strong&gt;SSL&lt;/strong&gt; tab → &lt;strong&gt;Request a new SSL Certificate&lt;/strong&gt; → enable &lt;strong&gt;Force SSL&lt;/strong&gt;, &lt;strong&gt;HTTP/2 Support&lt;/strong&gt;, and &lt;strong&gt;HSTS&lt;/strong&gt;. Agree to the Let's Encrypt terms and save.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A few seconds later NPM provisions the certificate and your app is live over HTTPS. For this to work, NPM has to reach the app — the cleanest way is to put both containers on the same Docker network and forward by container name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker network create proxy
docker network connect proxy npm
&lt;span class="c"&gt;# add `networks: [proxy]` to each app's compose service, then reference it by name&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole loop: add a DNS record, add a Proxy Host, get a certificate. Repeat for &lt;a href="https://blog.byte-guard.net/self-host-vaultwarden/" rel="noopener noreferrer"&gt;Vaultwarden&lt;/a&gt;, &lt;a href="https://blog.byte-guard.net/self-host-n8n-docker/" rel="noopener noreferrer"&gt;n8n&lt;/a&gt;, &lt;a href="https://blog.byte-guard.net/self-host-nextcloud-docker/" rel="noopener noreferrer"&gt;Nextcloud&lt;/a&gt;, or anything else you run.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Do You Secure Nginx Proxy Manager?
&lt;/h2&gt;

&lt;p&gt;You secure Nginx Proxy Manager by keeping its admin panel off the public internet, using strong credentials, and patching it regularly. The proxy ports (80/443) belong to the world; the admin port (81) does not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lock down port 81.&lt;/strong&gt; This is the single most important step. Either firewall it to your home IP, or rebind it to localhost and reach it over a VPN. Rebinding is the safer 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;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;127.0.0.1:81:81"&lt;/span&gt;   &lt;span class="c1"&gt;# admin UI no longer reachable from the internet&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After &lt;code&gt;docker compose up -d&lt;/code&gt;, reach the panel through an SSH tunnel (&lt;code&gt;ssh -L 8181:localhost:81 user@server&lt;/code&gt;) or, better, over your own &lt;a href="https://blog.byte-guard.net/wireguard-vpn-setup/" rel="noopener noreferrer"&gt;WireGuard VPN&lt;/a&gt; so the dashboard never touches the public internet at all.&lt;/p&gt;

&lt;p&gt;The rest of the checklist:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Use a long, unique admin password&lt;/strong&gt; (a password manager makes this painless).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Patch on a schedule&lt;/strong&gt; — &lt;code&gt;docker compose pull &amp;amp;&amp;amp; docker compose up -d&lt;/code&gt; pulls the latest image; NPM ships security fixes regularly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add an Access List&lt;/strong&gt; (Hosts → Access Lists) with HTTP Basic Auth or IP allow-listing in front of admin-only services.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run a host firewall&lt;/strong&gt; so only 80, 443, and SSH are open — see &lt;a href="https://blog.byte-guard.net/harden-linux-vps-10-minutes/" rel="noopener noreferrer"&gt;hardening your Linux VPS&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; NPM has no built-in two-factor auth, which is exactly why port 81 should never be publicly reachable. Treat network isolation as your second factor.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Troubleshooting Common Nginx Proxy Manager Problems
&lt;/h2&gt;

&lt;p&gt;Most NPM issues come down to networking or DNS. Here are the ones I hit most often.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can't reach the admin UI on port 81.&lt;/strong&gt; The port isn't published, a firewall is blocking it, or you're using &lt;code&gt;https://&lt;/code&gt; — the admin panel is plain &lt;code&gt;http&lt;/code&gt; on 81. Confirm with &lt;code&gt;docker compose ps&lt;/code&gt; that the port is mapped, and check your firewall rules.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SSL certificate won't issue.&lt;/strong&gt; Let's Encrypt's HTTP-01 challenge needs port 80 reachable from the internet and DNS that has actually propagated. If you're behind Cloudflare's orange-cloud proxy, the challenge fails — grey-cloud the record during issuance or switch to a DNS-challenge certificate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;502 Bad Gateway.&lt;/strong&gt; NPM can't reach your app. The forward hostname or port is wrong, or the two containers aren't on the same Docker network. Use the container name (not &lt;code&gt;localhost&lt;/code&gt;) as the forward host and put both on a shared network.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;WebSocket apps (like code editors or chat) won't connect.&lt;/strong&gt; Edit the Proxy Host and enable &lt;strong&gt;Websockets Support&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lost the admin password.&lt;/strong&gt; NPM re-creates the default &lt;code&gt;admin@example.com&lt;/code&gt; / &lt;code&gt;changeme&lt;/code&gt; account if the users table is empty. Back up &lt;code&gt;./data/database.sqlite&lt;/code&gt;, remove your user row with the &lt;code&gt;sqlite3&lt;/code&gt; CLI, restart the container, and log in with the defaults — then set a new password right away.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nginx Proxy Manager FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is Nginx Proxy Manager free?
&lt;/h3&gt;

&lt;p&gt;Yes. Nginx Proxy Manager is free and open source — there is no paid tier or license fee. Your only cost is the server you run it on, and a small VPS is plenty.&lt;/p&gt;

&lt;h3&gt;
  
  
  What ports does Nginx Proxy Manager use?
&lt;/h3&gt;

&lt;p&gt;The admin dashboard runs on port 81, while the proxy itself listens on 80 (HTTP) and 443 (HTTPS). Expose 80 and 443 to the internet for Let's Encrypt and your sites, but keep port 81 restricted to your LAN or VPN.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the default Nginx Proxy Manager login?
&lt;/h3&gt;

&lt;p&gt;The default credentials are email &lt;code&gt;admin@example.com&lt;/code&gt; and password &lt;code&gt;changeme&lt;/code&gt;. NPM forces you to set a real email and a strong password the first time you sign in.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I run Nginx Proxy Manager on Proxmox?
&lt;/h3&gt;

&lt;p&gt;Yes. Run it as a Docker container inside a VM or LXC on Proxmox — the same Compose file above works unchanged. Many homelabbers use the Proxmox community helper scripts to spin it up in minutes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is Nginx Proxy Manager secure to expose to the internet?
&lt;/h3&gt;

&lt;p&gt;The proxy ports (80/443) are designed to face the internet; the admin panel on port 81 is not. As long as you rebind or firewall port 81, keep the image patched, and use a strong password, NPM is safe to run as your public front door.&lt;/p&gt;

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

&lt;p&gt;Nginx Proxy Manager turns the fiddly parts of self-hosting — TLS certificates, renewals, and clean domains — into a form you fill out once per app. You've now got it running in Docker, your first service behind auto-renewing HTTPS, and the admin panel isolated from the public internet. From here, point a few more subdomains at it and put the rest of your stack behind it.&lt;/p&gt;

&lt;p&gt;If you're still choosing a proxy, read &lt;a href="https://blog.byte-guard.net/nginx-proxy-manager-vs-traefik-vs-caddy/" rel="noopener noreferrer"&gt;Nginx Proxy Manager vs Traefik vs Caddy&lt;/a&gt; before you commit. And if you'd rather not wire all this up yourself, &lt;a href="https://blog.byte-guard.net/vps-setup/" rel="noopener noreferrer"&gt;I'll set up your VPS and reverse proxy for you&lt;/a&gt; — Vaultwarden, n8n, or Nextcloud behind NPM, done for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Affiliate disclosure
&lt;/h2&gt;

&lt;p&gt;This post links to a VPS comparison that contains affiliate links. I only recommend providers I actually run production workloads on, and it costs you nothing extra.&lt;/p&gt;

&lt;p&gt;— &lt;em&gt;enim&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nginxproxymanager</category>
      <category>reverseproxy</category>
      <category>docker</category>
      <category>selfhosting</category>
    </item>
    <item>
      <title>How I Built byte-guard.net from Scratch on a Hetzner VPS</title>
      <dc:creator>byteguard</dc:creator>
      <pubDate>Tue, 23 Jun 2026 18:24:09 +0000</pubDate>
      <link>https://dev.to/byte-guard/how-i-built-byte-guardnet-from-scratch-on-a-hetzner-vps-54ia</link>
      <guid>https://dev.to/byte-guard/how-i-built-byte-guardnet-from-scratch-on-a-hetzner-vps-54ia</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://blog.byte-guard.net/building-byteguard-from-scratch-hetzner-vps/" rel="noopener noreferrer"&gt;byte-guard.net&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I wanted a security blog that I fully controlled — no managed platforms, no vendor lock-in, just a VPS and a stack I could understand end to end. This is how I built byte-guard.net from scratch on a &lt;a href="https://blog.byte-guard.net/hetzner-review-2026/" rel="noopener noreferrer"&gt;Hetzner&lt;/a&gt; cloud server using Ghost, &lt;a href="https://blog.byte-guard.net/nginx-proxy-manager-vs-traefik-vs-caddy/" rel="noopener noreferrer"&gt;Nginx Proxy Manager&lt;/a&gt;, and &lt;a href="https://blog.byte-guard.net/uptime-kuma-setup-guide/" rel="noopener noreferrer"&gt;Uptime Kuma&lt;/a&gt;, all running in Docker.&lt;/p&gt;
&lt;h2 id="why-self-host"&gt;Why Self-Host?&lt;/h2&gt;
&lt;p&gt;Platforms like Medium or WordPress.com are fine for getting started, but they come with trade-offs: limited customization, no control over your data, and someone else's rules about what you can publish. For a security-focused blog, I wanted:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Full ownership of my content and data&lt;/li&gt;
&lt;li&gt;The ability to run additional tools on the same server (status pages, paste bins, CVE trackers)&lt;/li&gt;
&lt;li&gt;A setup I could document and teach others to replicate&lt;/li&gt;
&lt;li&gt;Low monthly cost&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="choosing-the-server"&gt;Choosing the Server&lt;/h2&gt;
&lt;p&gt;I went with a &lt;strong&gt;Hetzner CPX22&lt;/strong&gt; in their Helsinki data center. The specs:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;3 vCPUs (AMD)&lt;/li&gt;
&lt;li&gt;4 GB RAM&lt;/li&gt;
&lt;li&gt;80 GB NVMe storage&lt;/li&gt;
&lt;li&gt;20 TB traffic&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is more than enough for a Ghost blog, a reverse proxy, and a monitoring dashboard. Hetzner's pricing is hard to beat for what you get, and their Helsinki location gives solid latency across Europe.&lt;/p&gt;
&lt;h2 id="the-stack"&gt;The Stack&lt;/h2&gt;
&lt;p&gt;Here's what runs on the server:&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;Purpose&lt;/th&gt;
&lt;th&gt;Port&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;Blog engine&lt;/td&gt;
&lt;td&gt;2368&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nginx Proxy Manager&lt;/td&gt;
&lt;td&gt;Reverse proxy + SSL&lt;/td&gt;
&lt;td&gt;80, 443, 81&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Uptime Kuma&lt;/td&gt;
&lt;td&gt;Status/monitoring page&lt;/td&gt;
&lt;td&gt;3001&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;


&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fblog.byte-guard.net%2Fcontent%2Fimages%2F2026%2F04%2Farchitecture.png" alt="Architecture diagram: Visitor traffic enters via Nginx Proxy Manager on ports 80 and 443, which reverse-proxies to Ghost (blog.byte-guard.net:2368) and Uptime Kuma (status.byte-guard.net:3001) over a shared byteguard Docker bridge network on a Hetzner CPX22 VPS. Ghost persists to SQLite. Let's Encrypt provides SSL certificates to NPM." width="800" height="276"&gt;&lt;p&gt;The byte-guard.net stack: NPM terminates TLS and reverse-proxies Ghost and Uptime Kuma over a shared Docker bridge network.&lt;/p&gt;&lt;p&gt;Everything runs in Docker, managed by a single Docker Compose file at &lt;code&gt;/opt/byteguard/docker-compose.yml&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Want the full list of tools and services powering this site — VPS providers, security tools, the self-hosted stack, and the books I learned from? See my &lt;a href="https://blog.byte-guard.net/resources/" rel="noopener noreferrer"&gt;resources page&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id="step-1-initial-server-setup"&gt;Step 1: Initial Server Setup&lt;/h2&gt;
&lt;p&gt;After provisioning the VPS from the Hetzner Cloud console, I SSH'd in and did the basics:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Update the system&lt;br&gt;
apt update &amp;amp;&amp;amp; apt upgrade -y

&lt;h1&gt;
  
  
  Create a non-root user
&lt;/h1&gt;

&lt;p&gt;adduser $user&lt;br&gt;
usermod -aG sudo $user&lt;/p&gt;

&lt;h1&gt;
  
  
  Set up SSH key authentication
&lt;/h1&gt;

&lt;p&gt;mkdir -p /home/$user/.ssh&lt;br&gt;
cp ~/.ssh/authorized_keys /home/amine/.ssh/&lt;br&gt;
chown -R amine:amine /home/$user/.ssh&lt;/p&gt;

&lt;h1&gt;
  
  
  Disable password authentication
&lt;/h1&gt;

&lt;/code&gt;&lt;p&gt;&lt;code&gt;sed -i 's/#PasswordAuthentication yes/PasswordAuthentication no/' /etc/ssh/sshd_config&lt;br&gt;&lt;br&gt;
systemctl restart sshd&lt;/code&gt;&lt;/p&gt;&lt;/pre&gt;
&lt;p&gt;Then I configured the firewall:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ufw allow OpenSSH&lt;br&gt;&lt;br&gt;
ufw allow 80/tcp&lt;br&gt;&lt;br&gt;
ufw allow 443/tcp&lt;br&gt;&lt;br&gt;
ufw enable&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id="step-2-install-docker-and-docker-compose"&gt;Step 2: Install Docker and Docker Compose&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# Install Docker&lt;br&gt;&lt;br&gt;
curl -fsSL &lt;a href="https://get.docker.com" rel="noopener noreferrer"&gt;&lt;/a&gt;&lt;a href="https://get.docker.com" rel="noopener noreferrer"&gt;https://get.docker.com&lt;/a&gt; | sh&lt;br&gt;&lt;br&gt;
usermod -aG docker amine

&lt;h1&gt;
  
  
  Verify
&lt;/h1&gt;

&lt;/code&gt;&lt;p&gt;&lt;code&gt;docker --version&lt;br&gt;&lt;br&gt;
docker compose version&lt;/code&gt;&lt;/p&gt;&lt;/pre&gt;
&lt;h2 id="step-3-set-up-the-project-directory"&gt;Step 3: Set Up the Project Directory&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;mkdir -p /opt/byteguard&lt;br&gt;&lt;br&gt;
cd /opt/byteguard&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id="step-4-write-the-docker-compose-file"&gt;Step 4: Write the Docker Compose File&lt;/h2&gt;
&lt;p&gt;Here's the &lt;code&gt;docker-compose.yml&lt;/code&gt; that powers the entire site:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;version: "3.8"

&lt;p&gt;services:&lt;br&gt;
  ghost:&lt;br&gt;
    image: ghost:5&lt;br&gt;
    container_name: ghost&lt;br&gt;
    restart: always&lt;br&gt;
    environment:&lt;br&gt;
      url: &lt;a href="https://blog.byte-guard.net" rel="noopener noreferrer"&gt;https://blog.byte-guard.net&lt;/a&gt;&lt;br&gt;
      database_&lt;em&gt;client: sqlite3&lt;br&gt;
      database&lt;/em&gt;&lt;em&gt;connection&lt;/em&gt;_filename: /var/lib/ghost/content/data/ghost.db&lt;br&gt;
    volumes:&lt;br&gt;
      - ghost_data:/var/lib/ghost/content&lt;br&gt;
    networks:&lt;br&gt;
      - byteguard&lt;/p&gt;

&lt;p&gt;npm:&lt;br&gt;
    image: jc21/nginx-proxy-manager:latest&lt;br&gt;
    container_name: nginx-proxy-manager&lt;br&gt;
    restart: always&lt;br&gt;
    ports:&lt;br&gt;
      - "80:80"&lt;br&gt;
      - "443:443"&lt;br&gt;
      - "81:81"&lt;br&gt;
    volumes:&lt;br&gt;
      - npm_data:/data&lt;br&gt;
      - npm_letsencrypt:/etc/letsencrypt&lt;br&gt;
    networks:&lt;br&gt;
      - byteguard&lt;/p&gt;

&lt;p&gt;uptime-kuma:&lt;br&gt;
    image: louislam/uptime-kuma:1&lt;br&gt;
    container_name: uptime-kuma&lt;br&gt;
    restart: always&lt;br&gt;
    volumes:&lt;br&gt;
      - kuma_data:/app/data&lt;br&gt;
    networks:&lt;br&gt;
      - byteguard&lt;/p&gt;

&lt;p&gt;volumes:&lt;br&gt;
  ghost_data:&lt;br&gt;
  npm_data:&lt;br&gt;
  npm_letsencrypt:&lt;br&gt;
  kuma_data:&lt;/p&gt;

&lt;/code&gt;&lt;p&gt;&lt;code&gt;networks:&lt;br&gt;&lt;br&gt;
  byteguard:&lt;br&gt;&lt;br&gt;
    driver: bridge&lt;/code&gt;&lt;/p&gt;&lt;/pre&gt;
&lt;p&gt;Key decisions here:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SQLite over MySQL&lt;/strong&gt; — Ghost supports SQLite natively, and for a single-author blog the performance is identical with zero extra resource usage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shared Docker network&lt;/strong&gt; — All services communicate internally over the &lt;code&gt;byteguard&lt;/code&gt; bridge network. Only Nginx Proxy Manager exposes ports to the internet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Named volumes&lt;/strong&gt; — Data persists across container rebuilds.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Bring it all up:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cd /opt/byteguard&lt;br&gt;&lt;br&gt;
docker compose up -d&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id="step-5-configure-dns"&gt;Step 5: Configure DNS&lt;/h2&gt;
&lt;p&gt;Over at my domain registrar, I added three A records pointing to the Hetzner VPS IP:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;blog.byte-guard.net    → &amp;lt;VPS_IP&amp;gt;&lt;br&gt;&lt;br&gt;
status.byte-guard.net  → &amp;lt;VPS_IP&amp;gt;&lt;br&gt;&lt;br&gt;
byte-guard.net         → &amp;lt;VPS_IP&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id="step-6-configure-nginx-proxy-manager"&gt;Step 6: Configure Nginx Proxy Manager&lt;/h2&gt;
&lt;p&gt;Nginx Proxy Manager (NPM) has a web UI at port 81. After logging in with the default credentials and changing them immediately, I set up proxy hosts:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;For Ghost (blog.byte-guard.net):&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Forward hostname: &lt;code&gt;ghost&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Forward port: &lt;code&gt;2368&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;SSL: Request a new Let's Encrypt certificate, force SSL&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;For Uptime Kuma (status.byte-guard.net):&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Forward hostname: &lt;code&gt;uptime-kuma&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Forward port: &lt;code&gt;3001&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;SSL: Request a new Let's Encrypt certificate, force SSL&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Because all containers share the &lt;code&gt;byteguard&lt;/code&gt; Docker network, NPM can reach them by container name. No need to expose individual service ports to the host.&lt;/p&gt;
&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fblog.byte-guard.net%2Fcontent%2Fimages%2F2026%2F04%2Fnpm-proxy-host.png" alt="Nginx Proxy Manager dashboard showing the configured proxy hosts for blog.byte-guard.net (Ghost on port 2368) and status.byte-guard.net (Uptime Kuma on port 3001), both with Let's Encrypt SSL and public access status." width="800" height="363"&gt;&lt;p&gt;Nginx Proxy Manager after the Ghost and Uptime Kuma proxy hosts are configured. NPM talks to each container by name over the shared Docker network.&lt;/p&gt;&lt;h2 id="step-7-set-up-uptime-kuma"&gt;Step 7: Set Up Uptime Kuma&lt;/h2&gt;
&lt;p&gt;With Uptime Kuma running at &lt;code&gt;status.byte-guard.net&lt;/code&gt;, I added monitors for:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;blog.byte-guard.net&lt;/code&gt; — HTTP(S) check every 60 seconds&lt;/li&gt;
&lt;li&gt;Ghost container — Docker host check&lt;/li&gt;
&lt;li&gt;The VPS itself — Ping check&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I also set up a public status page so anyone can check if the blog is up.&lt;/p&gt;
&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fblog.byte-guard.net%2Fcontent%2Fimages%2F2026%2F04%2Fuptime-kuma.png" alt="Uptime Kuma dashboard showing two active monitors for byte-guard.net and blog.byte-guard.net, both reporting UP status with green indicators, and zero incidents across Up, Down, Maintenance, Unknown, and Pause categories." width="799" height="308"&gt;&lt;p&gt;Uptime Kuma watching byte-guard.net and blog.byte-guard.net — two green monitors, zero incidents. Takes about 5 minutes to set up.&lt;/p&gt;&lt;h2 id="step-8-lock-down-the-admin-panel"&gt;Step 8: Lock Down the Admin Panel&lt;/h2&gt;
&lt;p&gt;A few important security steps after everything was running:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Restrict NPM admin panel to my IP (via UFW)&lt;br&gt;&lt;br&gt;
ufw allow from &amp;lt;MY_IP&amp;gt; to any port 81

&lt;h1&gt;
  
  
  Block port 81 from everyone else
&lt;/h1&gt;

&lt;/code&gt;&lt;p&gt;&lt;code&gt;ufw deny 81/tcp&lt;/code&gt;&lt;/p&gt;&lt;/pre&gt;
&lt;p&gt;I also enabled 2FA on the Ghost admin panel and Nginx Proxy Manager.&lt;/p&gt;
&lt;h2 id="what-it-costs"&gt;What It Costs&lt;/h2&gt;


&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Monthly Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hetzner CPX22&lt;/td&gt;
&lt;td&gt;~€7.50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Domain (byte-guard.net)&lt;/td&gt;
&lt;td&gt;~€1/mo amortized&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSL certificates&lt;/td&gt;
&lt;td&gt;Free (Let's Encrypt)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~€8.50/mo&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;


&lt;p&gt;A fully self-hosted blog with monitoring and SSL for under €10/month.&lt;/p&gt;
&lt;h2 id="whats-next"&gt;What's Next&lt;/h2&gt;
&lt;p&gt;This is just the foundation. Upcoming additions:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;tools.byte-guard.net&lt;/strong&gt; — Security utilities built with FastAPI&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;cve.byte-guard.net&lt;/strong&gt; — A lightweight CVE tracker&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;paste.byte-guard.net&lt;/strong&gt; — A self-hosted paste bin for sharing code&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Arabic translations&lt;/strong&gt; — Bilingual content starting in a few months&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;N8N automation&lt;/strong&gt; — Already running on a separate Contabo VPS for content workflows&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="lessons-learned"&gt;Lessons Learned&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Start simple.&lt;/strong&gt; Docker Compose with SQLite is plenty for a blog. You can always add complexity later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nginx Proxy Manager saves hours.&lt;/strong&gt; Manually configuring Nginx and Certbot is educational but NPM gets you to the same place with a GUI and auto-renewal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monitor from day one.&lt;/strong&gt; Uptime Kuma took 5 minutes to set up and has already caught a DNS propagation issue I would have missed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Document everything.&lt;/strong&gt; This blog post is as much for future-me as it is for you. When I inevitably need to rebuild or migrate, I'll have a complete reference.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you're thinking about self-hosting your own blog, I hope this helps you get started. The whole setup took an afternoon, and running costs are minimal. Feel free to check out the &lt;a href="https://status.byte-guard.net" rel="noopener noreferrer"&gt;status page&lt;/a&gt; to see it all in action.&lt;/p&gt;
&lt;p&gt;Once your server is running, I'd recommend &lt;a href="https://blog.byte-guard.net/harden-linux-vps-10-minutes/" rel="noopener noreferrer"&gt;hardening your VPS&lt;/a&gt; as the very next step — it takes 10 minutes and closes the most common attack vectors. Then lock down your containers with &lt;a href="https://blog.byte-guard.net/docker-security-best-practices/" rel="noopener noreferrer"&gt;Docker security best practices&lt;/a&gt; so your self-hosted stack isn't running with dangerous defaults.&lt;/p&gt;
&lt;blockquote&gt;
&lt;strong&gt;Free download:&lt;/strong&gt; I compiled every hardening step into a printable checklist you can pin next to your monitor. Grab the &lt;a href="https://blog.byte-guard.net/content/files/2026/04/server-hardening-checklist.pdf?v=20260611b" rel="noopener noreferrer"&gt;Server Hardening Checklist (PDF)&lt;/a&gt; — no signup required.&lt;/blockquote&gt;


</description>
      <category>vps</category>
      <category>selfhosting</category>
      <category>ghost</category>
      <category>docker</category>
    </item>
    <item>
      <title>How to Use Nmap Like a Pro — Beginner's Guide</title>
      <dc:creator>byteguard</dc:creator>
      <pubDate>Tue, 23 Jun 2026 18:23:20 +0000</pubDate>
      <link>https://dev.to/byte-guard/how-to-use-nmap-like-a-pro-beginners-guide-11hb</link>
      <guid>https://dev.to/byte-guard/how-to-use-nmap-like-a-pro-beginners-guide-11hb</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://blog.byte-guard.net/nmap-tutorial-beginners/" rel="noopener noreferrer"&gt;byte-guard.net&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every time I set up a new server, the first thing I do before hardening anything is figure out what's actually exposed. Open ports I forgot about, services running on default configs, legacy daemons nobody disabled — that's the stuff attackers find first. &lt;strong&gt;Nmap&lt;/strong&gt; (Network Mapper) is the tool that answers the question: "What does my network look like from the outside?"&lt;/p&gt;
&lt;p&gt;This guide is part of our &lt;a href="https://blog.byte-guard.net/best-free-security-tools-2026/" rel="noopener noreferrer"&gt;&lt;strong&gt;Security Tools Series&lt;/strong&gt;&lt;/a&gt; — hands-on guides for the tools every security-minded developer needs.&lt;/p&gt;
&lt;p&gt;This &lt;strong&gt;nmap tutorial for beginners&lt;/strong&gt; walks you through everything from installation to advanced NSE scripts. By the end, you'll know how to scan your own infrastructure, interpret the results, and use that information to lock things down.&lt;/p&gt;
&lt;blockquote&gt;
&lt;strong&gt;Legal Warning:&lt;/strong&gt; Only scan networks and systems you own or have explicit written permission to test. Unauthorized scanning is illegal in most jurisdictions and can get you fired, fined, or prosecuted. This entire tutorial assumes you're scanning your own lab or have a signed authorization.&lt;/blockquote&gt;
&lt;h2 id="prerequisites"&gt;Prerequisites&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;A Linux machine (Ubuntu/Debian used in examples) or macOS&lt;/li&gt;
&lt;li&gt;Root/sudo access (many scan types require it)&lt;/li&gt;
&lt;li&gt;A target system you &lt;strong&gt;own&lt;/strong&gt; or have permission to scan (a second VM, your VPS, etc.)&lt;/li&gt;
&lt;li&gt;Basic comfort with the terminal&lt;/li&gt;
&lt;li&gt;If you need a VPS to practice on, I covered setting one up in my &lt;a href="https://blog.byte-guard.net/building-byteguard-from-scratch-hetzner-vps/" rel="noopener noreferrer"&gt;Hetzner VPS build guide&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="installing-nmap"&gt;Installing Nmap&lt;/h2&gt;
&lt;p&gt;Nmap is available in every major package manager. Here's how to get it on common systems.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Ubuntu/Debian:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo apt update &amp;amp;&amp;amp; sudo apt install nmap -y
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Fedora/RHEL:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo dnf install nmap -y
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;macOS (Homebrew):&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;brew install nmap
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Arch Linux:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo pacman -S nmap
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Verify the installation:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;nmap --version
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You should see output showing Nmap version 7.9x or later, along with the compiled libraries (Npcap/libpcap, OpenSSL, etc.). If &lt;code&gt;nmap&lt;/code&gt; isn't found, make sure &lt;code&gt;/usr/bin&lt;/code&gt; or &lt;code&gt;/usr/local/bin&lt;/code&gt; is in your &lt;code&gt;$PATH&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id="understanding-how-nmap-scanning-works"&gt;Understanding How Nmap Scanning Works&lt;/h2&gt;
&lt;p&gt;Before firing off commands, it helps to understand what Nmap actually does under the hood.&lt;/p&gt;
&lt;p&gt;When you scan a target, Nmap sends carefully crafted packets to ports on the target machine and analyzes the responses. Based on how the target replies (or doesn't), Nmap determines whether a port is:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Open&lt;/strong&gt; — A service is actively listening and accepting connections.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Closed&lt;/strong&gt; — The port is reachable but no service is listening. The host responds with a RST packet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filtered&lt;/strong&gt; — Nmap can't determine the state because a firewall is silently dropping packets. No response comes back, or an ICMP unreachable message does.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This distinction matters. An open port is an attack surface. A filtered port means a firewall is doing its job. A closed port means the service isn't running but the port is reachable — which still tells an attacker something about the system.&lt;/p&gt;
&lt;h2 id="basic-nmap-scans-for-beginners"&gt;Basic Nmap Scans for Beginners&lt;/h2&gt;
&lt;p&gt;Let's start with the fundamentals. I'll use &lt;code&gt;192.168.1.100&lt;/code&gt; as an example target — replace it with your own IP.&lt;/p&gt;
&lt;h3 id="simple-host-discovery-ping-scan"&gt;Simple Host Discovery (Ping Scan)&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;nmap -sn 192.168.1.0/24
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This sends ICMP echo requests, TCP SYN to port 443, TCP ACK to port 80, and ICMP timestamps to every host in the subnet. It tells you &lt;strong&gt;which hosts are alive&lt;/strong&gt; without scanning any ports. Useful for mapping your local network.&lt;/p&gt;
&lt;p&gt;Expected output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Nmap scan report for 192.168.1.1
Host is up (0.0023s latency).
Nmap scan report for 192.168.1.100
Host is up (0.0015s latency).
Nmap done: 256 IP addresses (2 hosts up) scanned in 3.42 seconds
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id="default-port-scan"&gt;Default Port Scan&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;nmap 192.168.1.100
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Without any flags, Nmap scans the &lt;strong&gt;top 1,000 most common TCP ports&lt;/strong&gt; using a SYN scan (if run as root) or a connect scan (as a regular user). This is the scan most beginners start with.&lt;/p&gt;
&lt;h3 id="scanning-specific-ports"&gt;Scanning Specific Ports&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# Single port
nmap -p 22 192.168.1.100

# Port range
nmap -p 1-1000 192.168.1.100

# Multiple specific ports
nmap -p 22,80,443,8080 192.168.1.100

# All 65535 ports
nmap -p- 192.168.1.100
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;-p-&lt;/code&gt; flag is important. The default 1,000-port scan misses services running on non-standard ports. If you're auditing a server, &lt;strong&gt;always run a full port scan&lt;/strong&gt; at least once. It takes longer (a few minutes depending on the network), but it catches things like databases on port 27017, admin panels on port 8443, or forgotten dev servers on high ports.&lt;/p&gt;
&lt;h3 id="tcp-syn-scan-stealth-scan"&gt;TCP SYN Scan (Stealth Scan)&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;sudo nmap -sS 192.168.1.100
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The SYN scan sends a SYN packet and waits for a SYN-ACK (open) or RST (closed). It never completes the TCP handshake, which is why it's called "half-open" or "stealth" scanning. This is the default when running as root and is the fastest reliable scan type.&lt;/p&gt;
&lt;blockquote&gt;
&lt;strong&gt;Note:&lt;/strong&gt; Despite the name "stealth scan," modern intrusion detection systems (IDS) detect SYN scans easily. Don't rely on this for actual stealth — it's called that for historical reasons.&lt;/blockquote&gt;
&lt;h3 id="udp-scan"&gt;UDP Scan&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;sudo nmap -sU --top-ports 100 192.168.1.100
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;UDP scanning is painfully slow because UDP is connectionless — Nmap has to wait for timeouts to determine if a port is filtered. I typically limit it to the top 100 ports. Services like &lt;strong&gt;DNS (53)&lt;/strong&gt;, &lt;strong&gt;SNMP (161)&lt;/strong&gt;, &lt;strong&gt;DHCP (67/68)&lt;/strong&gt;, and &lt;strong&gt;NTP (123)&lt;/strong&gt; use UDP, so skipping UDP scanning entirely means you're missing real attack surface.&lt;/p&gt;
&lt;h2 id="nmap-service-and-version-detection"&gt;Nmap Service and Version Detection&lt;/h2&gt;
&lt;p&gt;Knowing a port is open is step one. Knowing &lt;strong&gt;what's running on it&lt;/strong&gt; is where things get useful.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;nmap -sV 192.168.1.100
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;-sV&lt;/code&gt; flag probes open ports to determine the service name and version. Instead of just seeing "port 22 open," you'll see something like:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;PORT    STATE SERVICE  VERSION
22/tcp  open  ssh      OpenSSH 9.6p1 Ubuntu 3ubuntu13
80/tcp  open  http     nginx 1.24.0
443/tcp open  ssl/http nginx 1.24.0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is critical for security auditing. If you see &lt;strong&gt;OpenSSH 7.2&lt;/strong&gt; running, you know it's vulnerable to several CVEs. If you see &lt;strong&gt;Apache 2.4.49&lt;/strong&gt;, that's the path traversal vulnerability (CVE-2021-41773). Version detection turns a port list into an actionable vulnerability report.&lt;/p&gt;
&lt;p&gt;You can control the intensity of version detection:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Light probe (faster, less accurate)
nmap -sV --version-intensity 2 192.168.1.100

# Aggressive probe (slower, more accurate)
nmap -sV --version-intensity 9 192.168.1.100
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id="nmap-os-detection"&gt;Nmap OS Detection&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;sudo nmap -O 192.168.1.100
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;OS detection uses TCP/IP stack fingerprinting — analyzing things like TCP window sizes, TTL values, and how the target handles specific packet flags. It requires at least one open and one closed port to work reliably.&lt;/p&gt;
&lt;p&gt;For a more aggressive and comprehensive scan that combines OS detection, version detection, script scanning, and traceroute:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo nmap -A 192.168.1.100
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;-A&lt;/code&gt; flag is the "aggressive" scan. I use it when auditing my own servers because it gives the most complete picture in one command. Don't use it on networks you're trying to be quiet on — it's noisy.&lt;/p&gt;
&lt;h2 id="nmap-nse-scripts-%E2%80%94-the-real-power"&gt;Nmap NSE Scripts — The Real Power&lt;/h2&gt;
&lt;p&gt;The &lt;strong&gt;Nmap Scripting Engine (NSE)&lt;/strong&gt; is what separates Nmap from a basic port scanner. NSE ships with hundreds of scripts for vulnerability detection, brute forcing, service enumeration, and more.&lt;/p&gt;
&lt;h3 id="running-default-scripts"&gt;Running Default Scripts&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;nmap -sC 192.168.1.100
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;-sC&lt;/code&gt; flag runs the "default" category of scripts — things like grabbing SSH host keys, checking HTTP titles, enumerating SSL certificates. These are considered safe and non-intrusive.&lt;/p&gt;
&lt;h3 id="running-specific-scripts"&gt;Running Specific Scripts&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# Check for a specific vulnerability
nmap --script vuln 192.168.1.100

# Run a specific script
nmap --script http-headers 192.168.1.100

# Run multiple scripts
nmap --script "http-headers,http-title,ssl-enum-ciphers" -p 80,443 192.168.1.100
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id="useful-nse-scripts-i-run-regularly"&gt;Useful NSE Scripts I Run Regularly&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Check HTTP security headers&lt;/strong&gt; (pairs well with my &lt;a href="https://blog.byte-guard.net/check-security-headers/" rel="noopener noreferrer"&gt;security headers guide&lt;/a&gt;):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;nmap --script http-security-headers -p 80,443 &amp;lt;YOUR_SERVER_IP&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Enumerate SSL/TLS ciphers:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;nmap --script ssl-enum-ciphers -p 443 &amp;lt;YOUR_SERVER_IP&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This reveals weak cipher suites, outdated TLS versions, and certificate issues. If you see TLSv1.0 or TLSv1.1 in the output, you need to fix your web server configuration.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Check for common vulnerabilities:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;nmap --script vulners -sV -p 22,80,443 &amp;lt;YOUR_SERVER_IP&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;vulners&lt;/code&gt; script cross-references detected service versions against the Vulners database and reports known CVEs. It's not a replacement for a proper vulnerability scanner, but it's a fast first check.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Brute-force SSH (on your own systems only):&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;nmap --script ssh-brute -p 22 &amp;lt;YOUR_SERVER_IP&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This tests for weak SSH passwords. If it succeeds, you need to disable password authentication immediately — I covered how in my &lt;a href="https://blog.byte-guard.net/ssh-hardening-guide/" rel="noopener noreferrer"&gt;SSH hardening guide&lt;/a&gt;.&lt;/p&gt;
&lt;h3 id="finding-all-available-scripts"&gt;Finding All Available Scripts&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;ls /usr/share/nmap/scripts/ | wc -l
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On a current install, you'll see 600+ scripts. Browse them by category:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ls /usr/share/nmap/scripts/ | grep http
ls /usr/share/nmap/scripts/ | grep ssh
ls /usr/share/nmap/scripts/ | grep vuln
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id="nmap-output-formats"&gt;Nmap Output Formats&lt;/h2&gt;
&lt;p&gt;Scan results are useless if you can't save and review them. Nmap supports multiple output formats.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Normal text output
nmap -oN scan_results.txt 192.168.1.100

# XML output (for parsing with other tools)
nmap -oX scan_results.xml 192.168.1.100

# Grepable output (one host per line, easy to pipe)
nmap -oG scan_results.gnmap 192.168.1.100

# All formats at once
nmap -oA scan_results 192.168.1.100
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I always use &lt;code&gt;-oA&lt;/code&gt; for any serious scan. The XML output can be imported into tools like &lt;strong&gt;Metasploit&lt;/strong&gt; or parsed with scripts. The grepable format is perfect for quick filtering:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Find all hosts with port 22 open
grep "22/open" scan_results.gnmap
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id="real-world-nmap-scanning-examples"&gt;Real-World Nmap Scanning Examples&lt;/h2&gt;
&lt;p&gt;Here are the scans I actually run on my own infrastructure.&lt;/p&gt;
&lt;h3 id="full-audit-of-a-single-server"&gt;Full Audit of a Single Server&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;sudo nmap -sS -sV -sC -O -p- -oA full_audit &amp;lt;YOUR_SERVER_IP&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This combines a SYN scan, version detection, default scripts, and OS detection across all 65,535 ports. Takes 5-15 minutes depending on the network. I run this after every major configuration change.&lt;/p&gt;
&lt;h3 id="quick-check-after-firewall-changes"&gt;Quick Check After Firewall Changes&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;nmap -sS -p 22,80,443 &amp;lt;YOUR_SERVER_IP&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After modifying firewall rules (iptables, ufw, etc.), I run a quick scan to verify only the intended ports are open. If you've been following my &lt;a href="https://blog.byte-guard.net/harden-linux-vps-10-minutes/" rel="noopener noreferrer"&gt;VPS hardening guide&lt;/a&gt;, you should see only SSH (22), HTTP (80), and HTTPS (443).&lt;/p&gt;
&lt;h3 id="network-sweep-for-rogue-devices"&gt;Network Sweep for Rogue Devices&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;sudo nmap -sn -oG alive_hosts.gnmap 192.168.1.0/24
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Run this on your home or office network periodically. Compare the output over time. New hosts appearing that you don't recognize deserve investigation.&lt;/p&gt;
&lt;h3 id="scanning-for-a-specific-vulnerability"&gt;Scanning for a Specific Vulnerability&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;nmap --script smb-vuln-ms17-010 -p 445 192.168.1.0/24
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This checks an entire subnet for EternalBlue (the vulnerability behind WannaCry). Replace with whatever CVE you're investigating. The OWASP Top 10 I covered in my &lt;a href="https://blog.byte-guard.net/owasp-top-10-explained/" rel="noopener noreferrer"&gt;OWASP guide&lt;/a&gt; often ties back to the kind of service misconfigurations Nmap reveals.&lt;/p&gt;
&lt;h2 id="nmap-scan-timing-and-performance"&gt;Nmap Scan Timing and Performance&lt;/h2&gt;
&lt;p&gt;Nmap has six timing templates, from T0 (paranoid) to T5 (insane):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Slow and careful (IDS evasion)
nmap -T2 192.168.1.100

# Default (balanced)
nmap -T3 192.168.1.100

# Aggressive (fast, might miss things on slow networks)
nmap -T4 192.168.1.100
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For scanning your own servers, &lt;strong&gt;T4 is the sweet spot&lt;/strong&gt; — fast enough to not waste your time, reliable enough to not miss open ports. I only use T2 or lower when testing IDS detection capabilities on my own network.&lt;/p&gt;
&lt;p&gt;You can also control parallelism directly:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Scan with minimum 1000 packets per second
nmap --min-rate 1000 -p- 192.168.1.100
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;--min-rate&lt;/code&gt; flag guarantees a minimum packet rate. This can dramatically speed up full port scans.&lt;/p&gt;
&lt;h2 id="security-considerations"&gt;Security Considerations&lt;/h2&gt;
&lt;p&gt;Nmap is a double-edged tool. Everything in this &lt;strong&gt;nmap tutorial&lt;/strong&gt; can be used offensively or defensively. Here's how to stay on the right side:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Document your scope.&lt;/strong&gt; Before scanning, write down exactly what you're authorized to scan. Keep this documentation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never scan production systems during peak hours&lt;/strong&gt; unless you've tested the impact. Aggressive scans can crash fragile services or trigger rate-limiting that affects real users.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monitor your own systems for scans.&lt;/strong&gt; Tools like Fail2ban (covered in my &lt;a href="https://blog.byte-guard.net/fail2ban-setup-guide/" rel="noopener noreferrer"&gt;Fail2ban guide&lt;/a&gt;) and Suricata can alert you when someone scans your infrastructure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep Nmap updated.&lt;/strong&gt; New NSE scripts and fingerprints are added regularly. An outdated Nmap misses things.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat scan results as sensitive data.&lt;/strong&gt; A full scan of your infrastructure is a roadmap for attackers. Store results securely and don't post them publicly.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="troubleshooting-common-nmap-issues"&gt;Troubleshooting Common Nmap Issues&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Problem:&lt;/strong&gt; Nmap reports all ports as "filtered." &lt;strong&gt;Cause:&lt;/strong&gt; A firewall (host-based or network) is dropping all probes. Cloud providers like AWS and Azure have security groups that block by default. &lt;strong&gt;Fix:&lt;/strong&gt; Check your firewall rules (&lt;code&gt;sudo ufw status&lt;/code&gt; or &lt;code&gt;sudo iptables -L -n&lt;/code&gt;). If scanning a cloud instance, verify the security group allows inbound from your scanning IP.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Problem:&lt;/strong&gt; OS detection returns "No exact OS matches." &lt;strong&gt;Cause:&lt;/strong&gt; OS fingerprinting needs at least one open and one closed port. If every port is either open or filtered, it can't get a reliable fingerprint. &lt;strong&gt;Fix:&lt;/strong&gt; Run a full port scan first (&lt;code&gt;-p-&lt;/code&gt;) to find at least one closed port, or accept that OS detection won't work behind strict firewalls.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Problem:&lt;/strong&gt; Scans are extremely slow. &lt;strong&gt;Cause:&lt;/strong&gt; Network congestion, aggressive firewall rate-limiting, or scanning too many ports with version detection enabled. &lt;strong&gt;Fix:&lt;/strong&gt; Use &lt;code&gt;-T4&lt;/code&gt; for faster timing. Split large scans into phases — do a quick port discovery first (&lt;code&gt;-sS -p-&lt;/code&gt;), then run &lt;code&gt;-sV&lt;/code&gt; only on open ports.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Problem:&lt;/strong&gt; "Nmap requires root privileges for this scan type." &lt;strong&gt;Cause:&lt;/strong&gt; SYN scans, OS detection, and UDP scans require raw socket access, which needs root. &lt;strong&gt;Fix:&lt;/strong&gt; Use &lt;code&gt;sudo&lt;/code&gt; before the command. Alternatively, use &lt;code&gt;-sT&lt;/code&gt; (TCP connect scan), which works without root but is slower and more detectable.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Problem:&lt;/strong&gt; NSE scripts are not found or fail to load. &lt;strong&gt;Cause:&lt;/strong&gt; Script database is outdated or scripts weren't installed. &lt;strong&gt;Fix:&lt;/strong&gt; Update the script database with &lt;code&gt;sudo nmap --script-updatedb&lt;/code&gt;. On minimal installs, you may need to install &lt;code&gt;nmap-scripts&lt;/code&gt; separately (&lt;code&gt;sudo apt install nmap-common&lt;/code&gt;).&lt;/p&gt;
&lt;h2 id="conclusion"&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;Nmap is one of those tools that scales with your skill. You can start with &lt;code&gt;nmap &amp;lt;target&amp;gt;&lt;/code&gt; and get useful results, or go deep with NSE scripts and custom timing to audit entire networks. The key is understanding what each scan type does and when to use it.&lt;/p&gt;
&lt;p&gt;If you're building out your security toolkit, start with the scans in this guide on your own infrastructure. Pair the results with the hardening steps in my &lt;a href="https://blog.byte-guard.net/harden-linux-vps-10-minutes/" rel="noopener noreferrer"&gt;VPS hardening guide&lt;/a&gt; and &lt;a href="https://blog.byte-guard.net/ssh-hardening-guide/" rel="noopener noreferrer"&gt;SSH hardening guide&lt;/a&gt; to close whatever Nmap finds.&lt;/p&gt;
&lt;p&gt;The best defense starts with knowing what's exposed. Nmap tells you exactly that.&lt;/p&gt;

</description>
      <category>nmap</category>
      <category>networkscanning</category>
      <category>penetrationtesting</category>
      <category>linux</category>
    </item>
    <item>
      <title>Self-Hosting Nextcloud with Docker Compose</title>
      <dc:creator>byteguard</dc:creator>
      <pubDate>Tue, 23 Jun 2026 18:22:44 +0000</pubDate>
      <link>https://dev.to/byte-guard/self-hosting-nextcloud-with-docker-compose-5dde</link>
      <guid>https://dev.to/byte-guard/self-hosting-nextcloud-with-docker-compose-5dde</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://blog.byte-guard.net/self-host-nextcloud-docker/" rel="noopener noreferrer"&gt;byte-guard.net&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Google Drive gives you 15 GB for free and sells you convenience. In exchange, it scans your files, trains AI on your data, and can lock you out of your own account with no explanation and no appeal. If you have ever wondered what it takes to own your files entirely, &lt;strong&gt;Nextcloud&lt;/strong&gt; is the answer.&lt;/p&gt;
&lt;p&gt;This guide is part of our &lt;a href="https://blog.byte-guard.net/self-hosted-alternatives-saas/" rel="noopener noreferrer"&gt;&lt;strong&gt;Self-Hosting Series&lt;/strong&gt;&lt;/a&gt; — step-by-step guides for running your own services on a VPS.&lt;/p&gt;
&lt;p&gt;Nextcloud is a self-hosted cloud platform that replaces Google Drive, Google Calendar, Google Contacts, and a dozen other SaaS tools -- all running on hardware you control. The catch? Setting it up properly takes more than a quick &lt;code&gt;docker run&lt;/code&gt;. Misconfigure the database, skip the caching layer, or ignore PHP tuning, and you end up with something painfully slow.&lt;/p&gt;
&lt;p&gt;In this guide, I will walk through how to &lt;strong&gt;self-host Nextcloud with Docker&lt;/strong&gt; Compose, backed by MariaDB, fronted by Nginx as a reverse proxy with SSL, and tuned for actual usable performance. By the end, you will have a production-ready Nextcloud instance that you would actually want to use daily.&lt;/p&gt;
&lt;h2 id="prerequisites"&gt;Prerequisites&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;VPS with at least 2 CPU cores and 4 GB RAM&lt;/strong&gt; -- Nextcloud with MariaDB and Redis needs room to breathe. I recommend Hetzner's CPX21 or higher. If you do not have a VPS yet, my guide on &lt;a href="https://blog.byte-guard.net/building-byteguard-from-scratch-hetzner-vps/" rel="noopener noreferrer"&gt;building a VPS from scratch&lt;/a&gt; covers the setup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A domain name&lt;/strong&gt; pointed at your server's IP (e.g., &lt;code&gt;cloud.yourdomain.com&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker and Docker Compose&lt;/strong&gt; installed (&lt;a href="https://blog.byte-guard.net/docker-security-best-practices/" rel="noopener noreferrer"&gt;Docker security guide&lt;/a&gt; covers secure installation)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ports 80 and 443&lt;/strong&gt; open on your firewall&lt;/li&gt;
&lt;li&gt;SSH access to your server&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="setting-up-the-project-structure"&gt;Setting Up the Project Structure&lt;/h2&gt;
&lt;p&gt;Start by creating a directory for the Nextcloud stack:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;mkdir -p /opt/nextcloud &amp;amp;&amp;amp; cd /opt/nextcloud
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Create an environment file to store sensitive values. &lt;strong&gt;Never hardcode credentials in your compose file.&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;touch .env
chmod 600 .env
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Add the following to &lt;code&gt;.env&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# /opt/nextcloud/.env
MYSQL_ROOT_PASSWORD=&amp;lt;GENERATE_A_STRONG_PASSWORD&amp;gt;
MYSQL_PASSWORD=&amp;lt;GENERATE_ANOTHER_STRONG_PASSWORD&amp;gt;
MYSQL_DATABASE=nextcloud
MYSQL_USER=nextcloud
NEXTCLOUD_ADMIN_USER=admin
NEXTCLOUD_ADMIN_PASSWORD=&amp;lt;YOUR_ADMIN_PASSWORD&amp;gt;
NEXTCLOUD_TRUSTED_DOMAINS=cloud.&amp;lt;YOUR_DOMAIN&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Generate strong passwords with:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;openssl rand -base64 32
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id="the-docker-compose-configuration"&gt;The Docker Compose Configuration&lt;/h2&gt;
&lt;p&gt;Here is the complete &lt;code&gt;docker-compose.yml&lt;/code&gt; with Nextcloud, MariaDB, and Redis:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# /opt/nextcloud/docker-compose.yml
version: "3.8"

services:
  db:
    image: mariadb:11
    container_name: nextcloud-db
    restart: unless-stopped
    command: &amp;gt;
      --transaction-isolation=READ-COMMITTED
      --log-bin=binlog
      --binlog-format=ROW
      --innodb-file-per-table=1
      --innodb-buffer-pool-size=256M
    volumes:
      - db_data:/var/lib/mysql
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
      MYSQL_PASSWORD: ${MYSQL_PASSWORD}
      MYSQL_DATABASE: ${MYSQL_DATABASE}
      MYSQL_USER: ${MYSQL_USER}
    networks:
      - nextcloud_net
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      interval: 10s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    container_name: nextcloud-redis
    restart: unless-stopped
    command: redis-server --requirepass redis_secret_password
    volumes:
      - redis_data:/data
    networks:
      - nextcloud_net
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "redis_secret_password", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5

  app:
    image: nextcloud:29-apache
    container_name: nextcloud-app
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    volumes:
      - nextcloud_data:/var/www/html
      - ./custom-php.ini:/usr/local/etc/php/conf.d/zzz-custom.ini:ro
    environment:
      MYSQL_HOST: db
      MYSQL_DATABASE: ${MYSQL_DATABASE}
      MYSQL_USER: ${MYSQL_USER}
      MYSQL_PASSWORD: ${MYSQL_PASSWORD}
      NEXTCLOUD_ADMIN_USER: ${NEXTCLOUD_ADMIN_USER}
      NEXTCLOUD_ADMIN_PASSWORD: ${NEXTCLOUD_ADMIN_PASSWORD}
      NEXTCLOUD_TRUSTED_DOMAINS: ${NEXTCLOUD_TRUSTED_DOMAINS}
      REDIS_HOST: redis
      REDIS_HOST_PASSWORD: redis_secret_password
      APACHE_DISABLE_REWRITE_IP: 1
      TRUSTED_PROXIES: 172.0.0.0/8
      OVERWRITEPROTOCOL: https
      OVERWRITECLIURL: https://${NEXTCLOUD_TRUSTED_DOMAINS}
    networks:
      - nextcloud_net
    ports:
      - "127.0.0.1:8080:80"

  cron:
    image: nextcloud:29-apache
    container_name: nextcloud-cron
    restart: unless-stopped
    depends_on:
      - app
    volumes:
      - nextcloud_data:/var/www/html
    entrypoint: /cron.sh
    networks:
      - nextcloud_net

volumes:
  db_data:
  redis_data:
  nextcloud_data:

networks:
  nextcloud_net:
    driver: bridge
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Let me break down the key decisions:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;MariaDB over SQLite:&lt;/strong&gt; Nextcloud supports SQLite, but it falls apart with more than one user or any concurrent file operations. MariaDB is the recommended production database. The &lt;code&gt;--transaction-isolation=READ-COMMITTED&lt;/code&gt; flag is specifically required by Nextcloud.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Redis for caching and locking:&lt;/strong&gt; Without Redis, Nextcloud uses the database for file locking and caching, which kills performance. Redis handles both file locking (preventing conflicts during sync) and memory caching.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Dedicated cron container:&lt;/strong&gt; Nextcloud needs background jobs for file scanning, notifications, and cleanup. Running a separate container with &lt;code&gt;/cron.sh&lt;/code&gt; is more reliable than AJAX-based cron, which depends on users visiting the web interface.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Port binding to 127.0.0.1:&lt;/strong&gt; The &lt;code&gt;127.0.0.1:8080:80&lt;/code&gt; binding ensures Nextcloud is only accessible through the reverse proxy, not directly on port 8080 from the internet.&lt;/p&gt;
&lt;h2 id="php-performance-tuning"&gt;PHP Performance Tuning&lt;/h2&gt;
&lt;p&gt;Create the custom PHP configuration file referenced in the compose file:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;; /opt/nextcloud/custom-php.ini
; Memory and upload limits
memory_limit = 1024M
upload_max_filesize = 16G
post_max_size = 16G
max_execution_time = 3600
max_input_time = 3600

; OPcache settings (critical for performance)
opcache.enable = 1
opcache.interned_strings_buffer = 32
opcache.max_accelerated_files = 10000
opcache.memory_consumption = 256
opcache.save_comments = 1
opcache.revalidate_freq = 60
opcache.jit = 1255
opcache.jit_buffer_size = 128M

; Output buffering
output_buffering = Off
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Why this matters:&lt;/strong&gt; The default PHP &lt;code&gt;memory_limit&lt;/code&gt; of 128 MB causes Nextcloud to choke on file previews and large uploads. &lt;strong&gt;OPcache&lt;/strong&gt; is the single biggest performance improvement you can make -- it caches compiled PHP code in memory so it does not need to be recompiled on every request. The JIT compiler (available in PHP 8+) adds another layer of optimization.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;upload_max_filesize&lt;/code&gt; of 16 GB lets you upload large files. Adjust this based on your storage capacity.&lt;/p&gt;
&lt;h2 id="configuring-the-nginx-reverse-proxy"&gt;Configuring the Nginx Reverse Proxy&lt;/h2&gt;
&lt;p&gt;If you are using &lt;strong&gt;Nginx Proxy Manager&lt;/strong&gt; (which I compared against Traefik and Caddy in my &lt;a href="https://blog.byte-guard.net/nginx-proxy-manager-vs-traefik-vs-caddy/" rel="noopener noreferrer"&gt;reverse proxy comparison&lt;/a&gt;), you can create a new proxy host through the UI pointing to &lt;code&gt;http://127.0.0.1:8080&lt;/code&gt; with SSL enabled.&lt;/p&gt;
&lt;p&gt;For a manual Nginx configuration:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# /etc/nginx/sites-available/nextcloud.conf

upstream nextcloud_backend {
    server 127.0.0.1:8080;
    keepalive 64;
}

server {
    listen 80;
    server_name cloud.&amp;lt;YOUR_DOMAIN&amp;gt;;
    return 301 https://$server_name$request_uri;
}

server {
    listen 443 ssl http2;
    server_name cloud.&amp;lt;YOUR_DOMAIN&amp;gt;;

    # SSL certificates (use certbot or your preferred method)
    ssl_certificate /etc/letsencrypt/live/cloud.&amp;lt;YOUR_DOMAIN&amp;gt;/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/cloud.&amp;lt;YOUR_DOMAIN&amp;gt;/privkey.pem;

    # SSL hardening
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers HIGH:!aNULL:!MD5;
    ssl_prefer_server_ciphers off;

    # Upload size -- must match PHP settings
    client_max_body_size 16G;
    client_body_buffer_size 512k;

    # Timeouts for large file operations
    proxy_connect_timeout 3600;
    proxy_send_timeout 3600;
    proxy_read_timeout 3600;
    send_timeout 3600;

    # Security headers
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    # CalDAV/CardDAV discovery
    location /.well-known/carddav {
        return 301 $scheme://$host/remote.php/dav;
    }
    location /.well-known/caldav {
        return 301 $scheme://$host/remote.php/dav;
    }
    location /.well-known/webfinger {
        return 301 $scheme://$host/index.php/.well-known/webfinger;
    }
    location /.well-known/nodeinfo {
        return 301 $scheme://$host/index.php/.well-known/nodeinfo;
    }

    location / {
        proxy_pass http://nextcloud_backend;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Host $host;
        proxy_set_header X-Forwarded-Port $server_port;

        # WebSocket support (for Notify Push)
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Enable the site and obtain an SSL certificate:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ln -s /etc/nginx/sites-available/nextcloud.conf /etc/nginx/sites-enabled/
nginx -t &amp;amp;&amp;amp; systemctl reload nginx

# Get SSL certificate with certbot
certbot --nginx -d cloud.&amp;lt;YOUR_DOMAIN&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;strong&gt;Note:&lt;/strong&gt; The &lt;code&gt;.well-known&lt;/code&gt; redirects are essential for CalDAV and CardDAV discovery. Without them, calendar and contact sync will fail on mobile devices. The &lt;code&gt;client_max_body_size&lt;/code&gt; must match your PHP &lt;code&gt;upload_max_filesize&lt;/code&gt; setting, or Nginx will reject large uploads before PHP even sees them.&lt;/blockquote&gt;
&lt;h2 id="launching-nextcloud"&gt;Launching Nextcloud&lt;/h2&gt;
&lt;p&gt;Bring up the stack:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cd /opt/nextcloud
docker compose up -d
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Watch the logs to verify everything starts cleanly:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker compose logs -f app
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You should see Nextcloud's installation routine run automatically (creating the admin user and configuring the database). This takes 30-60 seconds on first launch.&lt;/p&gt;
&lt;p&gt;Once the logs settle, visit &lt;code&gt;https://cloud.&amp;lt;YOUR_DOMAIN&amp;gt;&lt;/code&gt; in your browser. You should see the Nextcloud login page. Sign in with the admin credentials from your &lt;code&gt;.env&lt;/code&gt; file.&lt;/p&gt;
&lt;h2 id="post-installation-configuration"&gt;Post-Installation Configuration&lt;/h2&gt;
&lt;h3 id="verify-redis-caching"&gt;Verify Redis Caching&lt;/h3&gt;
&lt;p&gt;After logging in as admin, go to &lt;strong&gt;Administration Settings &amp;gt; Overview&lt;/strong&gt;. Check that there are no warnings about the caching backend. You can also verify Redis is connected:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker exec nextcloud-redis redis-cli -a redis_secret_password INFO clients
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You should see at least one connected client (the Nextcloud app).&lt;/p&gt;
&lt;h3 id="configure-the-nextcloud-config-file"&gt;Configure the Nextcloud Config File&lt;/h3&gt;
&lt;p&gt;Some settings need to be added directly to Nextcloud's &lt;code&gt;config.php&lt;/code&gt;. Access it through the Docker volume:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker exec -it nextcloud-app cat /var/www/html/config/config.php
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you need to edit it:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker exec -it nextcloud-app vi /var/www/html/config/config.php
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Verify these settings are present (most should be set automatically by the environment variables):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;'trusted_domains' =&amp;gt;
  array (
    0 =&amp;gt; 'cloud.&amp;lt;YOUR_DOMAIN&amp;gt;',
  ),
'overwrite.cli.url' =&amp;gt; 'https://cloud.&amp;lt;YOUR_DOMAIN&amp;gt;',
'overwriteprotocol' =&amp;gt; 'https',
'memcache.local' =&amp;gt; '\\OC\\Memcache\\APCu',
'memcache.distributed' =&amp;gt; '\\OC\\Memcache\\Redis',
'memcache.locking' =&amp;gt; '\\OC\\Memcache\\Redis',
'redis' =&amp;gt;
  array (
    'host' =&amp;gt; 'redis',
    'password' =&amp;gt; 'redis_secret_password',
    'port' =&amp;gt; 6379,
  ),
'default_phone_region' =&amp;gt; 'US',
'maintenance_window_start' =&amp;gt; 1,
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;maintenance_window_start&lt;/code&gt; tells Nextcloud when to run heavy background tasks (value is UTC hour, so &lt;code&gt;1&lt;/code&gt; means 1:00 AM UTC).&lt;/p&gt;
&lt;h3 id="set-background-jobs-to-cron"&gt;Set Background Jobs to Cron&lt;/h3&gt;
&lt;p&gt;Go to &lt;strong&gt;Administration Settings &amp;gt; Basic settings&lt;/strong&gt; and set the background jobs method to &lt;strong&gt;Cron&lt;/strong&gt;. Since we have a dedicated cron container, this is already handled -- but you need to tell Nextcloud to expect it.&lt;/p&gt;
&lt;p&gt;Verify cron is running:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker exec nextcloud-app php -f /var/www/html/cron.php
docker logs nextcloud-cron --tail 5
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id="essential-apps-to-enable"&gt;Essential Apps to Enable&lt;/h2&gt;
&lt;p&gt;Nextcloud has a large app ecosystem. Here are the ones I consider essential for a production setup:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Install apps via the command line (faster than the web UI)
docker exec -u www-data nextcloud-app php occ app:install calendar
docker exec -u www-data nextcloud-app php occ app:install contacts
docker exec -u www-data nextcloud-app php occ app:install tasks
docker exec -u www-data nextcloud-app php occ app:install notes
docker exec -u www-data nextcloud-app php occ app:install deck
docker exec -u www-data nextcloud-app php occ app:install previewgenerator
&lt;/code&gt;&lt;/pre&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;App&lt;/th&gt;
&lt;th&gt;Replaces&lt;/th&gt;
&lt;th&gt;Why Install It&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Calendar&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Google Calendar&lt;/td&gt;
&lt;td&gt;CalDAV-based, syncs with any calendar app&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Contacts&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Google Contacts&lt;/td&gt;
&lt;td&gt;CardDAV-based, syncs with phone contacts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Tasks&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Google Tasks, Todoist&lt;/td&gt;
&lt;td&gt;Integrates with Calendar&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Notes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Google Keep&lt;/td&gt;
&lt;td&gt;Markdown support, folder organization&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Deck&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Trello&lt;/td&gt;
&lt;td&gt;Kanban boards with Calendar integration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Preview Generator&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;--&lt;/td&gt;
&lt;td&gt;Pre-generates image thumbnails for faster browsing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;After installing Preview Generator, run the initial generation:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker exec -u www-data nextcloud-app php occ preview:generate-all
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This takes a while if you have existing files. After the initial run, the cron job handles new files automatically.&lt;/p&gt;
&lt;h2 id="security-considerations"&gt;Security Considerations&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Brute force protection&lt;/strong&gt; is built into Nextcloud and enabled by default. It rate-limits login attempts per IP. Verify it is active under &lt;strong&gt;Administration Settings &amp;gt; Security&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Two-factor authentication&lt;/strong&gt; -- enable it immediately for the admin account. Go to &lt;strong&gt;Personal Settings &amp;gt; Security&lt;/strong&gt; and set up TOTP (works with any authenticator app). For other users, you can enforce 2FA under &lt;strong&gt;Administration Settings &amp;gt; Security&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;File encryption&lt;/strong&gt; -- Nextcloud supports server-side encryption, but think carefully before enabling it. It protects files at rest (useful if someone steals your disk), but it adds CPU overhead, makes disaster recovery harder, and does not protect against a compromised server (since the keys are stored on the same server). For most self-hosting scenarios, full-disk encryption (LUKS) at the OS level is a better approach.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Expose only what you need.&lt;/strong&gt; The Docker Compose file already binds Nextcloud to &lt;code&gt;127.0.0.1:8080&lt;/code&gt;, so it is only accessible through the reverse proxy. Make sure your firewall blocks direct access to port 8080 from outside.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Keep it updated.&lt;/strong&gt; Nextcloud publishes security advisories regularly. Check for updates in the admin panel or via CLI:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker exec -u www-data nextcloud-app php occ update:check
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;To upgrade, update the image tag in &lt;code&gt;docker-compose.yml&lt;/code&gt; and recreate the container:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker compose pull app cron
docker compose up -d app cron
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;strong&gt;Warning:&lt;/strong&gt; Always back up your database and data volume before upgrading. Major version upgrades (e.g., 28 to 29) sometimes require manual migration steps.&lt;/blockquote&gt;
&lt;h2 id="troubleshooting"&gt;Troubleshooting&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Problem:&lt;/strong&gt; "Access through untrusted domain" error when visiting the site. &lt;strong&gt;Cause:&lt;/strong&gt; The domain you are accessing is not listed in Nextcloud's &lt;code&gt;trusted_domains&lt;/code&gt; array. &lt;strong&gt;Fix:&lt;/strong&gt; Edit &lt;code&gt;config.php&lt;/code&gt; inside the container and add your domain to the &lt;code&gt;trusted_domains&lt;/code&gt; array. Or set the &lt;code&gt;NEXTCLOUD_TRUSTED_DOMAINS&lt;/code&gt; environment variable and recreate the container.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Problem:&lt;/strong&gt; File uploads fail for files larger than a few megabytes. &lt;strong&gt;Cause:&lt;/strong&gt; Either Nginx's &lt;code&gt;client_max_body_size&lt;/code&gt;, PHP's &lt;code&gt;upload_max_filesize&lt;/code&gt;, or PHP's &lt;code&gt;post_max_size&lt;/code&gt; is too low. &lt;strong&gt;Fix:&lt;/strong&gt; All three values must match. Check your &lt;code&gt;custom-php.ini&lt;/code&gt; and your Nginx configuration. After changes, restart both Nginx and the Nextcloud container.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Problem:&lt;/strong&gt; Nextcloud dashboard shows "No memory cache configured" warning. &lt;strong&gt;Cause:&lt;/strong&gt; Redis connection failed or the caching configuration is missing from &lt;code&gt;config.php&lt;/code&gt;. &lt;strong&gt;Fix:&lt;/strong&gt; Verify Redis is running (&lt;code&gt;docker logs nextcloud-redis&lt;/code&gt;). Check that the Redis password in &lt;code&gt;config.php&lt;/code&gt; matches the one in &lt;code&gt;docker-compose.yml&lt;/code&gt;. Test connectivity: &lt;code&gt;docker exec nextcloud-app apt-get update &amp;amp;&amp;amp; apt-get install -y redis-tools &amp;amp;&amp;amp; redis-cli -h redis -a redis_secret_password ping&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Problem:&lt;/strong&gt; Calendar and contact sync fails on mobile devices. &lt;strong&gt;Cause:&lt;/strong&gt; Missing &lt;code&gt;.well-known&lt;/code&gt; redirects for CalDAV and CardDAV discovery. &lt;strong&gt;Fix:&lt;/strong&gt; Add the &lt;code&gt;.well-known&lt;/code&gt; location blocks to your Nginx configuration (shown in the reverse proxy section above). The URLs &lt;code&gt;/.well-known/carddav&lt;/code&gt; and &lt;code&gt;/.well-known/caldav&lt;/code&gt; must redirect to &lt;code&gt;/remote.php/dav&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Problem:&lt;/strong&gt; Nextcloud is extremely slow, pages take 5-10 seconds to load. &lt;strong&gt;Cause:&lt;/strong&gt; OPcache is not enabled, or the cron container is not running (causing background jobs to pile up). &lt;strong&gt;Fix:&lt;/strong&gt; Verify &lt;code&gt;custom-php.ini&lt;/code&gt; is mounted correctly: &lt;code&gt;docker exec nextcloud-app php -i | grep opcache.enable&lt;/code&gt;. It should show &lt;code&gt;opcache.enable =&amp;gt; On&lt;/code&gt;. Also verify the cron container is running: &lt;code&gt;docker compose ps cron&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id="conclusion"&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;You now have a fully functional, performance-tuned &lt;strong&gt;Nextcloud instance running on Docker&lt;/strong&gt; with MariaDB for the database, Redis for caching, and Nginx handling SSL termination. This setup replaces Google Drive, Calendar, Contacts, and several other services -- all on infrastructure you own.&lt;/p&gt;
&lt;p&gt;The key performance wins: &lt;strong&gt;Redis for caching and file locking&lt;/strong&gt;, &lt;strong&gt;OPcache with JIT enabled&lt;/strong&gt;, and &lt;strong&gt;a dedicated cron container&lt;/strong&gt; for background jobs. Without these three, Nextcloud feels sluggish. With them, it is genuinely pleasant to use.&lt;/p&gt;
&lt;p&gt;For next steps, consider setting up automated backups (a &lt;code&gt;pg_dump&lt;/code&gt; equivalent for MariaDB is &lt;code&gt;mariadb-dump&lt;/code&gt;), connecting the Nextcloud desktop and mobile sync clients, and exploring the app ecosystem.&lt;/p&gt;
&lt;p&gt;If you are choosing a reverse proxy for your setup, I compared the top three options in my &lt;a href="https://blog.byte-guard.net/nginx-proxy-manager-vs-traefik-vs-caddy/" rel="noopener noreferrer"&gt;Nginx Proxy Manager vs Traefik vs Caddy&lt;/a&gt; post. And if you need a reliable VPS to host Nextcloud, I run all my projects on Hetzner -- great performance, fair pricing, and EU-based data centers. &lt;a href="https://www.hetzner.com/cloud" rel="noopener noreferrer"&gt;Check out Hetzner's cloud plans&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>selfhosting</category>
      <category>nextcloud</category>
      <category>docker</category>
      <category>cloudstorage</category>
    </item>
    <item>
      <title>How to Self-Host Gitea with Docker Compose</title>
      <dc:creator>byteguard</dc:creator>
      <pubDate>Tue, 23 Jun 2026 18:22:07 +0000</pubDate>
      <link>https://dev.to/byte-guard/how-to-self-host-gitea-with-docker-compose-1f5a</link>
      <guid>https://dev.to/byte-guard/how-to-self-host-gitea-with-docker-compose-1f5a</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://blog.byte-guard.net/self-host-gitea-docker/" rel="noopener noreferrer"&gt;byte-guard.net&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;GitHub is free, reliable, and deeply integrated into every developer's workflow. So why would anyone &lt;strong&gt;self-host Gitea&lt;/strong&gt; as an alternative? Control. Your code, your data, your rules. No surprise policy changes, no training AI models on your private repositories, no vendor lock-in. If Microsoft decides tomorrow that free private repos require a new plan, you are stuck. With Gitea running on your own VPS, you answer to no one.&lt;/p&gt;
&lt;p&gt;This guide is part of our &lt;a href="https://blog.byte-guard.net/self-hosted-alternatives-saas/" rel="noopener noreferrer"&gt;&lt;strong&gt;Self-Hosting Series&lt;/strong&gt;&lt;/a&gt; — step-by-step guides for running your own services on a VPS.&lt;/p&gt;
&lt;p&gt;Gitea is a lightweight, self-hosted Git service written in Go. It consumes around 200MB of RAM under normal usage — a fraction of what GitLab demands. It includes pull requests, issues, a package registry, and since version 1.19, &lt;strong&gt;Gitea Actions&lt;/strong&gt; — a CI/CD system compatible with GitHub Actions workflows. For a personal or small-team setup, it is the best self-hosted Git option available.&lt;/p&gt;
&lt;p&gt;In this guide, I will walk through a complete production-ready Gitea deployment: Docker Compose with PostgreSQL, reverse proxy configuration, SSH passthrough for Git operations, CI/CD with Gitea Actions, and migrating your existing repositories from GitHub.&lt;/p&gt;
&lt;h2 id="prerequisites"&gt;Prerequisites&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;A Linux VPS (Ubuntu 22.04 or Debian 12) with at least 1 GB RAM and 20 GB storage&lt;/li&gt;
&lt;li&gt;Docker Engine 24+ and Docker Compose v2 installed&lt;/li&gt;
&lt;li&gt;A domain name pointing to your VPS (e.g., &lt;code&gt;git.yourdomain.com&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;A reverse proxy (&lt;a href="https://blog.byte-guard.net/nginx-proxy-manager-vs-traefik-vs-caddy/" rel="noopener noreferrer"&gt;Nginx Proxy Manager&lt;/a&gt;, Caddy, or Traefik) — I use Nginx Proxy Manager as described in my &lt;a href="https://blog.byte-guard.net/building-byteguard-from-scratch-hetzner-vps/" rel="noopener noreferrer"&gt;VPS setup guide&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Basic familiarity with Docker and Git&lt;/li&gt;
&lt;li&gt;SSH access to your server — see my &lt;a href="https://blog.byte-guard.net/self-host-vaultwarden/" rel="noopener noreferrer"&gt;Vaultwarden guide&lt;/a&gt; for managing credentials securely&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you need a VPS, I run all my self-hosted services on a Hetzner CPX22. It handles Ghost, Uptime Kuma, Nginx Proxy Manager, and Gitea without breaking a sweat.&lt;/p&gt;
&lt;h2 id="setting-up-the-directory-structure"&gt;Setting Up the Directory Structure&lt;/h2&gt;
&lt;p&gt;Create a dedicated directory for Gitea:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo mkdir -p /opt/gitea
cd /opt/gitea
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I keep all my Docker services under &lt;code&gt;/opt/&lt;/code&gt; with one directory per service. This makes backups and management predictable.&lt;/p&gt;
&lt;h2 id="docker-compose-configuration"&gt;Docker Compose Configuration&lt;/h2&gt;
&lt;p&gt;Create the Compose file:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# /opt/gitea/docker-compose.yml
services:
  gitea:
    image: gitea/gitea:1.22-rootless
    container_name: gitea
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
    environment:
      - GITEA__database__DB_TYPE=postgres
      - GITEA__database__HOST=db:5432
      - GITEA__database__NAME=gitea
      - GITEA__database__USER=gitea
      - GITEA__database__PASSWD=${DB_PASSWORD}
      - GITEA__server__ROOT_URL=https://&amp;lt;YOUR_DOMAIN&amp;gt;/
      - GITEA__server__SSH_DOMAIN=&amp;lt;YOUR_DOMAIN&amp;gt;
      - GITEA__server__SSH_PORT=2222
      - GITEA__server__LFS_START_SERVER=true
      - GITEA__service__DISABLE_REGISTRATION=true
      - GITEA__mailer__ENABLED=false
      - GITEA__openid__ENABLE_OPENID_SIGNIN=false
    volumes:
      - gitea-data:/var/lib/gitea
      - gitea-config:/etc/gitea
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
    ports:
      - "3000:3000"
      - "2222:2222"
    networks:
      - gitea-net

  db:
    image: postgres:16-alpine
    container_name: gitea-db
    restart: unless-stopped
    environment:
      - POSTGRES_DB=gitea
      - POSTGRES_USER=gitea
      - POSTGRES_PASSWORD=${DB_PASSWORD}
    volumes:
      - postgres-data:/var/lib/postgresql/data
    networks:
      - gitea-net
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U gitea -d gitea"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  gitea-data:
  gitea-config:
  postgres-data:

networks:
  gitea-net:
    driver: bridge
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A few important decisions in this configuration:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rootless image&lt;/strong&gt; (&lt;code&gt;gitea/gitea:1.22-rootless&lt;/code&gt;): The container runs as a non-root user. This is a security best practice I covered in my &lt;a href="https://blog.byte-guard.net/docker-security-best-practices/" rel="noopener noreferrer"&gt;Docker security guide&lt;/a&gt;. If an attacker escapes the Gitea process, they land as an unprivileged user.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PostgreSQL over SQLite&lt;/strong&gt;: SQLite works for personal use, but PostgreSQL handles concurrent connections better and makes backups more reliable. The overhead is minimal — PostgreSQL Alpine uses about 30MB of RAM.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Registration disabled&lt;/strong&gt;: &lt;code&gt;DISABLE_REGISTRATION=true&lt;/code&gt; prevents random people from creating accounts on your instance. You create accounts via the admin panel or CLI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Health check on PostgreSQL&lt;/strong&gt;: The &lt;code&gt;depends_on&lt;/code&gt; with &lt;code&gt;condition: service_healthy&lt;/code&gt; ensures Gitea does not start before the database is ready. Without this, Gitea may crash on first boot and require a manual restart.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="creating-the-environment-file"&gt;Creating the Environment File&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;cat &amp;gt; /opt/gitea/.env &amp;lt;&amp;lt; 'EOF'
DB_PASSWORD=&amp;lt;YOUR_SECURE_PASSWORD&amp;gt;
EOF
chmod 600 /opt/gitea/.env
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Generate a strong password:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;openssl rand -base64 32
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Replace &lt;code&gt;&amp;lt;YOUR_SECURE_PASSWORD&amp;gt;&lt;/code&gt; with the generated value. Replace &lt;code&gt;&amp;lt;YOUR_DOMAIN&amp;gt;&lt;/code&gt; in the Compose file with your actual domain (e.g., &lt;code&gt;git.byte-guard.net&lt;/code&gt;).&lt;/p&gt;
&lt;blockquote&gt;
&lt;strong&gt;Note:&lt;/strong&gt; Never commit &lt;code&gt;.env&lt;/code&gt; files to version control. The &lt;code&gt;chmod 600&lt;/code&gt; ensures only the file owner can read it.&lt;/blockquote&gt;
&lt;h2 id="starting-gitea"&gt;Starting Gitea&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;cd /opt/gitea &amp;amp;&amp;amp; docker compose up -d
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Check that both containers are running:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker compose ps
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Expected output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;NAME        SERVICE   STATUS
gitea       gitea     Up (healthy)
gitea-db    db        Up (healthy)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Gitea is now listening on port 3000 (HTTP) and port 2222 (SSH).&lt;/p&gt;
&lt;h2 id="reverse-proxy-configuration"&gt;Reverse Proxy Configuration&lt;/h2&gt;
&lt;p&gt;You should never expose Gitea directly on port 3000. Put it behind a reverse proxy with TLS.&lt;/p&gt;
&lt;h3 id="nginx-proxy-manager"&gt;Nginx Proxy Manager&lt;/h3&gt;
&lt;p&gt;If you use Nginx Proxy Manager (as I do on all my projects):&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Add a new proxy host&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Domain:&lt;/strong&gt; &lt;code&gt;git.yourdomain.com&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forward Hostname:&lt;/strong&gt; &lt;code&gt;gitea&lt;/code&gt; (or the server IP if NPM runs in a different Docker network)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forward Port:&lt;/strong&gt; &lt;code&gt;3000&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SSL:&lt;/strong&gt; Request a new Let's Encrypt certificate, force SSL&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;If NPM and Gitea are on different Docker networks, either add Gitea to NPM's network or use the host IP:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Add to the gitea service in docker-compose.yml
networks:
  - gitea-net
  - npm-network

# Add at the bottom
networks:
  gitea-net:
    driver: bridge
  npm-network:
    external: true
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id="caddy-alternative"&gt;Caddy (Alternative)&lt;/h3&gt;
&lt;p&gt;If you prefer Caddy as your reverse proxy:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;git.yourdomain.com {
    reverse_proxy gitea:3000
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Caddy handles TLS automatically — no certificate configuration needed.&lt;/p&gt;
&lt;h2 id="initial-setup"&gt;Initial Setup&lt;/h2&gt;
&lt;p&gt;Open &lt;code&gt;https://git.yourdomain.com&lt;/code&gt; in your browser. On first visit, Gitea shows an installation page. Most settings are already configured via environment variables, but verify:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Site Title:&lt;/strong&gt; Your preferred name (e.g., "ByteGuard Git")&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Admin Account:&lt;/strong&gt; Create your admin user here — this is the only chance to do it through the web UI before registration is disabled&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Click "Install Gitea." The page will redirect to the login screen.&lt;/p&gt;
&lt;h3 id="creating-additional-users-via-cli"&gt;Creating Additional Users via CLI&lt;/h3&gt;
&lt;p&gt;Since registration is disabled, add users through the Gitea CLI:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker exec -it gitea gitea admin user create \
  --username developer \
  --password '&amp;lt;STRONG_PASSWORD&amp;gt;' \
  --email developer@yourdomain.com
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id="ssh-passthrough-for-git-operations"&gt;SSH Passthrough for Git Operations&lt;/h2&gt;
&lt;p&gt;Git over HTTPS works immediately through the reverse proxy. But serious Git users prefer &lt;strong&gt;SSH&lt;/strong&gt; for authentication — no password prompts, no token management, better performance for large pushes.&lt;/p&gt;
&lt;p&gt;The Compose file already maps port 2222 from the container to the host. Configure your SSH client to use this port:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cat &amp;gt;&amp;gt; ~/.ssh/config &amp;lt;&amp;lt; 'EOF'

Host git.yourdomain.com
    HostName git.yourdomain.com
    Port 2222
    User git
    IdentityFile ~/.ssh/id_ed25519
EOF
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Add your public key to Gitea through the web UI: &lt;strong&gt;Settings → SSH / GPG Keys → Add Key&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Test the connection:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ssh -T git@git.yourdomain.com
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Expected output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Hi there, &amp;lt;username&amp;gt;! You've successfully authenticated, but Gitea does not provide shell access.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now clone repositories using SSH:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;git clone git@git.yourdomain.com:&amp;lt;username&amp;gt;/&amp;lt;repo&amp;gt;.git
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id="using-port-22-instead-of-2222"&gt;Using Port 22 Instead of 2222&lt;/h3&gt;
&lt;p&gt;If you want standard SSH Git operations on port 22, you have two options:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Option A:&lt;/strong&gt; Move your system SSH to a different port (e.g., 2222) and give port 22 to Gitea. Update your server's SSH config in &lt;code&gt;/etc/ssh/sshd_config&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Port 2200
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then change the Gitea port mapping to &lt;code&gt;"22:2222"&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Option B (recommended):&lt;/strong&gt; Use SSH passthrough. This is more complex but lets both system SSH and Gitea SSH coexist on port 22. It requires adding the &lt;code&gt;git&lt;/code&gt; user on the host and configuring &lt;code&gt;AuthorizedKeysCommand&lt;/code&gt; in sshd. The Gitea documentation covers this in detail — I recommend Option A for most self-hosters because it is simpler to maintain.&lt;/p&gt;
&lt;h2 id="cicd-with-gitea-actions"&gt;CI/CD with Gitea Actions&lt;/h2&gt;
&lt;p&gt;Gitea Actions (available since v1.19) is compatible with GitHub Actions workflow syntax. If you have existing GitHub Actions workflows, they work in Gitea with minimal changes.&lt;/p&gt;
&lt;h3 id="enabling-actions"&gt;Enabling Actions&lt;/h3&gt;
&lt;p&gt;Add the Actions configuration to your Gitea environment variables:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Add to the gitea service environment in docker-compose.yml
- GITEA__actions__ENABLED=true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Restart Gitea:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cd /opt/gitea &amp;amp;&amp;amp; docker compose up -d
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id="setting-up-an-actions-runner"&gt;Setting Up an Actions Runner&lt;/h3&gt;
&lt;p&gt;Gitea Actions requires a &lt;strong&gt;runner&lt;/strong&gt; — a separate process that picks up jobs and executes them. This is identical to GitHub's self-hosted runner concept.&lt;/p&gt;
&lt;p&gt;First, get a registration token from Gitea. Navigate to &lt;strong&gt;Site Administration → Runners&lt;/strong&gt; and copy the registration token.&lt;/p&gt;
&lt;p&gt;Add the runner to your Compose file:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;  runner:
    image: gitea/act_runner:latest
    container_name: gitea-runner
    restart: unless-stopped
    depends_on:
      - gitea
    environment:
      - GITEA_INSTANCE_URL=http://gitea:3000
      - GITEA_RUNNER_REGISTRATION_TOKEN=${RUNNER_TOKEN}
      - GITEA_RUNNER_NAME=local-runner
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - runner-data:/data
    networks:
      - gitea-net
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Add &lt;code&gt;runner-data&lt;/code&gt; to the volumes section and &lt;code&gt;RUNNER_TOKEN&lt;/code&gt; to your &lt;code&gt;.env&lt;/code&gt; file.&lt;/p&gt;
&lt;blockquote&gt;
&lt;strong&gt;Note:&lt;/strong&gt; The runner needs Docker socket access because it launches job containers. This is a security consideration — review my &lt;a href="https://blog.byte-guard.net/docker-security-best-practices/" rel="noopener noreferrer"&gt;Docker security guide&lt;/a&gt; section on socket protection. For a personal instance, the risk is manageable. For a team, consider running the runner on a separate host.&lt;/blockquote&gt;
&lt;h3 id="creating-a-workflow"&gt;Creating a Workflow&lt;/h3&gt;
&lt;p&gt;In any repository, create &lt;code&gt;.gitea/workflows/ci.yml&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;name: CI
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run tests
        run: |
          echo "Running tests..."
          # Replace with your actual test command
          make test
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Push this file, and Gitea Actions will pick it up automatically. Check the &lt;strong&gt;Actions&lt;/strong&gt; tab in your repository to see the workflow status.&lt;/p&gt;
&lt;h2 id="migrating-repositories-from-github"&gt;Migrating Repositories from GitHub&lt;/h2&gt;
&lt;p&gt;Gitea has a built-in migration tool that imports repositories including issues, pull requests, labels, milestones, releases, and wiki pages.&lt;/p&gt;
&lt;h3 id="via-the-web-ui"&gt;Via the Web UI&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;Click &lt;strong&gt;+&lt;/strong&gt; → &lt;strong&gt;New Migration&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Select &lt;strong&gt;GitHub&lt;/strong&gt; as the source&lt;/li&gt;
&lt;li&gt;Enter the repository URL (e.g., &lt;code&gt;https://github.com/youruser/yourrepo&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;For private repos, provide a GitHub Personal Access Token with &lt;code&gt;repo&lt;/code&gt; scope&lt;/li&gt;
&lt;li&gt;Select what to migrate (issues, PRs, labels, etc.)&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Migrate Repository&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id="via-the-api-bulk-migration"&gt;Via the API (Bulk Migration)&lt;/h3&gt;
&lt;p&gt;For migrating many repositories, use the Gitea API:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#!/bin/bash
# migrate-repos.sh
GITEA_URL="https://git.yourdomain.com"
GITEA_TOKEN="&amp;lt;YOUR_GITEA_API_TOKEN&amp;gt;"
GITHUB_TOKEN="&amp;lt;YOUR_GITHUB_TOKEN&amp;gt;"
GITHUB_USER="&amp;lt;YOUR_GITHUB_USERNAME&amp;gt;"

# Get list of GitHub repos
repos=$(curl -s -H "Authorization: token $GITHUB_TOKEN" \
  "https://api.github.com/user/repos?per_page=100&amp;amp;type=owner" | \
  jq -r '.[].clone_url')

for repo_url in $repos; do
  repo_name=$(basename "$repo_url" .git)
  echo "Migrating $repo_name..."

  curl -s -X POST "$GITEA_URL/api/v1/repos/migrate" \
    -H "Authorization: token $GITEA_TOKEN" \
    -H "Content-Type: application/json" \
    -d "{
      \"clone_addr\": \"$repo_url\",
      \"auth_token\": \"$GITHUB_TOKEN\",
      \"repo_name\": \"$repo_name\",
      \"repo_owner\": \"$(echo $GITEA_TOKEN | cut -d: -f1)\",
      \"service\": \"github\",
      \"mirror\": false,
      \"issues\": true,
      \"pull_requests\": true,
      \"labels\": true,
      \"milestones\": true,
      \"releases\": true
    }"

  echo " Done."
done
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Set &lt;code&gt;"mirror": true&lt;/code&gt; if you want Gitea to periodically sync from GitHub rather than making a one-time copy. Mirroring is useful during a transition period when you are still pushing to both.&lt;/p&gt;
&lt;h2 id="backup-strategy"&gt;Backup Strategy&lt;/h2&gt;
&lt;p&gt;Your Git repositories contain irreplaceable work. Back them up.&lt;/p&gt;
&lt;h3 id="database-backup"&gt;Database Backup&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;docker exec gitea-db pg_dump -U gitea gitea &amp;gt; /opt/gitea/backups/gitea-db-$(date +%Y%m%d).sql
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id="repository-data-backup"&gt;Repository Data Backup&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;docker run --rm \
  -v gitea-data:/data:ro \
  -v /opt/gitea/backups:/backup \
  alpine tar czf /backup/gitea-data-$(date +%Y%m%d).tar.gz -C /data .
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id="automated-daily-backups"&gt;Automated Daily Backups&lt;/h3&gt;
&lt;p&gt;Create a cron job:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cat &amp;gt; /etc/cron.daily/gitea-backup &amp;lt;&amp;lt; 'SCRIPT'
#!/bin/bash
BACKUP_DIR="/opt/gitea/backups"
mkdir -p "$BACKUP_DIR"

# Database
docker exec gitea-db pg_dump -U gitea gitea &amp;gt; "$BACKUP_DIR/gitea-db-$(date +%Y%m%d).sql"

# Data volumes
docker run --rm \
  -v gitea-data:/data:ro \
  -v "$BACKUP_DIR":/backup \
  alpine tar czf "/backup/gitea-data-$(date +%Y%m%d).tar.gz" -C /data .

# Retain 30 days
find "$BACKUP_DIR" -name "*.sql" -mtime +30 -delete
find "$BACKUP_DIR" -name "*.tar.gz" -mtime +30 -delete

echo "$(date): Gitea backup completed" &amp;gt;&amp;gt; /var/log/gitea-backup.log
SCRIPT
chmod +x /etc/cron.daily/gitea-backup
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id="security-considerations"&gt;Security Considerations&lt;/h2&gt;
&lt;p&gt;Self-hosting a Git server means you are responsible for its security. Here is what matters most:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Disable public registration.&lt;/strong&gt; I set &lt;code&gt;DISABLE_REGISTRATION=true&lt;/code&gt; in the Compose file. If you leave registration open, bots will create accounts within hours.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Use the rootless image.&lt;/strong&gt; The &lt;code&gt;gitea/gitea:1.22-rootless&lt;/code&gt; image runs as UID 1000 instead of root. If an attacker exploits a Gitea vulnerability, they cannot escalate to root inside the container.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Restrict SSH access.&lt;/strong&gt; Only expose the SSH port (2222) if you need Git-over-SSH. If HTTPS-only is acceptable for your workflow, remove the SSH port mapping entirely.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Keep Gitea updated.&lt;/strong&gt; Gitea releases security patches frequently. Pin a minor version (e.g., &lt;code&gt;1.22&lt;/code&gt;) rather than &lt;code&gt;latest&lt;/code&gt; to avoid surprise breaking changes, but update regularly:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cd /opt/gitea
docker compose pull
docker compose up -d
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Protect the runner.&lt;/strong&gt; The Actions runner has Docker socket access. Do not run untrusted workflows. For a personal instance, this is fine. For a team, restrict who can create workflows via repository permissions.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Enable two-factor authentication.&lt;/strong&gt; In Gitea's admin panel, you can require 2FA for all users. Do this — passwords alone are not enough.&lt;/p&gt;
&lt;h2 id="troubleshooting"&gt;Troubleshooting&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Problem:&lt;/strong&gt; Gitea shows "502 Bad Gateway" behind the reverse proxy. &lt;strong&gt;Cause:&lt;/strong&gt; The reverse proxy cannot reach Gitea on port 3000, usually because they are on different Docker networks. &lt;strong&gt;Fix:&lt;/strong&gt; Either add both services to the same Docker network (shown in the Nginx Proxy Manager section above) or use &lt;code&gt;host.docker.internal&lt;/code&gt; as the forward hostname. Verify with &lt;code&gt;docker exec npm curl -s http://gitea:3000&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Problem:&lt;/strong&gt; SSH clone fails with "Connection refused" on port 2222. &lt;strong&gt;Cause:&lt;/strong&gt; The port mapping is not working, or your firewall blocks port 2222. &lt;strong&gt;Fix:&lt;/strong&gt; Verify the port is listening with &lt;code&gt;ss -tlnp | grep 2222&lt;/code&gt;. If not, check &lt;code&gt;docker compose ps&lt;/code&gt; to confirm the container is running. If the port is listening but connections fail, open it in your firewall: &lt;code&gt;sudo ufw allow 2222/tcp&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Problem:&lt;/strong&gt; Gitea Actions workflow stuck in "Waiting" status. &lt;strong&gt;Cause:&lt;/strong&gt; No runner is registered, or the runner is offline. &lt;strong&gt;Fix:&lt;/strong&gt; Check runner status in &lt;strong&gt;Site Administration → Runners&lt;/strong&gt;. If no runner is listed, verify the &lt;code&gt;GITEA_RUNNER_REGISTRATION_TOKEN&lt;/code&gt; matches what Gitea shows. Check runner logs with &lt;code&gt;docker logs gitea-runner&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Problem:&lt;/strong&gt; Migration from GitHub fails with "401 Unauthorized." &lt;strong&gt;Cause:&lt;/strong&gt; The GitHub Personal Access Token has expired or lacks the &lt;code&gt;repo&lt;/code&gt; scope. &lt;strong&gt;Fix:&lt;/strong&gt; Generate a new token at &lt;code&gt;github.com/settings/tokens&lt;/code&gt; with the &lt;code&gt;repo&lt;/code&gt; scope. Classic tokens work more reliably than fine-grained tokens for migration.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Problem:&lt;/strong&gt; PostgreSQL container fails to start with "could not access directory." &lt;strong&gt;Cause:&lt;/strong&gt; The volume permissions are wrong, usually after restoring from a backup. &lt;strong&gt;Fix:&lt;/strong&gt; Check ownership inside the volume: &lt;code&gt;docker run --rm -v postgres-data:/data alpine ls -la /data&lt;/code&gt;. PostgreSQL requires the data directory to be owned by UID 70 (the postgres user in Alpine). Fix with &lt;code&gt;docker run --rm -v postgres-data:/data alpine chown -R 70:70 /data&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id="conclusion"&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;You now have a fully functional, self-hosted Git service with &lt;strong&gt;PostgreSQL for reliability&lt;/strong&gt;, &lt;strong&gt;SSH passthrough for developer convenience&lt;/strong&gt;, &lt;strong&gt;CI/CD with Gitea Actions&lt;/strong&gt;, and &lt;strong&gt;automated backups&lt;/strong&gt;. The entire stack uses around 300MB of RAM and runs comfortably on the smallest VPS tier.&lt;/p&gt;
&lt;p&gt;Gitea is not a GitHub replacement in terms of social features and ecosystem. It is a GitHub replacement in terms of functionality you actually use daily: hosting code, reviewing pull requests, tracking issues, and running CI pipelines. The trade-off is maintenance responsibility — you own the uptime, the backups, and the security updates.&lt;/p&gt;
&lt;p&gt;For related self-hosting guides, check out my post on &lt;a href="https://blog.byte-guard.net/self-host-vaultwarden/" rel="noopener noreferrer"&gt;self-hosting Vaultwarden&lt;/a&gt; for password management alongside your Git server, and my &lt;a href="https://blog.byte-guard.net/docker-security-best-practices/" rel="noopener noreferrer"&gt;Docker security best practices&lt;/a&gt; for hardening the containers running Gitea.&lt;/p&gt;
&lt;p&gt;If you are setting up a self-hosted development environment, you will need a reliable VPS. I use Hetzner for all my projects — the CPX22 in Helsinki gives me solid performance for around 5 euros per month, and you can follow my &lt;a href="https://blog.byte-guard.net/building-byteguard-from-scratch-hetzner-vps/" rel="noopener noreferrer"&gt;infrastructure guide&lt;/a&gt; to get the base setup running.&lt;/p&gt;

</description>
      <category>selfhosting</category>
      <category>gitea</category>
      <category>docker</category>
      <category>git</category>
    </item>
    <item>
      <title>How to Build a Home Lab with Proxmox VE</title>
      <dc:creator>byteguard</dc:creator>
      <pubDate>Tue, 23 Jun 2026 18:21:30 +0000</pubDate>
      <link>https://dev.to/byte-guard/how-to-build-a-home-lab-with-proxmox-ve-25cj</link>
      <guid>https://dev.to/byte-guard/how-to-build-a-home-lab-with-proxmox-ve-25cj</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://blog.byte-guard.net/proxmox-home-lab-setup/" rel="noopener noreferrer"&gt;byte-guard.net&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A &lt;a href="https://blog.byte-guard.net/best-vps-self-hosting-hetzner-contabo-vultr/" rel="noopener noreferrer"&gt;VPS&lt;/a&gt; is great for running production services, but for learning, testing, and breaking things on purpose, nothing beats a home lab. I run my production blog on a Hetzner VPS, but my Proxmox home lab is where I test Docker configs, spin up vulnerable machines for CTF practice, and experiment with networking — all without risking anything that matters.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Proxmox VE&lt;/strong&gt; (Virtual Environment) is a free, open-source hypervisor built on Debian that supports both full virtual machines (KVM) and lightweight containers (LXC). It has a web-based management interface, built-in backup tools, and clustering support. If you want a &lt;strong&gt;proxmox home lab setup&lt;/strong&gt; that grows with your skills, this guide gets you from bare hardware to running VMs and containers.&lt;/p&gt;
&lt;h2 id="prerequisites"&gt;Prerequisites&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hardware:&lt;/strong&gt; A dedicated machine with at least 8GB RAM, a 64-bit CPU with virtualization support (Intel VT-x or AMD-V), and 120GB+ storage. An old desktop, a mini PC (like an Intel NUC or Beelink), or a used Dell Optiplex all work well.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A USB drive&lt;/strong&gt; (2GB+) for the Proxmox installer&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Network access&lt;/strong&gt; (Ethernet recommended — WiFi is possible but painful)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A separate computer&lt;/strong&gt; to access the Proxmox web interface&lt;/li&gt;
&lt;li&gt;Basic Linux familiarity — if you need a refresher, my &lt;a href="https://blog.byte-guard.net/harden-linux-vps-10-minutes/" rel="noopener noreferrer"&gt;VPS hardening guide&lt;/a&gt; covers core Linux admin concepts&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;strong&gt;Note:&lt;/strong&gt; Proxmox is a bare-metal hypervisor. It replaces whatever OS is currently on the machine. Don't install it on your daily-use computer. Dedicate hardware to it.&lt;/blockquote&gt;
&lt;h2 id="what-is-proxmox-ve-and-why-use-it"&gt;What Is Proxmox VE and Why Use It&lt;/h2&gt;
&lt;p&gt;Proxmox VE is a &lt;strong&gt;Type 1 hypervisor&lt;/strong&gt; — it runs directly on hardware, not inside another operating system. This gives it better performance than Type 2 hypervisors like VirtualBox or VMware Workstation that run on top of Windows or Linux.&lt;/p&gt;
&lt;p&gt;Here's what makes Proxmox the best choice for a home lab:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Free and open source.&lt;/strong&gt; The enterprise subscription is optional — it unlocks the stable update repository and support, but the free "no-subscription" repository works fine for home use.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full VMs (KVM) and containers (LXC)&lt;/strong&gt; on the same platform. Use VMs when you need full OS isolation, containers when you want lightweight services.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Web-based management.&lt;/strong&gt; Everything is done through a browser. No GUI installed on the host.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Snapshots and backups&lt;/strong&gt; built in. Break something? Roll back in seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clustering.&lt;/strong&gt; Start with one node, add more later. Proxmox scales from a single NUC to a rack of servers.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;The trade-off:&lt;/strong&gt; Proxmox's learning curve is steeper than spinning up a VM in VirtualBox. You're managing a real hypervisor with networking, storage pools, and resource allocation. That's also exactly why it's worth learning — these are production skills.&lt;/p&gt;
&lt;h2 id="hardware-recommendations-for-a-proxmox-home-lab"&gt;Hardware Recommendations for a Proxmox Home Lab&lt;/h2&gt;
&lt;p&gt;You don't need expensive hardware. Here's what I recommend at three budget levels:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Budget ($50-100 — used hardware):&lt;/strong&gt; - Dell Optiplex 7050 or HP EliteDesk 800 G3 (used on eBay) - 16GB DDR4 RAM (upgrade from stock) - 256GB SSD - Runs 3-5 lightweight VMs or 10+ containers comfortably&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Mid-range ($200-400 — mini PC):&lt;/strong&gt; - Beelink SER5 or Intel NUC 12/13 - 32GB DDR4/DDR5 RAM - 500GB NVMe SSD - Runs 8-10 VMs or 20+ containers, enough for a serious lab&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Performance ($500+ — enterprise surplus):&lt;/strong&gt; - Dell PowerEdge T340/R640 or HP DL360 - 64GB+ ECC RAM - Multiple SSDs in ZFS mirror - Enterprise networking (IPMI/iDRAC for remote management)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;My advice:&lt;/strong&gt; Start cheap. A $75 used Optiplex with a RAM upgrade teaches you everything. Upgrade when you hit limits, not before.&lt;/p&gt;
&lt;h2 id="installing-proxmox-ve"&gt;Installing Proxmox VE&lt;/h2&gt;
&lt;h3 id="step-1-download-the-iso"&gt;Step 1: Download the ISO&lt;/h3&gt;
&lt;p&gt;Grab the latest Proxmox VE ISO from the official site:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;https://www.proxmox.com/en/downloads/proxmox-virtual-environment/iso
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Download the &lt;code&gt;.iso&lt;/code&gt; file (about 1.2GB).&lt;/p&gt;
&lt;h3 id="step-2-create-a-bootable-usb"&gt;Step 2: Create a Bootable USB&lt;/h3&gt;
&lt;p&gt;On Linux:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Find your USB drive (be CAREFUL — wrong device = data loss)
lsblk

# Write the ISO (replace sdX with your USB device)
sudo dd bs=4M if=proxmox-ve_8.x-x.iso of=/dev/sdX conv=fsync status=progress
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On Windows, use &lt;strong&gt;Rufus&lt;/strong&gt; or &lt;strong&gt;Etcher&lt;/strong&gt;. Select "DD mode" if Rufus asks — ISO mode won't boot correctly for Proxmox.&lt;/p&gt;
&lt;blockquote&gt;
&lt;strong&gt;Warning:&lt;/strong&gt; The &lt;code&gt;dd&lt;/code&gt; command will &lt;strong&gt;destroy all data&lt;/strong&gt; on the target device. Triple-check the device name with &lt;code&gt;lsblk&lt;/code&gt; before running it. If your main drive is &lt;code&gt;/dev/sda&lt;/code&gt;, your USB is probably &lt;code&gt;/dev/sdb&lt;/code&gt; — but verify.&lt;/blockquote&gt;
&lt;h3 id="step-3-boot-and-install"&gt;Step 3: Boot and Install&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;Plug the USB into your Proxmox machine and boot from it (F2/F12/Del for BIOS/boot menu, varies by manufacturer).&lt;/li&gt;
&lt;li&gt;Select &lt;strong&gt;Install Proxmox VE (Graphical)&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Accept the EULA.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Select the target disk.&lt;/strong&gt; Proxmox will format this entire disk. If you have multiple drives, pick the one you want as the boot/system drive. For the filesystem, choose &lt;strong&gt;ext4&lt;/strong&gt; for simplicity or &lt;strong&gt;ZFS&lt;/strong&gt; if you have multiple drives and want redundancy.&lt;/li&gt;
&lt;li&gt;Set your &lt;strong&gt;country, timezone, and keyboard layout&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Set a &lt;strong&gt;strong root password&lt;/strong&gt; and enter an email address (used for alerts).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Configure networking:&lt;/strong&gt; - &lt;strong&gt;Management Interface:&lt;/strong&gt; Select your Ethernet adapter - &lt;strong&gt;Hostname:&lt;/strong&gt; Something like &lt;code&gt;pve.homelab.local&lt;/code&gt; - &lt;strong&gt;IP Address:&lt;/strong&gt; Assign a static IP on your LAN (e.g., &lt;code&gt;192.168.1.50/24&lt;/code&gt;) - &lt;strong&gt;Gateway:&lt;/strong&gt; Your router's IP (usually &lt;code&gt;192.168.1.1&lt;/code&gt;) - &lt;strong&gt;DNS Server:&lt;/strong&gt; Your router or &lt;code&gt;1.1.1.1&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Review the summary and click &lt;strong&gt;Install&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Installation takes 3-5 minutes. When done, remove the USB and reboot.&lt;/p&gt;
&lt;h3 id="step-4-access-the-web-interface"&gt;Step 4: Access the Web Interface&lt;/h3&gt;
&lt;p&gt;From another computer on the same network, open a browser and go to:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;https://192.168.1.50:8006
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Replace with the IP you set during installation. You'll get a certificate warning — that's expected (Proxmox uses a self-signed cert). Accept it and log in with:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Username:&lt;/strong&gt; &lt;code&gt;root&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Realm:&lt;/strong&gt; &lt;code&gt;Linux PAM standard authentication&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Password:&lt;/strong&gt; The password you set during install&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;You're in. The Proxmox dashboard shows your node's CPU, RAM, storage, and network usage.&lt;/p&gt;
&lt;h2 id="post-install-configuration"&gt;Post-Install Configuration&lt;/h2&gt;
&lt;h3 id="disable-the-enterprise-repository-free-users"&gt;Disable the Enterprise Repository (Free Users)&lt;/h3&gt;
&lt;p&gt;By default, Proxmox is configured to use the enterprise repository, which requires a paid subscription. Without it, you'll get errors on &lt;code&gt;apt update&lt;/code&gt;. Fix this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# SSH into your Proxmox host
ssh root@192.168.1.50

# Disable the enterprise repo
sed -i 's/^deb/#deb/' /etc/apt/sources.list.d/pve-enterprise.list

# Add the free no-subscription repo
echo "deb http://download.proxmox.com/debian/pve bookworm pve-no-subscription" &amp;gt; /etc/apt/sources.list.d/pve-no-subscription.list

# Update and upgrade
apt update &amp;amp;&amp;amp; apt full-upgrade -y
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id="remove-the-subscription-nag-optional"&gt;Remove the Subscription Nag (Optional)&lt;/h3&gt;
&lt;p&gt;Every time you log into the web UI, a popup reminds you that you don't have a subscription. It's harmless but annoying. To remove it:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sed -Ezi.bak "s/(Ext\.Msg\.show\(\{\s+title: gettext\('No valid sub)/void\(\{ \/\/\1/g" /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js
systemctl restart pveproxy.service
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;strong&gt;Note:&lt;/strong&gt; This change gets overwritten on Proxmox updates. You'll need to reapply it after upgrades. If you use Proxmox professionally, consider buying a subscription — it supports the project.&lt;/blockquote&gt;
&lt;h3 id="enable-iommu-for-gpupci-passthrough"&gt;Enable IOMMU (For GPU/PCI Passthrough)&lt;/h3&gt;
&lt;p&gt;If you plan to pass physical hardware (like a GPU) into a VM, enable IOMMU now:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# For Intel CPUs, edit GRUB
nano_is_not_allowed  # edit with vim instead
vim /etc/default/grub
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Change the &lt;code&gt;GRUB_CMDLINE_LINUX_DEFAULT&lt;/code&gt; line:&lt;/p&gt;
&lt;p&gt;For &lt;strong&gt;Intel&lt;/strong&gt; CPUs:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;GRUB_CMDLINE_LINUX_DEFAULT="quiet intel_iommu=on"
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For &lt;strong&gt;AMD&lt;/strong&gt; CPUs:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;GRUB_CMDLINE_LINUX_DEFAULT="quiet amd_iommu=on"
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then update GRUB and reboot:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;update-grub
reboot
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id="creating-your-first-virtual-machine"&gt;Creating Your First Virtual Machine&lt;/h2&gt;
&lt;p&gt;Let's create an Ubuntu Server VM — the most common starting point.&lt;/p&gt;
&lt;h3 id="step-1-upload-an-iso"&gt;Step 1: Upload an ISO&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;In the Proxmox web UI, click your &lt;strong&gt;node&lt;/strong&gt; in the left sidebar.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;local (pve)&lt;/strong&gt; storage → &lt;strong&gt;ISO Images&lt;/strong&gt; → &lt;strong&gt;Upload&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Upload the Ubuntu Server 24.04 LTS ISO (download from ubuntu.com if you don't have it).&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Alternatively, download directly on the Proxmox host:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cd /var/lib/vz/template/iso/
wget https://releases.ubuntu.com/24.04/ubuntu-24.04-live-server-amd64.iso
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id="step-2-create-the-vm"&gt;Step 2: Create the VM&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;Click &lt;strong&gt;Create VM&lt;/strong&gt; (top right of the web UI).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;General:&lt;/strong&gt; Give it a name (e.g., &lt;code&gt;ubuntu-server&lt;/code&gt;) and a VM ID.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OS:&lt;/strong&gt; Select the ISO you uploaded. Guest OS type: Linux, version: 6.x - 2.6 Kernel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;System:&lt;/strong&gt; Leave defaults. Check &lt;strong&gt;Qemu Agent&lt;/strong&gt; if you plan to install it (recommended).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disks:&lt;/strong&gt; Set disk size (32GB minimum for Ubuntu, I usually do 50GB). Use &lt;strong&gt;VirtIO Block&lt;/strong&gt; for best performance. Enable &lt;strong&gt;Discard&lt;/strong&gt; if using SSD storage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CPU:&lt;/strong&gt; Allocate cores (2 is fine for testing, 4 for heavier workloads). Set type to &lt;strong&gt;host&lt;/strong&gt; for best performance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory:&lt;/strong&gt; Allocate RAM in MiB (2048 = 2GB minimum, 4096 = 4GB recommended).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Network:&lt;/strong&gt; Leave default bridge (&lt;code&gt;vmbr0&lt;/code&gt;), model &lt;strong&gt;VirtIO (paravirtualized)&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Confirm&lt;/strong&gt; and check &lt;strong&gt;Start after created&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id="step-3-install-the-os"&gt;Step 3: Install the OS&lt;/h3&gt;
&lt;p&gt;Click on your new VM → &lt;strong&gt;Console&lt;/strong&gt;. You'll see the Ubuntu installer. Go through the standard installation — select language, configure networking (DHCP is fine for now), create a user, install OpenSSH server when prompted.&lt;/p&gt;
&lt;p&gt;Once installed, you can SSH into the VM from your main computer:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ssh your-user@&amp;lt;VM_IP_ADDRESS&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;strong&gt;Tip:&lt;/strong&gt; Install the QEMU guest agent inside the VM for better integration (graceful shutdown, IP reporting, etc.):&lt;br&gt;&lt;br&gt;&lt;code&gt;bash sudo apt install qemu-guest-agent -y sudo systemctl enable qemu-guest-agent --now&lt;/code&gt;
&lt;/blockquote&gt;
&lt;h2 id="creating-lxc-containers-%E2%80%94-lightweight-alternative-to-vms"&gt;Creating LXC Containers — Lightweight Alternative to VMs&lt;/h2&gt;
&lt;p&gt;LXC containers share the host kernel, which makes them use far less RAM and CPU than full VMs. They start in seconds instead of minutes. For services like web servers, databases, DNS, or monitoring tools, containers are the better choice.&lt;/p&gt;
&lt;h3 id="download-a-container-template"&gt;Download a Container Template&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;Click &lt;strong&gt;local (pve)&lt;/strong&gt; storage → &lt;strong&gt;CT Templates&lt;/strong&gt; → &lt;strong&gt;Templates&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Search for "ubuntu" or "debian" and download the template you want.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Or via CLI:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pveam update
pveam available --section system | grep ubuntu
pveam download local ubuntu-24.04-standard_24.04-2_amd64.tar.zst
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id="create-the-container"&gt;Create the Container&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;Click &lt;strong&gt;Create CT&lt;/strong&gt; (top right).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;General:&lt;/strong&gt; Set hostname (e.g., &lt;code&gt;docker-host&lt;/code&gt;), set a root password.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Template:&lt;/strong&gt; Select the template you downloaded.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disks:&lt;/strong&gt; 8-20GB depending on use case.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CPU:&lt;/strong&gt; 1-2 cores.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory:&lt;/strong&gt; 512MB-2048MB depending on what you're running.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Network:&lt;/strong&gt; Bridge &lt;code&gt;vmbr0&lt;/code&gt;, DHCP or static IP.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DNS:&lt;/strong&gt; Use host settings or set custom.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Confirm&lt;/strong&gt; and start.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The container boots in under 5 seconds. Access it via the console or SSH.&lt;/p&gt;
&lt;h3 id="running-docker-inside-lxc"&gt;Running Docker Inside LXC&lt;/h3&gt;
&lt;p&gt;This is a common home lab pattern — run Docker inside an LXC container to keep things isolated without the overhead of a full VM. I use this setup for many of my services.&lt;/p&gt;
&lt;p&gt;You need to enable nesting and a few features on the container. In the Proxmox web UI: select the container → &lt;strong&gt;Options&lt;/strong&gt; → &lt;strong&gt;Features&lt;/strong&gt; → enable &lt;strong&gt;Nesting&lt;/strong&gt; and &lt;strong&gt;keyctl&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Or edit the container config directly:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# On the Proxmox host
vim /etc/pve/lxc/&amp;lt;CT_ID&amp;gt;.conf
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Add or modify:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;features: nesting=1,keyctl=1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then inside the container, install Docker:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;apt update &amp;amp;&amp;amp; apt install -y curl
curl -fsSL https://get.docker.com | sh
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Docker inside LXC works well for most use cases. For more on securing your Docker setup, see my &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;h2 id="networking-in-proxmox"&gt;Networking in Proxmox&lt;/h2&gt;
&lt;h3 id="default-bridge-vmbr0"&gt;Default Bridge (vmbr0)&lt;/h3&gt;
&lt;p&gt;Out of the box, Proxmox creates a Linux bridge called &lt;code&gt;vmbr0&lt;/code&gt; that connects to your physical NIC. All VMs and containers attached to this bridge get IPs on your LAN — they're visible to other devices on your network.&lt;/p&gt;
&lt;p&gt;This is the simplest setup and works for most home labs.&lt;/p&gt;
&lt;h3 id="creating-an-internal-network"&gt;Creating an Internal Network&lt;/h3&gt;
&lt;p&gt;For isolated lab environments (like a pentesting lab), create a bridge without a physical interface:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Go to your &lt;strong&gt;Node&lt;/strong&gt; → &lt;strong&gt;Network&lt;/strong&gt; → &lt;strong&gt;Create&lt;/strong&gt; → &lt;strong&gt;Linux Bridge&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Name it &lt;code&gt;vmbr1&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Leave &lt;strong&gt;Bridge ports&lt;/strong&gt; empty (no physical NIC).&lt;/li&gt;
&lt;li&gt;Assign a subnet: &lt;code&gt;10.10.10.1/24&lt;/code&gt; as the bridge IP.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;VMs connected to &lt;code&gt;vmbr1&lt;/code&gt; can talk to each other but not to your LAN or the internet — perfect for vulnerable machine labs.&lt;/p&gt;
&lt;p&gt;To give them internet access through the Proxmox host (NAT), run on the host:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Enable IP forwarding
echo 'net.ipv4.ip_forward=1' &amp;gt;&amp;gt; /etc/sysctl.conf
sysctl -p

# Add NAT rule
iptables -t nat -A POSTROUTING -s 10.10.10.0/24 -o vmbr0 -j MASQUERADE
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;To make the iptables rule persistent:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;apt install iptables-persistent -y
netfilter-persistent save
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you've set up WireGuard before (I covered it in my &lt;a href="https://blog.byte-guard.net/wireguard-vpn-setup/" rel="noopener noreferrer"&gt;WireGuard VPN guide&lt;/a&gt;), the networking concepts here — bridges, NAT, forwarding — are the same principles.&lt;/p&gt;
&lt;h2 id="storage-configuration"&gt;Storage Configuration&lt;/h2&gt;
&lt;h3 id="understanding-proxmox-storage-types"&gt;Understanding Proxmox Storage Types&lt;/h3&gt;
&lt;p&gt;Proxmox supports multiple storage backends:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Storage Type&lt;/th&gt;
&lt;th&gt;Best For&lt;/th&gt;
&lt;th&gt;Supports Snapshots&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Local (dir)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;ISOs, backups, container templates&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Default, always available&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LVM&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;VM disks&lt;/td&gt;
&lt;td&gt;No (use LVM-Thin instead)&lt;/td&gt;
&lt;td&gt;Created during install&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LVM-Thin&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;VM disks, efficient snapshots&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Recommended for VM storage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ZFS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Everything, data integrity&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Needs more RAM (1GB per TB of storage)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;NFS/CIFS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Shared storage, backups&lt;/td&gt;
&lt;td&gt;Depends on backend&lt;/td&gt;
&lt;td&gt;For NAS integration&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For a single-disk home lab, the default LVM-Thin partition created during installation is fine. If you add a second drive later, I recommend ZFS mirror for redundancy:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Create a ZFS mirror from two drives
zpool create tank mirror /dev/sdb /dev/sdc

# Add it to Proxmox
pvesm add zfspool tank-pool --pool tank --content images,rootdir
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id="backups-%E2%80%94-dont-skip-this"&gt;Backups — Don't Skip This&lt;/h2&gt;
&lt;p&gt;The number one advantage of running a home lab on Proxmox is the ability to snapshot and back up everything. Use it.&lt;/p&gt;
&lt;h3 id="manual-backup"&gt;Manual Backup&lt;/h3&gt;
&lt;p&gt;In the web UI: select a VM or container → &lt;strong&gt;Backup&lt;/strong&gt; → &lt;strong&gt;Backup now&lt;/strong&gt;. Choose: - &lt;strong&gt;Storage:&lt;/strong&gt; Where to store the backup (local or NFS) - &lt;strong&gt;Mode:&lt;/strong&gt; Snapshot (no downtime), Suspend, or Stop - &lt;strong&gt;Compression:&lt;/strong&gt; ZSTD (best ratio/speed balance)&lt;/p&gt;
&lt;h3 id="scheduled-backups"&gt;Scheduled Backups&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;Go to &lt;strong&gt;Datacenter&lt;/strong&gt; → &lt;strong&gt;Backup&lt;/strong&gt; → &lt;strong&gt;Add&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Set the schedule (e.g., daily at 2:00 AM).&lt;/li&gt;
&lt;li&gt;Select which VMs/containers to include.&lt;/li&gt;
&lt;li&gt;Set retention (e.g., keep last 3 backups).&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;# Check existing backup jobs via CLI
cat /etc/pve/jobs.cfg
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id="snapshots"&gt;Snapshots&lt;/h3&gt;
&lt;p&gt;Snapshots capture the current state of a VM or container. They're instant and stored on the same disk.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Create a snapshot
qm snapshot &amp;lt;VMID&amp;gt; pre-update --description "Before apt upgrade"

# Roll back to a snapshot
qm rollback &amp;lt;VMID&amp;gt; pre-update
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Use snapshots before risky changes&lt;/strong&gt; — kernel upgrades, config rewrites, experimental software installs. If something breaks, roll back in seconds.&lt;/p&gt;
&lt;blockquote&gt;
&lt;strong&gt;Warning:&lt;/strong&gt; Snapshots are not backups. They live on the same disk as the VM. If the disk fails, snapshots are gone too. Use both: snapshots for quick rollback, backups for disaster recovery.&lt;/blockquote&gt;
&lt;h2 id="security-considerations"&gt;Security Considerations&lt;/h2&gt;
&lt;p&gt;Your Proxmox host is the single most important machine in your home lab. If it's compromised, every VM and container on it is compromised. Treat it accordingly:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Change the default web UI port&lt;/strong&gt; from 8006 if exposing to the internet (though you shouldn't expose it).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Create a non-root user&lt;/strong&gt; for daily management. Go to &lt;strong&gt;Datacenter&lt;/strong&gt; → &lt;strong&gt;Permissions&lt;/strong&gt; → &lt;strong&gt;Users&lt;/strong&gt; → &lt;strong&gt;Add&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enable the firewall.&lt;/strong&gt; Proxmox has a built-in firewall configurable from the web UI. At minimum, restrict management access to your LAN.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep Proxmox updated.&lt;/strong&gt; Run &lt;code&gt;apt update &amp;amp;&amp;amp; apt full-upgrade&lt;/code&gt; regularly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't expose the web interface to the internet.&lt;/strong&gt; If you need remote access, use a VPN (like &lt;a href="https://blog.byte-guard.net/wireguard-vpn-setup/" rel="noopener noreferrer"&gt;WireGuard&lt;/a&gt;) to connect to your home network first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use strong passwords and consider two-factor authentication.&lt;/strong&gt; Proxmox supports TOTP 2FA out of the box. Enable it under &lt;strong&gt;Datacenter&lt;/strong&gt; → &lt;strong&gt;Permissions&lt;/strong&gt; → &lt;strong&gt;Two Factor&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="troubleshooting"&gt;Troubleshooting&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Problem:&lt;/strong&gt; Web interface unreachable after installation. &lt;strong&gt;Cause:&lt;/strong&gt; Incorrect IP configuration during install, or the machine is on a different subnet than your computer. &lt;strong&gt;Fix:&lt;/strong&gt; Connect a monitor and keyboard directly. Check the IP with &lt;code&gt;ip addr show vmbr0&lt;/code&gt;. Verify your computer is on the same subnet. Test with &lt;code&gt;ping 192.168.1.50&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Problem:&lt;/strong&gt; VM boots but gets no network connectivity. &lt;strong&gt;Cause:&lt;/strong&gt; The VM's network interface isn't connected to the bridge, or DHCP isn't available on the bridged network. &lt;strong&gt;Fix:&lt;/strong&gt; Check that the VM's network device is set to bridge &lt;code&gt;vmbr0&lt;/code&gt;. Inside the VM, verify the interface is up with &lt;code&gt;ip link&lt;/code&gt;. Try a static IP configuration if DHCP isn't working.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Problem:&lt;/strong&gt; "No valid subscription" error on &lt;code&gt;apt update&lt;/code&gt;. &lt;strong&gt;Cause:&lt;/strong&gt; The enterprise repository is enabled but you don't have a subscription key. &lt;strong&gt;Fix:&lt;/strong&gt; Disable the enterprise repo and enable the no-subscription repo as shown in the post-install section above.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Problem:&lt;/strong&gt; LXC container can't run Docker. &lt;strong&gt;Cause:&lt;/strong&gt; Nesting and keyctl features aren't enabled on the container. &lt;strong&gt;Fix:&lt;/strong&gt; Enable nesting in the container options: &lt;strong&gt;Options&lt;/strong&gt; → &lt;strong&gt;Features&lt;/strong&gt; → check &lt;strong&gt;Nesting&lt;/strong&gt; and &lt;strong&gt;keyctl&lt;/strong&gt;. Restart the container.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Problem:&lt;/strong&gt; ZFS is using too much RAM. &lt;strong&gt;Cause:&lt;/strong&gt; ZFS uses RAM for its ARC (Adaptive Replacement Cache) and defaults to claiming up to 50% of system RAM. &lt;strong&gt;Fix:&lt;/strong&gt; Limit the ARC size. Add to &lt;code&gt;/etc/modprobe.d/zfs.conf&lt;/code&gt;: &lt;code&gt;options zfs zfs_arc_max=2147483648&lt;/code&gt; (2GB limit). Run &lt;code&gt;update-initramfs -u&lt;/code&gt; and reboot.&lt;/p&gt;
&lt;h2 id="conclusion"&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;A &lt;strong&gt;Proxmox home lab&lt;/strong&gt; is the best investment you can make in your technical skills. It costs less than a month of cloud hosting, runs on hardware you probably already have, and gives you a safe environment to learn virtualization, networking, containers, and security — all on real infrastructure, not simulators.&lt;/p&gt;
&lt;p&gt;Start with a single machine, create a few VMs and containers, and experiment. Break things on purpose. That's what a lab is for. When you're ready to run production services, the skills transfer directly to cloud VPS management.&lt;/p&gt;
&lt;p&gt;For next steps, harden your Proxmox host using my &lt;a href="https://blog.byte-guard.net/harden-linux-vps-10-minutes/" rel="noopener noreferrer"&gt;Linux hardening guide&lt;/a&gt;, run Docker inside an LXC container following my &lt;a href="https://blog.byte-guard.net/docker-security-best-practices/" rel="noopener noreferrer"&gt;Docker security guide&lt;/a&gt;, and set up WireGuard for remote access using my &lt;a href="https://blog.byte-guard.net/wireguard-vpn-setup/" rel="noopener noreferrer"&gt;VPN setup tutorial&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Your lab. Your rules. No monthly bill.&lt;/p&gt;

</description>
      <category>proxmox</category>
      <category>homelab</category>
      <category>virtualization</category>
      <category>selfhosting</category>
    </item>
    <item>
      <title>Hetzner Review 2026: My Honest Experience</title>
      <dc:creator>byteguard</dc:creator>
      <pubDate>Tue, 23 Jun 2026 18:20:53 +0000</pubDate>
      <link>https://dev.to/byte-guard/hetzner-review-2026-my-honest-experience-nd2</link>
      <guid>https://dev.to/byte-guard/hetzner-review-2026-my-honest-experience-nd2</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://blog.byte-guard.net/hetzner-review-2026/" rel="noopener noreferrer"&gt;byte-guard.net&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I've been running the entire ByteGuard stack on a Hetzner CPX22 in Helsinki for months. Ghost blog, Nginx Proxy Manager, &lt;a href="https://blog.byte-guard.net/uptime-kuma-setup-guide/" rel="noopener noreferrer"&gt;Uptime Kuma&lt;/a&gt;, Vaultwarden, &lt;a href="https://blog.byte-guard.net/wireguard-vpn-setup/" rel="noopener noreferrer"&gt;WireGuard&lt;/a&gt; — all on a single VPS. This isn't a spec-sheet comparison or a regurgitation of their marketing page. This is a &lt;strong&gt;Hetzner review&lt;/strong&gt; based on actual daily use, real benchmarks, and real support interactions.&lt;/p&gt;
&lt;p&gt;If you're shopping for a VPS for self-hosting, development, or a small production workload, here's what you need to know about Hetzner Cloud in 2026.&lt;/p&gt;
&lt;h2 id="what-im-running"&gt;What I'm Running&lt;/h2&gt;
&lt;p&gt;My setup for context:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Plan:&lt;/strong&gt; CPX22 (3 vCPU AMD, 4GB RAM, 80GB NVMe SSD)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Location:&lt;/strong&gt; Helsinki, Finland (eu-central)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OS:&lt;/strong&gt; Ubuntu 24.04 LTS&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Workload:&lt;/strong&gt; 6 &lt;a href="https://blog.byte-guard.net/docker-security-best-practices/" rel="noopener noreferrer"&gt;Docker&lt;/a&gt; containers (Ghost, NPM, Uptime Kuma, Vaultwarden, WireGuard, plus occasional tools)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monthly cost:&lt;/strong&gt; €5.39/month (billed hourly)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I chose Hetzner over DigitalOcean, Vultr, and Contabo. I documented the full comparison in my &lt;a href="https://blog.byte-guard.net/best-vps-self-hosting-hetzner-contabo-vultr/" rel="noopener noreferrer"&gt;VPS comparison post&lt;/a&gt;, and walked through the initial setup in my &lt;a href="https://blog.byte-guard.net/building-byteguard-from-scratch-hetzner-vps/" rel="noopener noreferrer"&gt;building ByteGuard guide&lt;/a&gt;.&lt;/p&gt;

&lt;h2 id="performance-benchmarks"&gt;Performance Benchmarks&lt;/h2&gt;
&lt;p&gt;These are real numbers from my CPX22, not theoretical maximums.&lt;/p&gt;
&lt;h3 id="disk-io"&gt;Disk I/O&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;fio --name=write --ioengine=libaio --rw=write --bs=1M --size=1G --numjobs=1 --runtime=10 --group_reporting
fio --name=read --ioengine=libaio --rw=read --bs=1M --size=1G --numjobs=1 --runtime=10 --group_reporting
&lt;/code&gt;&lt;/pre&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sequential write&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.1 GB/s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sequential read&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;788 MB/s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Random 4K write (IOPS)&lt;/td&gt;
&lt;td&gt;~45,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Random 4K read (IOPS)&lt;/td&gt;
&lt;td&gt;~52,000&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These are NVMe numbers. For comparison, &lt;a href="https://www.dpbolvw.net/click-101731802-13796470" rel="noopener noreferrer"&gt;my Contabo VPS&lt;/a&gt; with "SSD" storage gets 1.0 GB/s write and 1.1 GB/s read — but the random IOPS tell the real story. Hetzner's NVMe consistently outperforms in real workloads where random I/O matters (databases, Docker overlay storage).&lt;/p&gt;
&lt;h3 id="cpu-performance"&gt;CPU Performance&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;openssl speed -evp aes-256-gcm
&lt;/code&gt;&lt;/pre&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;AES-256-GCM throughput&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~10.24 GB/s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPU model&lt;/td&gt;
&lt;td&gt;AMD EPYC (Genoa)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The EPYC Genoa processors Hetzner uses are current-gen server CPUs. The AES-NI performance is excellent — relevant for VPN, SSL termination, and encrypted storage. For comparison, Contabo's older EPYC processors hit about 3.05 GB/s on the same test.&lt;/p&gt;
&lt;h3 id="network"&gt;Network&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;speedtest-cli --simple
&lt;/code&gt;&lt;/pre&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Download&lt;/td&gt;
&lt;td&gt;~920 Mbps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Upload&lt;/td&gt;
&lt;td&gt;~890 Mbps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ping to Frankfurt&lt;/td&gt;
&lt;td&gt;~12ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ping to New York&lt;/td&gt;
&lt;td&gt;~110ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Hetzner includes 20TB of outbound traffic per month on the CPX22. For a blog and a handful of services, I've never come close to using even 1% of that. Inbound traffic is unlimited and free.&lt;/p&gt;

&lt;h2 id="pricing-breakdown"&gt;Pricing Breakdown&lt;/h2&gt;
&lt;p&gt;Hetzner Cloud pricing as of 2026:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Plan&lt;/th&gt;
&lt;th&gt;vCPU&lt;/th&gt;
&lt;th&gt;RAM&lt;/th&gt;
&lt;th&gt;SSD&lt;/th&gt;
&lt;th&gt;Traffic&lt;/th&gt;
&lt;th&gt;Price&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CX22&lt;/td&gt;
&lt;td&gt;2 (Intel)&lt;/td&gt;
&lt;td&gt;4GB&lt;/td&gt;
&lt;td&gt;40GB&lt;/td&gt;
&lt;td&gt;20TB&lt;/td&gt;
&lt;td&gt;€3.99/mo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPX22&lt;/td&gt;
&lt;td&gt;3 (AMD)&lt;/td&gt;
&lt;td&gt;4GB&lt;/td&gt;
&lt;td&gt;80GB&lt;/td&gt;
&lt;td&gt;20TB&lt;/td&gt;
&lt;td&gt;€5.39/mo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPX32&lt;/td&gt;
&lt;td&gt;4 (AMD)&lt;/td&gt;
&lt;td&gt;8GB&lt;/td&gt;
&lt;td&gt;160GB&lt;/td&gt;
&lt;td&gt;20TB&lt;/td&gt;
&lt;td&gt;€8.49/mo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPX42&lt;/td&gt;
&lt;td&gt;8 (AMD)&lt;/td&gt;
&lt;td&gt;16GB&lt;/td&gt;
&lt;td&gt;240GB&lt;/td&gt;
&lt;td&gt;20TB&lt;/td&gt;
&lt;td&gt;€15.99/mo&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The CPX (AMD) line offers better performance per euro than the CX (Intel) line. The AMD EPYC processors consistently benchmark higher, and you get more SSD storage at the same price point.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Hidden costs?&lt;/strong&gt; Almost none. Snapshots are €0.012/GB/month. Floating IPs are €0.50/month. Load balancers start at €5.39/month. But for a self-hosting setup, the base VPS price is likely all you'll pay.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Billing:&lt;/strong&gt; Hourly, capped at the monthly price. Spin up a server, test for 3 hours, destroy it, pay cents. This is great for experimentation.&lt;/p&gt;

&lt;h2 id="dashboard-and-ux"&gt;Dashboard and UX&lt;/h2&gt;
&lt;p&gt;The Hetzner Cloud Console is minimal but functional. It's not as polished as DigitalOcean's, but it has everything you need:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Server management&lt;/strong&gt; — start, stop, reboot, resize, rebuild&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Networking&lt;/strong&gt; — private networks, floating IPs, firewall rules&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Snapshots&lt;/strong&gt; — create, restore, schedule&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Graphs&lt;/strong&gt; — CPU, disk I/O, network (basic but useful)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Console&lt;/strong&gt; — web-based VNC console for emergency access (saved me once when I locked myself out of SSH)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;What I appreciate:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fast provisioning.&lt;/strong&gt; A new server is ready in under 30 seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Firewall rules in the cloud console.&lt;/strong&gt; These apply at the network level, before traffic reaches your VPS. Combined with UFW on the server, it's defense in depth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API and CLI.&lt;/strong&gt; The &lt;code&gt;hcloud&lt;/code&gt; CLI tool handles everything the dashboard does. Useful for scripting and automation.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;What I wish was better:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No managed databases.&lt;/strong&gt; DigitalOcean and Vultr offer managed PostgreSQL/MySQL. Hetzner doesn't. For self-hosters this doesn't matter (you run your own), but it's a gap in the product line.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monitoring is basic.&lt;/strong&gt; The built-in graphs show CPU, disk, and network at a high level. No alerting, no custom metrics. I use Uptime Kuma instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two-factor auth is SMS-only.&lt;/strong&gt; In 2026, not offering TOTP for account security is a miss. Use a strong, unique password and watch your account email.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id="support-experience"&gt;Support Experience&lt;/h2&gt;
&lt;p&gt;I've contacted Hetzner support twice:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Network routing issue&lt;/strong&gt; (Helsinki DC) — submitted ticket at 2am, got a human response in 4 hours, issue resolved in 6. Not instant, but competent and professional.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Billing question&lt;/strong&gt; about snapshot costs — responded within 2 hours with a clear breakdown.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Support is ticket-based only. No live chat, no phone. For the price point, this is expected. The responses are technical and direct — no "have you tried restarting?" tier-1 scripts.&lt;/p&gt;
&lt;p&gt;The community forum is also active and helpful for non-urgent questions.&lt;/p&gt;

&lt;h2 id="pros"&gt;Pros&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Price-to-performance ratio is unbeatable.&lt;/strong&gt; The CPX22 at €5.39/month with AMD EPYC, NVMe storage, and 20TB traffic competes with providers charging 2-3x more.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Honest resource allocation.&lt;/strong&gt; No "burstable" CPU nonsense. What you see is what you get. The 3 vCPUs on my CPX22 are consistently available.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;European data centers with GDPR compliance.&lt;/strong&gt; Helsinki, Falkenstein, Nuremberg, Ashburn (US). All EU data centers are GDPR-compliant, which matters if you host anything with user data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;20TB traffic included.&lt;/strong&gt; Most self-hosters will never hit this. DigitalOcean includes less and charges for overages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hourly billing.&lt;/strong&gt; Spin up, test, destroy. No commitment.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2 id="cons"&gt;Cons&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Limited US presence.&lt;/strong&gt; Only Ashburn (Virginia). If your audience is primarily US West Coast or Asia-Pacific, latency will be noticeable. Consider Vultr or DigitalOcean for those regions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No managed services.&lt;/strong&gt; No managed databases, no managed Kubernetes (they have basic k8s but it's not fully managed), no app platform. You manage everything yourself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SMS-only 2FA.&lt;/strong&gt; A genuine security concern for your hosting account. Mitigate with a strong password and monitoring for unauthorized access.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Support is slow for urgent issues.&lt;/strong&gt; If your production server is down at 3am, a 4-hour response time feels long. Enterprise users should look elsewhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Community/ecosystem is smaller than DigitalOcean.&lt;/strong&gt; Fewer one-click apps, fewer tutorials, fewer integrations. This matters less if you're comfortable with manual setup.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2 id="who-should-use-hetzner"&gt;Who Should Use Hetzner&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Self-hosters&lt;/strong&gt; running Docker stacks on a budget. This is Hetzner's sweet spot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Developers&lt;/strong&gt; who need cheap, powerful VMs for testing and side projects.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small businesses&lt;/strong&gt; in Europe who need GDPR-compliant hosting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anyone&lt;/strong&gt; who wants the most compute per dollar and is comfortable managing their own server.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="who-should-skip-hetzner"&gt;Who Should Skip Hetzner&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Beginners&lt;/strong&gt; who want managed services, one-click deploys, and hand-holding. DigitalOcean's App Platform is better for that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;US-focused products&lt;/strong&gt; needing low-latency West Coast or Asia presence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enterprise teams&lt;/strong&gt; needing guaranteed SLAs, phone support, and managed infrastructure. Look at AWS, GCP, or a managed provider.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id="troubleshooting"&gt;Troubleshooting&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Problem: Server unreachable after creation.&lt;/strong&gt; Cause: Cloud firewall blocking SSH or your IP. Fix: Check Hetzner Cloud Console → Firewall Rules. The default firewall blocks everything. Add a rule allowing SSH (port 22/tcp) from your IP.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Problem: Slow disk I/O compared to benchmarks.&lt;/strong&gt; Cause: Using local SSD storage instead of NVMe, or noisy neighbors. Fix: Verify you're on a CPX (AMD) plan, not CX (Intel). The CPX line uses newer hardware. If performance is consistently below spec, contact support — you might be on degraded hardware.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Problem: Can't connect to web console.&lt;/strong&gt; Cause: Browser blocking popups or WebSocket connections. Fix: Allow popups from console.hetzner.cloud. Try a different browser. The console uses VNC over WebSocket.&lt;/p&gt;

&lt;h2 id="verdict"&gt;Verdict&lt;/h2&gt;
&lt;p&gt;Hetzner is my default recommendation for anyone self-hosting on a budget. The CPX22 at €5.39/month punches well above its weight — AMD EPYC, NVMe storage, generous traffic, and honest resource allocation.&lt;/p&gt;
&lt;p&gt;It's not perfect. The US presence is limited, managed services are nonexistent, and support is slower than premium providers. But for the self-hosting audience — people who enjoy running their own infrastructure — these trade-offs are acceptable.&lt;/p&gt;
&lt;p&gt;I run my entire blog, monitoring, VPN, and password manager on a single Hetzner CPX22. Total monthly cost: €5.39. That's less than a Notion subscription.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;My recommendation:&lt;/strong&gt; Start with a CPX22. If you outgrow it, resize to CPX32 in the dashboard — it takes about 30 seconds and preserves all your data.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://www.hetzner.com/cloud/" rel="noopener noreferrer"&gt;Get started with Hetzner Cloud →&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;For a deeper comparison with Contabo and Vultr, check my &lt;a href="https://blog.byte-guard.net/best-vps-self-hosting-hetzner-contabo-vultr/" rel="noopener noreferrer"&gt;VPS comparison post&lt;/a&gt;. And if you're setting up a new server from scratch, start with my &lt;a href="https://blog.byte-guard.net/building-byteguard-from-scratch-hetzner-vps/" rel="noopener noreferrer"&gt;building ByteGuard guide&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>hetzner</category>
      <category>vps</category>
      <category>review</category>
      <category>cloud</category>
    </item>
    <item>
      <title>How to Set Up Fail2Ban on Your Server</title>
      <dc:creator>byteguard</dc:creator>
      <pubDate>Tue, 23 Jun 2026 18:20:17 +0000</pubDate>
      <link>https://dev.to/byte-guard/how-to-set-up-fail2ban-on-your-server-1ip9</link>
      <guid>https://dev.to/byte-guard/how-to-set-up-fail2ban-on-your-server-1ip9</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://blog.byte-guard.net/fail2ban-setup-guide/" rel="noopener noreferrer"&gt;byte-guard.net&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Install and configure Fail2Ban to automatically block IPs that brute-force your SSH, Nginx, or other services. Includes custom jails for web services, aggressive repeat-offender banning, Docker integration, and email alert setup.&lt;/p&gt;

&lt;p&gt;Every public-facing server is a target. SSH brute-force bots, WordPress login scanners, API credential stuffers — they run 24/7 against every IP on the internet. Your firewall blocks unauthorized ports, but what about the ports you intentionally keep open?&lt;/p&gt;
&lt;p&gt;This guide is part of our &lt;a href="https://blog.byte-guard.net/self-hosted-alternatives-saas/" rel="noopener noreferrer"&gt;&lt;strong&gt;Self-Hosting Series&lt;/strong&gt;&lt;/a&gt; — step-by-step guides for running your own services on a VPS.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Fail2Ban&lt;/strong&gt; watches your log files for failed authentication attempts and automatically bans offending IPs. It's the automated bouncer for your server. This &lt;strong&gt;Fail2Ban setup guide&lt;/strong&gt; covers installation, SSH jail configuration, custom jails for web services, Docker integration, and email alerts.&lt;/p&gt;
&lt;p&gt;I run Fail2Ban on the same Hetzner &lt;a href="https://blog.byte-guard.net/best-vps-self-hosting-hetzner-contabo-vultr/" rel="noopener noreferrer"&gt;VPS&lt;/a&gt; that hosts this blog, and it bans hundreds of IPs per week — all without any manual intervention.&lt;/p&gt;
&lt;h2 id="what-do-you-need-to-set-up-fail2ban"&gt;What Do You Need to Set Up Fail2Ban?&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;A Linux VPS running Ubuntu 22.04/24.04 or Debian 12&lt;/li&gt;
&lt;li&gt;Root or sudo access&lt;/li&gt;
&lt;li&gt;SSH hardened (&lt;a href="https://blog.byte-guard.net/ssh-hardening-guide/" rel="noopener noreferrer"&gt;my SSH hardening guide&lt;/a&gt; covers the full setup)&lt;/li&gt;
&lt;li&gt;Basic understanding of log files and firewall rules&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id="how-do-you-install-fail2ban-on-ubuntudebian"&gt;How Do You Install Fail2Ban on Ubuntu/Debian?&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;sudo apt update &amp;amp;&amp;amp; sudo apt install fail2ban -y
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Fail2Ban installs but doesn't configure any jails by default (on modern Ubuntu/Debian). Start and enable the service:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo systemctl enable fail2ban
sudo systemctl start fail2ban
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Check it's running:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo systemctl status fail2ban
&lt;/code&gt;&lt;/pre&gt;

&lt;h2 id="how-does-fail2ban-work"&gt;How Does Fail2Ban Work?&lt;/h2&gt;
&lt;p&gt;Before configuring, understand how the pieces fit together:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Jails&lt;/strong&gt; — define what to monitor and what to do. Each jail watches a specific log file with a specific filter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filters&lt;/strong&gt; — regex patterns that match failed authentication attempts in log files.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Actions&lt;/strong&gt; — what happens when the threshold is reached (ban IP, send email, etc.).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ban logic&lt;/strong&gt; — if an IP triggers &lt;code&gt;maxretry&lt;/code&gt; failures within &lt;code&gt;findtime&lt;/code&gt; seconds, it's banned for &lt;code&gt;bantime&lt;/code&gt; seconds.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Config files: - &lt;code&gt;/etc/fail2ban/jail.conf&lt;/code&gt; — defaults. &lt;strong&gt;Never edit this&lt;/strong&gt; — it gets overwritten on updates. - &lt;code&gt;/etc/fail2ban/jail.local&lt;/code&gt; — your overrides. This is where all your config goes. - &lt;code&gt;/etc/fail2ban/jail.d/*.conf&lt;/code&gt; — additional jail files (also override-safe). - &lt;code&gt;/etc/fail2ban/filter.d/*.conf&lt;/code&gt; — filter definitions (regex patterns).&lt;/p&gt;

&lt;h2 id="how-do-you-configure-the-fail2ban-ssh-jail"&gt;How Do You Configure the Fail2Ban SSH Jail?&lt;/h2&gt;
&lt;p&gt;Create your local config:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo tee /etc/fail2ban/jail.local &amp;lt;&amp;lt; 'EOF'
[DEFAULT]
# Ban for 1 hour
bantime = 3600
# Window of time to count failures
findtime = 600
# Max failures before ban
maxretry = 3
# Use UFW for banning (change to iptables if not using UFW)
banaction = ufw
# Ignore your own IP (replace with yours)
ignoreip = 127.0.0.1/8 ::1

[sshd]
enabled = true
port = ssh
filter = sshd
logpath = /var/log/auth.log
maxretry = 3
bantime = 3600
EOF
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you changed your SSH port (as recommended in my &lt;a href="https://blog.byte-guard.net/ssh-hardening-guide/" rel="noopener noreferrer"&gt;SSH hardening guide&lt;/a&gt;), update the port:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[sshd]
enabled = true
port = 2222
filter = sshd
logpath = /var/log/auth.log
maxretry = 3
bantime = 3600
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Restart Fail2Ban to apply:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo systemctl restart fail2ban
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Check the jail status:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo fail2ban-client status sshd
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Expected output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Status for the jail: sshd
|- Filter
|  |- Currently failed: 2
|  |- Total failed:     47
|  `- File list:        /var/log/auth.log
`- Actions
   |- Currently banned: 1
   |- Total banned:     12
   `- Banned IP list:   203.0.113.42
&lt;/code&gt;&lt;/pre&gt;

&lt;h2 id="how-do-you-set-up-aggressive-banning-for-repeat-offenders"&gt;How Do You Set Up Aggressive Banning for Repeat Offenders?&lt;/h2&gt;
&lt;p&gt;The default 1-hour ban is fine for casual bots. But some IPs come back repeatedly. Add a recidive jail that escalates bans:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo tee -a /etc/fail2ban/jail.local &amp;lt;&amp;lt; 'EOF'

[recidive]
enabled = true
filter = recidive
logpath = /var/log/fail2ban.log
bantime = 604800    # 1 week
findtime = 86400    # 24 hours
maxretry = 3        # 3 bans in 24 hours = 1 week ban
banaction = ufw
EOF
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This watches Fail2Ban's own log. If an IP gets banned 3 times in 24 hours, it gets a 1-week ban. Persistent attackers get escalating consequences.&lt;/p&gt;

&lt;h2 id="how-do-you-create-custom-fail2ban-jails-for-web-services"&gt;How Do You Create Custom Fail2Ban Jails for Web Services?&lt;/h2&gt;
&lt;h3 id="nginx-http-auth"&gt;Nginx HTTP Auth&lt;/h3&gt;
&lt;p&gt;If you protect any web services with HTTP basic auth:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo tee -a /etc/fail2ban/jail.local &amp;lt;&amp;lt; 'EOF'

[nginx-http-auth]
enabled = true
filter = nginx-http-auth
logpath = /var/log/nginx/error.log
maxretry = 3
bantime = 3600
EOF
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id="nginx-rate-limiting"&gt;Nginx Rate Limiting&lt;/h3&gt;
&lt;p&gt;Create a custom filter for Nginx 429 responses:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo tee /etc/fail2ban/filter.d/nginx-limit-req.conf &amp;lt;&amp;lt; 'EOF'
[Definition]
failregex = limiting requests, excess:.* by zone.*client: &amp;lt;HOST&amp;gt;
ignoreregex =
EOF
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Add the jail:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo tee -a /etc/fail2ban/jail.local &amp;lt;&amp;lt; 'EOF'

[nginx-limit-req]
enabled = true
filter = nginx-limit-req
logpath = /var/log/nginx/error.log
maxretry = 5
bantime = 600
EOF
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id="ghost-or-any-web-app-login-protection"&gt;Ghost (or any web app) Login Protection&lt;/h3&gt;
&lt;p&gt;Create a custom filter that watches for repeated 401/403 responses:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo tee /etc/fail2ban/filter.d/ghost-login.conf &amp;lt;&amp;lt; 'EOF'
[Definition]
failregex = ^&amp;lt;HOST&amp;gt; .* "POST /ghost/api/.*/session" (401|403)
ignoreregex =
EOF
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Add the jail (adjust the log path to your Nginx access log):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo tee -a /etc/fail2ban/jail.local &amp;lt;&amp;lt; 'EOF'

[ghost-login]
enabled = true
filter = ghost-login
logpath = /var/log/nginx/access.log
maxretry = 5
bantime = 1800
EOF
&lt;/code&gt;&lt;/pre&gt;

&lt;h2 id="how-do-you-use-fail2ban-with-docker-services"&gt;How Do You Use Fail2Ban with Docker Services?&lt;/h2&gt;
&lt;p&gt;Docker containers log to Docker's logging driver, not system log files. If your web server runs in Docker, you need to either:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Option A:&lt;/strong&gt; Mount logs from the container to the host and point Fail2Ban at them.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;services:
  nginx:
    image: nginx:latest
    volumes:
      - /var/log/nginx:/var/log/nginx
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Option B:&lt;/strong&gt; Use Docker's JSON log files directly:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Find the container's log file
docker inspect --format='{{.LogPath}}' nginx
# Output: /var/lib/docker/containers/&amp;lt;id&amp;gt;/&amp;lt;id&amp;gt;-json.log
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Point Fail2Ban at this file. You'll need a custom filter that handles Docker's JSON log format:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo tee /etc/fail2ban/filter.d/docker-nginx.conf &amp;lt;&amp;lt; 'EOF'
[Definition]
failregex = .*"log":"&amp;lt;HOST&amp;gt; .* (401|403).*
ignoreregex =
EOF
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Option A is simpler and more reliable.&lt;/strong&gt; I recommend mounting log volumes from your containers.&lt;/p&gt;

&lt;h2 id="how-do-you-set-up-fail2ban-email-notifications"&gt;How Do You Set Up Fail2Ban Email Notifications?&lt;/h2&gt;
&lt;p&gt;Get notified when Fail2Ban bans someone:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo apt install mailutils -y
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Update your jail.local &lt;code&gt;[DEFAULT]&lt;/code&gt; section:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[DEFAULT]
bantime = 3600
findtime = 600
maxretry = 3
banaction = ufw
ignoreip = 127.0.0.1/8 ::1

# Email notifications
destemail = you@yourdomain.com
sender = fail2ban@yourdomain.com
mta = mail
action = %(action_mwl)s
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;action_mwl&lt;/code&gt; action bans the IP &lt;strong&gt;and&lt;/strong&gt; sends an email with the whois info and relevant log lines. It's the most informative option.&lt;/p&gt;
&lt;blockquote&gt;
&lt;strong&gt;Note:&lt;/strong&gt; Email requires a working MTA (mail transfer agent) on your server. If you don't have one configured, use &lt;code&gt;sendmail&lt;/code&gt; or integrate with an external SMTP service. For my setup, I use Brevo's SMTP relay.&lt;/blockquote&gt;

&lt;h2 id="what-are-the-most-useful-fail2ban-commands"&gt;What Are the Most Useful Fail2Ban Commands?&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# Check overall status
sudo fail2ban-client status

# Check a specific jail
sudo fail2ban-client status sshd

# Manually ban an IP
sudo fail2ban-client set sshd banip 203.0.113.42

# Manually unban an IP
sudo fail2ban-client set sshd unbanip 203.0.113.42

# Check which IPs are banned
sudo fail2ban-client get sshd banned

# Test a filter against a log file (dry run)
sudo fail2ban-regex /var/log/auth.log /etc/fail2ban/filter.d/sshd.conf

# Reload config without restarting
sudo fail2ban-client reload
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;fail2ban-regex&lt;/code&gt; command is invaluable when creating custom filters. Test your regex against the actual log file before enabling the jail.&lt;/p&gt;

&lt;h2 id="what-are-the-key-fail2ban-security-considerations"&gt;What Are the Key Fail2Ban Security Considerations?&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Don't ban yourself.&lt;/strong&gt; Always add your own IP (or IP range) to &lt;code&gt;ignoreip&lt;/code&gt;. If you get locked out, you'll need console access from your hosting provider to fix it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ban duration trade-offs.&lt;/strong&gt; Short bans (10 minutes) reduce bot noise but don't deter determined attackers. Long bans (1 week) are more effective but risk banning legitimate users behind shared IPs (NAT, corporate networks). The recidive jail offers a good middle ground.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Log rotation.&lt;/strong&gt; Fail2Ban follows log files. If your log rotation setup creates new files (instead of truncating), Fail2Ban might lose track. Verify with &lt;code&gt;fail2ban-client status &amp;lt;jail&amp;gt;&lt;/code&gt; that the file list is correct after rotation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resource usage.&lt;/strong&gt; Fail2Ban uses regex on log files, which can be CPU-intensive on busy servers with verbose logs. Keep your filter regexes efficient and avoid overly broad patterns.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fail2Ban as a complement, not a replacement.&lt;/strong&gt; Fail2Ban is reactive — it bans after failed attempts. Combine it with proactive measures: &lt;a href="https://blog.byte-guard.net/ssh-hardening-guide/" rel="noopener noreferrer"&gt;SSH key-based auth&lt;/a&gt;, strong passwords, and &lt;a href="https://blog.byte-guard.net/docker-security-best-practices/" rel="noopener noreferrer"&gt;Docker security practices&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id="how-do-you-troubleshoot-fail2ban-configuration-issues"&gt;How Do You Troubleshoot Fail2Ban Configuration Issues?&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Problem: Fail2Ban service won't start.&lt;/strong&gt; Cause: Syntax error in jail.local. Fix: Check the logs: &lt;code&gt;sudo journalctl -u fail2ban -n 50&lt;/code&gt;. Common issues: missing &lt;code&gt;enabled = true&lt;/code&gt;, wrong log path, or invalid regex in custom filters.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Problem: IPs aren't getting banned despite failed attempts.&lt;/strong&gt; Cause: Wrong log path, wrong filter, or the log format doesn't match the filter regex. Fix: Run &lt;code&gt;sudo fail2ban-regex /var/log/auth.log /etc/fail2ban/filter.d/sshd.conf&lt;/code&gt; to test. If matches are 0, the filter doesn't match your log format.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Problem: Banned myself.&lt;/strong&gt; Cause: Too many failed SSH attempts from your IP, or &lt;code&gt;ignoreip&lt;/code&gt; not configured. Fix: Access via your hosting provider's console. Run &lt;code&gt;sudo fail2ban-client set sshd unbanip YOUR_IP&lt;/code&gt;. Add your IP to &lt;code&gt;ignoreip&lt;/code&gt; in jail.local.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Problem: Bans don't persist after Fail2Ban restart.&lt;/strong&gt; Cause: Fail2Ban clears its ban list on restart by default. Fix: Set &lt;code&gt;bantime = -1&lt;/code&gt; for permanent bans (use with caution), or use the &lt;code&gt;dbpurgeage&lt;/code&gt; setting in jail.local to control how long ban records are kept.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Problem: UFW ban action fails.&lt;/strong&gt; Cause: UFW isn't installed or the &lt;code&gt;banaction&lt;/code&gt; is misconfigured. Fix: Verify &lt;code&gt;sudo ufw status&lt;/code&gt; shows UFW is active. If you're not using UFW, change &lt;code&gt;banaction&lt;/code&gt; to &lt;code&gt;iptables-multiport&lt;/code&gt;.&lt;/p&gt;

&lt;h2 id="what-is-the-result-of-a-properly-configured-fail2ban-setup"&gt;What Is the Result of a Properly Configured Fail2Ban Setup?&lt;/h2&gt;
&lt;p&gt;Fail2Ban is one of those tools that runs quietly in the background and makes a measurable difference. On my server, it bans 200+ IPs per week — that's 200+ fewer bots hammering my SSH port and web services.&lt;/p&gt;
&lt;p&gt;The setup takes 15 minutes. The SSH jail alone is worth it. Add the recidive jail and custom web service jails as your stack grows.&lt;/p&gt;
&lt;p&gt;For the complete server security picture, pair Fail2Ban with:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://blog.byte-guard.net/ssh-hardening-guide/" rel="noopener noreferrer"&gt;SSH hardening&lt;/a&gt; — disable password auth, key-based only&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://blog.byte-guard.net/harden-linux-vps-10-minutes/" rel="noopener noreferrer"&gt;VPS hardening basics&lt;/a&gt; — firewall, updates, user setup&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://blog.byte-guard.net/docker-security-best-practices/" rel="noopener noreferrer"&gt;Docker security practices&lt;/a&gt; — if your services run in containers&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you need a VPS, I use &lt;a href="https://www.hetzner.com/cloud/" rel="noopener noreferrer"&gt;Hetzner&lt;/a&gt; for everything — affordable and reliable.&lt;/p&gt;

</description>
      <category>fail2ban</category>
      <category>security</category>
      <category>linux</category>
      <category>serveradministration</category>
    </item>
    <item>
      <title>How to Check Your Website's Security Headers</title>
      <dc:creator>byteguard</dc:creator>
      <pubDate>Tue, 23 Jun 2026 18:19:39 +0000</pubDate>
      <link>https://dev.to/byte-guard/how-to-check-your-websites-security-headers-1p73</link>
      <guid>https://dev.to/byte-guard/how-to-check-your-websites-security-headers-1p73</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://blog.byte-guard.net/check-security-headers/" rel="noopener noreferrer"&gt;byte-guard.net&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every HTTP response your server sends includes headers that browsers use to decide how to handle your content. Get them wrong -- or leave them out entirely -- and you are handing attackers an open invitation. Clickjacking, cross-site scripting, MIME-type sniffing, protocol downgrade attacks: &lt;strong&gt;security headers&lt;/strong&gt; are the first line of defense against all of these.&lt;/p&gt;
&lt;p&gt;This guide is part of our &lt;a href="https://blog.byte-guard.net/best-free-security-tools-2026/" rel="noopener noreferrer"&gt;&lt;strong&gt;Security Tools Series&lt;/strong&gt;&lt;/a&gt; — hands-on guides for the tools every security-minded developer needs.&lt;/p&gt;
&lt;p&gt;The problem is that most default server configurations ship with almost none of these headers set. I have audited dozens of self-hosted services, and the majority score an &lt;strong&gt;F&lt;/strong&gt; on security header scanners. The good news: fixing this takes about fifteen minutes once you understand what each header does.&lt;/p&gt;
&lt;p&gt;By the end of this post, you will know how to &lt;strong&gt;check security headers&lt;/strong&gt; on any website, understand what each critical header does, and configure all of them in Nginx, Apache, and Caddy.&lt;/p&gt;
&lt;h2 id="prerequisites"&gt;Prerequisites&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;A live website or web application you control&lt;/li&gt;
&lt;li&gt;SSH access to your server (if you plan to add headers)&lt;/li&gt;
&lt;li&gt;One of: &lt;strong&gt;Nginx&lt;/strong&gt;, &lt;strong&gt;Apache&lt;/strong&gt;, or &lt;strong&gt;Caddy&lt;/strong&gt; as your web server or reverse proxy&lt;/li&gt;
&lt;li&gt;Basic familiarity with your server's configuration files&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you are starting from scratch, my guide on &lt;a href="https://blog.byte-guard.net/building-byteguard-from-scratch-hetzner-vps/" rel="noopener noreferrer"&gt;setting up a VPS from scratch&lt;/a&gt; covers the initial server setup.&lt;/p&gt;
&lt;h2 id="how-to-check-security-headers-on-any-website"&gt;How to Check Security Headers on Any Website&lt;/h2&gt;
&lt;h3 id="using-online-scanners"&gt;Using Online Scanners&lt;/h3&gt;
&lt;p&gt;The fastest way to &lt;strong&gt;check security headers&lt;/strong&gt; is with a dedicated scanner. Here are the tools I actually use:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;securityheaders.com&lt;/strong&gt; -- Gives you a letter grade (A+ to F) and lists every missing header. Free, instant results.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mozilla Observatory&lt;/strong&gt; (observatory.mozilla.org) -- More comprehensive. Checks headers, TLS, and other best practices. Scores out of 100.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;tools.byte-guard.net&lt;/strong&gt; -- Our own &lt;a href="https://tools.byte-guard.net" rel="noopener noreferrer"&gt;header checker tool&lt;/a&gt; gives you a clean breakdown of what is present, what is missing, and exact configuration snippets to fix each issue.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="using-curl-from-the-command-line"&gt;Using curl from the Command Line&lt;/h3&gt;
&lt;p&gt;For a quick check without leaving your terminal:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl -I https://your-domain.com
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;-I&lt;/code&gt; flag fetches only the headers. You will see something like:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;HTTP/2 200
content-type: text/html; charset=utf-8
strict-transport-security: max-age=31536000; includeSubDomains
x-content-type-options: nosniff
x-frame-options: SAMEORIGIN
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you do not see headers like &lt;code&gt;Strict-Transport-Security&lt;/code&gt;, &lt;code&gt;Content-Security-Policy&lt;/code&gt;, or &lt;code&gt;X-Content-Type-Options&lt;/code&gt; in that output, your site is missing critical protections.&lt;/p&gt;
&lt;h3 id="using-browser-developer-tools"&gt;Using Browser Developer Tools&lt;/h3&gt;
&lt;p&gt;Open your browser's developer tools (F12), go to the &lt;strong&gt;Network&lt;/strong&gt; tab, reload the page, click on the main document request, and check the &lt;strong&gt;Response Headers&lt;/strong&gt; section. This is useful when you need to verify headers on pages that require authentication.&lt;/p&gt;
&lt;h2 id="the-six-security-headers-that-matter-most"&gt;The Six Security Headers That Matter Most&lt;/h2&gt;
&lt;p&gt;Let me walk through each header, what it protects against, and how to configure it across all three major web servers.&lt;/p&gt;
&lt;h3 id="strict-transport-security-hsts"&gt;Strict-Transport-Security (HSTS)&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;What it does:&lt;/strong&gt; Tells browsers to only connect to your site over HTTPS. Once a browser sees this header, it will refuse to load your site over plain HTTP for the specified duration -- even if the user types &lt;code&gt;http://&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What it prevents:&lt;/strong&gt; Protocol downgrade attacks, SSL stripping, cookie hijacking over unencrypted connections.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Nginx:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Apache:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Caddy:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;strong&gt;Note:&lt;/strong&gt; The &lt;code&gt;preload&lt;/code&gt; directive submits your domain to browser preload lists (hstspreload.org). Only add it if you are certain every subdomain supports HTTPS. Removing a domain from the preload list takes months.&lt;/blockquote&gt;
&lt;p&gt;The &lt;code&gt;max-age=31536000&lt;/code&gt; value is one year in seconds. Start with a shorter value like &lt;code&gt;86400&lt;/code&gt; (one day) while testing, then increase it once you have confirmed everything works.&lt;/p&gt;
&lt;h3 id="content-security-policy-csp"&gt;Content-Security-Policy (CSP)&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Content-Security-Policy&lt;/strong&gt; is the most powerful -- and most complex -- security header. It tells the browser exactly which sources are allowed to load scripts, styles, images, fonts, and other resources on your page.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What it prevents:&lt;/strong&gt; Cross-site scripting (XSS), data injection, clickjacking (partially), and unauthorized resource loading.&lt;/p&gt;
&lt;p&gt;A restrictive CSP looks like this:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Nginx:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Apache:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Header always set Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Caddy:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Breaking down the directives:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Directive&lt;/th&gt;
&lt;th&gt;Controls&lt;/th&gt;
&lt;th&gt;Example Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;default-src&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Fallback for all resource types&lt;/td&gt;
&lt;td&gt;&lt;code&gt;'self'&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;script-src&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;JavaScript sources&lt;/td&gt;
&lt;td&gt;&lt;code&gt;'self' https://cdn.example.com&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;style-src&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;CSS sources&lt;/td&gt;
&lt;td&gt;&lt;code&gt;'self' 'unsafe-inline'&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;img-src&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Image sources&lt;/td&gt;
&lt;td&gt;&lt;code&gt;'self' data: https:&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;font-src&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Font file sources&lt;/td&gt;
&lt;td&gt;&lt;code&gt;'self' https://fonts.gstatic.com&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;frame-ancestors&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Who can embed your page&lt;/td&gt;
&lt;td&gt;&lt;code&gt;'none'&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;form-action&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Where forms can submit&lt;/td&gt;
&lt;td&gt;&lt;code&gt;'self'&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;


&lt;blockquote&gt;
&lt;strong&gt;Warning:&lt;/strong&gt; A misconfigured CSP will break your site. Start with &lt;code&gt;Content-Security-Policy-Report-Only&lt;/code&gt; to log violations without blocking anything. Check the browser console for blocked resources, adjust your policy, then switch to the enforcing header.&lt;/blockquote&gt;
&lt;p&gt;If you run Docker containers behind a reverse proxy (as I covered in my &lt;a href="https://blog.byte-guard.net/docker-security-best-practices/" rel="noopener noreferrer"&gt;Docker security guide&lt;/a&gt;), CSP is especially important because each container may serve content from different origins.&lt;/p&gt;
&lt;h3 id="x-frame-options"&gt;X-Frame-Options&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;What it does:&lt;/strong&gt; Controls whether your site can be embedded in &lt;code&gt;&amp;lt;iframe&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;frame&amp;gt;&lt;/code&gt;, or &lt;code&gt;&amp;lt;object&amp;gt;&lt;/code&gt; elements on other sites.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What it prevents:&lt;/strong&gt; Clickjacking attacks, where an attacker overlays your site with invisible frames to trick users into clicking things they did not intend to.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Nginx:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;add_header X-Frame-Options "SAMEORIGIN" always;&lt;br&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Apache:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Header always set X-Frame-Options "SAMEORIGIN"&lt;br&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Caddy:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;header X-Frame-Options "SAMEORIGIN"&lt;br&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The three possible values:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;DENY&lt;/code&gt; -- No one can frame your page, not even your own site&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SAMEORIGIN&lt;/code&gt; -- Only pages on the same origin can frame it&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ALLOW-FROM &lt;a href="https://example.com" rel="noopener noreferrer"&gt;https://example.com&lt;/a&gt;&lt;/code&gt; -- Only the specified origin (deprecated in modern browsers)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For most sites, &lt;code&gt;SAMEORIGIN&lt;/code&gt; is the right choice. Use &lt;code&gt;DENY&lt;/code&gt; if you have no reason to embed your own pages.&lt;/p&gt;
&lt;blockquote&gt;
&lt;strong&gt;Note:&lt;/strong&gt; The &lt;code&gt;frame-ancestors&lt;/code&gt; directive in CSP is the modern replacement for X-Frame-Options. Set both for backward compatibility.&lt;/blockquote&gt;
&lt;h3 id="x-content-type-options"&gt;X-Content-Type-Options&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;What it does:&lt;/strong&gt; Prevents browsers from MIME-type sniffing -- guessing the content type of a response instead of trusting the &lt;code&gt;Content-Type&lt;/code&gt; header.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What it prevents:&lt;/strong&gt; Drive-by downloads, where a browser interprets an uploaded file (say, a &lt;code&gt;.txt&lt;/code&gt; that contains JavaScript) as executable content.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Nginx:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;add_header X-Content-Type-Options "nosniff" always;&lt;br&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Apache:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Header always set X-Content-Type-Options "nosniff"&lt;br&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Caddy:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;header X-Content-Type-Options "nosniff"&lt;br&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This one is simple: the only valid value is &lt;code&gt;nosniff&lt;/code&gt;. There is no reason not to set it on every site.&lt;/p&gt;
&lt;h3 id="referrer-policy"&gt;Referrer-Policy&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;What it does:&lt;/strong&gt; Controls how much referrer information is sent when users navigate away from your site.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Why it matters:&lt;/strong&gt; Without this header, the full URL (including query parameters that may contain tokens, session IDs, or other sensitive data) gets sent to external sites.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Nginx:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;add_header Referrer-Policy "strict-origin-when-cross-origin" always;&lt;br&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Apache:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Header always set Referrer-Policy "strict-origin-when-cross-origin"&lt;br&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Caddy:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;header Referrer-Policy "strict-origin-when-cross-origin"&lt;br&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The most common values:&lt;/p&gt;


&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Behavior&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;no-referrer&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Never send referrer info&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;same-origin&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Only send referrer for same-origin requests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;strict-origin-when-cross-origin&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Send full URL for same-origin, only origin for cross-origin, nothing for HTTPS-to-HTTP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;no-referrer-when-downgrade&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Browser default -- send everything except on HTTPS-to-HTTP&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I recommend &lt;code&gt;strict-origin-when-cross-origin&lt;/code&gt; for most sites. It preserves analytics functionality while protecting sensitive URL paths.&lt;/p&gt;
&lt;h3 id="permissions-policy"&gt;Permissions-Policy&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;What it does:&lt;/strong&gt; Controls which browser features and APIs your site can use -- camera, microphone, geolocation, payment, and more.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What it prevents:&lt;/strong&gt; Malicious scripts or embedded content from accessing device features without your knowledge.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Nginx:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Apache:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Header always set Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()"
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Caddy:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()"
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;()&lt;/code&gt; value means the feature is disabled entirely. If your site needs camera access (for a video chat feature, for example), you would use &lt;code&gt;camera=(self)&lt;/code&gt; to allow it only from your own origin.&lt;/p&gt;
&lt;h2 id="putting-it-all-together-complete-configuration"&gt;Putting It All Together: Complete Configuration&lt;/h2&gt;
&lt;p&gt;Here is a complete configuration block for each web server with all six headers. If you are choosing a reverse proxy, I compared &lt;a href="https://blog.byte-guard.net/nginx-proxy-manager-vs-traefik-vs-caddy/" rel="noopener noreferrer"&gt;Nginx Proxy Manager, Traefik, and Caddy&lt;/a&gt; in a separate post.&lt;/p&gt;
&lt;h3 id="complete-nginx-configuration"&gt;Complete Nginx Configuration&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# /etc/nginx/snippets/security-headers.conf
# Include this in your server blocks: include /etc/nginx/snippets/security-headers.conf;

add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then in each server block:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;server {
    listen 443 ssl http2;
    server_name &amp;lt;YOUR_DOMAIN&amp;gt;;

    include /etc/nginx/snippets/security-headers.conf;

    # ... rest of your config
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id="complete-apache-configuration"&gt;Complete Apache Configuration&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# /etc/apache2/conf-available/security-headers.conf
# Enable with: a2enconf security-headers &amp;amp;&amp;amp; systemctl reload apache2

&amp;lt;IfModule mod_headers.c&amp;gt;
    Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
    Header always set Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
    Header always set X-Frame-Options "SAMEORIGIN"
    Header always set X-Content-Type-Options "nosniff"
    Header always set Referrer-Policy "strict-origin-when-cross-origin"
    Header always set Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()"
&amp;lt;/IfModule&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;strong&gt;Note:&lt;/strong&gt; Apache requires &lt;code&gt;mod_headers&lt;/code&gt; to be enabled. Run &lt;code&gt;a2enmod headers &amp;amp;&amp;amp; systemctl restart apache2&lt;/code&gt; if it is not already active.&lt;/blockquote&gt;
&lt;h3 id="complete-caddy-configuration"&gt;Complete Caddy Configuration&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;YOUR_DOMAIN&amp;gt; {
    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
        Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
        X-Frame-Options "SAMEORIGIN"
        X-Content-Type-Options "nosniff"
        Referrer-Policy "strict-origin-when-cross-origin"
        Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()"
    }

    reverse_proxy localhost:8080
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Caddy's configuration is the most concise. It also handles HTTPS automatically, which means HSTS is already partially covered -- but you should still set the header explicitly for preloading.&lt;/p&gt;
&lt;h2 id="security-considerations"&gt;Security Considerations&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Order matters with reverse proxies.&lt;/strong&gt; If you are running Nginx Proxy Manager in front of an application that also sets security headers, the proxy's headers may override or duplicate the application's headers. Always check the final response with &lt;code&gt;curl -I&lt;/code&gt; to verify what the browser actually receives.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;CSP can break third-party integrations.&lt;/strong&gt; If you use analytics (Plausible, Umami), comment systems (Giscus, Disqus), or embed YouTube videos, you need to whitelist those domains in the relevant CSP directives. Do not disable CSP because one widget broke -- add the specific source.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;HSTS preloading is a commitment.&lt;/strong&gt; Once your domain is on the preload list, browsers will refuse HTTP connections to any subdomain. If you have internal services running on HTTP behind a VPN, preloading will cause problems. Consider whether &lt;code&gt;includeSubDomains&lt;/code&gt; is appropriate for your setup.&lt;/p&gt;
&lt;p&gt;If you have followed my &lt;a href="https://blog.byte-guard.net/harden-linux-vps-10-minutes/" rel="noopener noreferrer"&gt;VPS hardening guide&lt;/a&gt;, security headers are the natural next step -- your server is locked down at the OS level, and now you are locking down the application layer.&lt;/p&gt;
&lt;h2 id="troubleshooting"&gt;Troubleshooting&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Problem:&lt;/strong&gt; Headers are not showing up in &lt;code&gt;curl -I&lt;/code&gt; output after adding them. &lt;strong&gt;Cause:&lt;/strong&gt; Configuration file not loaded, or syntax error preventing reload. &lt;strong&gt;Fix:&lt;/strong&gt; Check for errors with &lt;code&gt;nginx -t&lt;/code&gt; (Nginx), &lt;code&gt;apachectl configtest&lt;/code&gt; (Apache), or &lt;code&gt;caddy validate&lt;/code&gt; (Caddy). Then reload the service. Do not restart -- reload is sufficient and avoids downtime.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Problem:&lt;/strong&gt; Site breaks after adding Content-Security-Policy. &lt;strong&gt;Cause:&lt;/strong&gt; The CSP is blocking legitimate resources (scripts, styles, fonts) that your site needs. &lt;strong&gt;Fix:&lt;/strong&gt; Open browser developer tools, check the Console tab for CSP violation messages. Each message tells you exactly which resource was blocked and which directive blocked it. Add the source to the appropriate directive.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Problem:&lt;/strong&gt; Security header scanner still shows a low grade after adding all headers. &lt;strong&gt;Cause:&lt;/strong&gt; Your application or CDN is overriding headers set by the web server, or the scanner is checking a different URL than where you added the headers. &lt;strong&gt;Fix:&lt;/strong&gt; Verify with &lt;code&gt;curl -I https://your-exact-url.com&lt;/code&gt; that the headers appear in the raw response. If using a CDN like Cloudflare, check their dashboard -- some CDNs strip or modify security headers.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Problem:&lt;/strong&gt; X-Frame-Options set to DENY but you need to embed your own pages. &lt;strong&gt;Cause:&lt;/strong&gt; &lt;code&gt;DENY&lt;/code&gt; blocks all framing, including same-origin. &lt;strong&gt;Fix:&lt;/strong&gt; Switch to &lt;code&gt;SAMEORIGIN&lt;/code&gt; and set &lt;code&gt;frame-ancestors 'self'&lt;/code&gt; in your CSP.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Problem:&lt;/strong&gt; HSTS causing issues on development/staging environments. &lt;strong&gt;Cause:&lt;/strong&gt; Browser cached the HSTS policy from a previous visit and now refuses HTTP. &lt;strong&gt;Fix:&lt;/strong&gt; In Chrome, go to &lt;code&gt;chrome://net-internals/#hsts&lt;/code&gt;, enter your domain under "Delete domain security policies", and click Delete. In Firefox, clear your browsing history for that domain. Never set HSTS headers on non-production environments.&lt;/p&gt;
&lt;h2 id="conclusion"&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;Setting up &lt;strong&gt;security headers&lt;/strong&gt; is one of the highest-impact, lowest-effort security improvements you can make. Six headers, a few lines of configuration, and your site goes from an F to an A+ on security scanners.&lt;/p&gt;
&lt;p&gt;The key takeaway: start with everything except CSP, verify with &lt;code&gt;curl -I&lt;/code&gt;, then build your CSP gradually using &lt;code&gt;Content-Security-Policy-Report-Only&lt;/code&gt; before switching to enforcement.&lt;/p&gt;
&lt;p&gt;If you are running self-hosted services behind a reverse proxy, check out my &lt;a href="https://blog.byte-guard.net/nginx-proxy-manager-vs-traefik-vs-caddy/" rel="noopener noreferrer"&gt;comparison of Nginx Proxy Manager, Traefik, and Caddy&lt;/a&gt; to pick the right proxy for your setup. And if your server itself is not hardened 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; before worrying about HTTP headers.&lt;/p&gt;
&lt;p&gt;Need a reliable VPS to host your projects? I run all of ByteGuard on Hetzner -- solid performance, fair pricing, and European data centers. &lt;a href="https://www.hetzner.com/cloud" rel="noopener noreferrer"&gt;Check out Hetzner's cloud plans&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>security</category>
      <category>httpheaders</category>
      <category>nginx</category>
      <category>websecurity</category>
    </item>
    <item>
      <title>Advanced Docker Container Security Guide</title>
      <dc:creator>byteguard</dc:creator>
      <pubDate>Tue, 23 Jun 2026 18:19:02 +0000</pubDate>
      <link>https://dev.to/byte-guard/advanced-docker-container-security-guide-5ecf</link>
      <guid>https://dev.to/byte-guard/advanced-docker-container-security-guide-5ecf</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://blog.byte-guard.net/advanced-docker-container-security/" rel="noopener noreferrer"&gt;byte-guard.net&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If you followed my earlier post on &lt;a href="https://blog.byte-guard.net/docker-security-best-practices/" rel="noopener noreferrer"&gt;Docker security best practices&lt;/a&gt;, you already know the fundamentals: non-root users, read-only filesystems, dropped capabilities. Those practices eliminate the low-hanging fruit. But containers run in production, face real attackers, and the basics are table stakes, not a finish line. &lt;strong&gt;Advanced Docker container security&lt;/strong&gt; requires tools and techniques that go deeper — kernel-level restrictions, image provenance verification, runtime anomaly detection, and automated vulnerability scanning baked into your pipeline.&lt;/p&gt;
&lt;p&gt;This guide covers the next layer. By the end, you will have seccomp profiles restricting system calls, AppArmor policies limiting file access, Trivy scanning your images for CVEs, Falco watching for suspicious runtime behavior, and Docker Content Trust verifying image signatures. These are the techniques that separate a hobbyist Docker setup from a hardened production deployment.&lt;/p&gt;
&lt;h2 id="prerequisites"&gt;Prerequisites&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;A Linux server running Docker Engine 24+ (Ubuntu 22.04 or Debian 12 recommended)&lt;/li&gt;
&lt;li&gt;Docker Compose v2 installed&lt;/li&gt;
&lt;li&gt;Basic familiarity with Docker (images, containers, volumes, networking)&lt;/li&gt;
&lt;li&gt;Completion of the fundamentals from my &lt;a href="https://blog.byte-guard.net/docker-security-best-practices/" rel="noopener noreferrer"&gt;Docker security best practices&lt;/a&gt; guide&lt;/li&gt;
&lt;li&gt;A VPS or home lab — if you need one, my &lt;a href="https://blog.byte-guard.net/building-byteguard-from-scratch-hetzner-vps/" rel="noopener noreferrer"&gt;VPS setup guide&lt;/a&gt; covers the infrastructure&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="seccomp-profiles-%E2%80%94-restricting-system-calls"&gt;Seccomp Profiles — Restricting System Calls&lt;/h2&gt;
&lt;p&gt;Seccomp (Secure Computing Mode) filters which &lt;strong&gt;Linux system calls&lt;/strong&gt; a container can make. Docker ships with a default seccomp profile that blocks around 44 of the 300+ available syscalls — things like &lt;code&gt;reboot&lt;/code&gt;, &lt;code&gt;mount&lt;/code&gt;, and &lt;code&gt;clock_settime&lt;/code&gt;. But the default profile is permissive by design. A custom profile lets you whitelist only the syscalls your application actually needs.&lt;/p&gt;
&lt;h3 id="why-this-matters"&gt;Why This Matters&lt;/h3&gt;
&lt;p&gt;If an attacker gains code execution inside a container, their first move is often to escape to the host. Most container escape techniques rely on specific syscalls — &lt;code&gt;ptrace&lt;/code&gt;, &lt;code&gt;unshare&lt;/code&gt;, &lt;code&gt;mount&lt;/code&gt;. A tight seccomp profile makes these escapes impossible even if the container runtime has a zero-day.&lt;/p&gt;
&lt;h3 id="creating-a-custom-seccomp-profile"&gt;Creating a Custom Seccomp Profile&lt;/h3&gt;
&lt;p&gt;Start by examining what syscalls your application uses. Run your container with the default profile and audit:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker run --rm --security-opt seccomp=unconfined \
  strace -cf -S calls your-image your-command 2&amp;gt;&amp;amp;1 | tail -20
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This gives you a frequency count of every syscall your process makes. Build your allowlist from this output.&lt;/p&gt;
&lt;p&gt;Here is a minimal seccomp profile for a typical Node.js web application:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  "defaultAction": "SCMP_ACT_ERRNO",
  "architectures": ["SCMP_ARCH_X86_64"],
  "syscalls": [
    {
      "names": [
        "accept", "accept4", "access", "arch_prctl", "bind", "brk",
        "capget", "capset", "chdir", "clock_getres", "clock_gettime",
        "clone", "close", "connect", "dup", "dup2", "dup3",
        "epoll_create1", "epoll_ctl", "epoll_wait", "eventfd2",
        "execve", "exit", "exit_group", "faccessat", "fchmod",
        "fchown", "fcntl", "fstat", "fstatfs", "futex",
        "getcwd", "getdents64", "getegid", "geteuid", "getgid",
        "getpeername", "getpid", "getppid", "getrandom", "getsockname",
        "getsockopt", "getuid", "ioctl", "listen", "lseek",
        "madvise", "memfd_create", "mmap", "mprotect", "mremap",
        "munmap", "newfstatat", "openat", "pipe2", "poll",
        "prctl", "pread64", "prlimit64", "read", "readlink",
        "recvfrom", "recvmsg", "rename", "rt_sigaction",
        "rt_sigprocmask", "rt_sigreturn", "sched_getaffinity",
        "sched_yield", "sendmsg", "sendto", "set_robust_list",
        "set_tid_address", "setgid", "setgroups", "setsockopt",
        "setuid", "shutdown", "sigaltstack", "socket", "statfs",
        "sysinfo", "tgkill", "umask", "uname", "unlink",
        "wait4", "write", "writev"
      ],
      "action": "SCMP_ACT_ALLOW"
    }
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Save this as &lt;code&gt;seccomp-nodejs.json&lt;/code&gt; and apply it:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker run --rm \
  --security-opt seccomp=seccomp-nodejs.json \
  your-node-app
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In Docker Compose:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;services:
  web:
    image: your-node-app
    security_opt:
      - seccomp=./seccomp-nodejs.json
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;strong&gt;Note:&lt;/strong&gt; Start with the default Docker profile and remove syscalls rather than building from scratch. The default profile is at &lt;code&gt;/etc/docker/seccomp/default.json&lt;/code&gt; on most installations, or you can export it from the Moby repository on GitHub.&lt;/blockquote&gt;
&lt;h3 id="testing-your-profile"&gt;Testing Your Profile&lt;/h3&gt;
&lt;p&gt;Run your application's full test suite with the profile applied. If a syscall is blocked, you will see &lt;code&gt;EPERM&lt;/code&gt; (Operation not permitted) errors in your application logs. Add the missing syscall to your allowlist and retest.&lt;/p&gt;
&lt;h2 id="apparmor-policies-%E2%80%94-restricting-file-and-network-access"&gt;AppArmor Policies — Restricting File and Network Access&lt;/h2&gt;
&lt;p&gt;While seccomp controls &lt;strong&gt;what syscalls&lt;/strong&gt; a process can make, AppArmor controls &lt;strong&gt;what resources&lt;/strong&gt; it can access — files, directories, network operations, capabilities. They complement each other: seccomp is the verb filter, AppArmor is the noun filter.&lt;/p&gt;
&lt;h3 id="creating-a-custom-apparmor-profile"&gt;Creating a Custom AppArmor Profile&lt;/h3&gt;
&lt;p&gt;Here is an AppArmor profile for a container running a Python web API that should only read from &lt;code&gt;/app&lt;/code&gt; and write to &lt;code&gt;/tmp&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cat &amp;gt; /etc/apparmor.d/docker-python-api &amp;lt;&amp;lt; 'PROFILE'
#include &amp;lt;tunables/global&amp;gt;

profile docker-python-api flags=(attach_disconnected,mediate_deleted) {
  #include &amp;lt;abstractions/base&amp;gt;
  #include &amp;lt;abstractions/python&amp;gt;

  # Allow reading application code
  /app/** r,

  # Allow writing to tmp only
  /tmp/** rw,

  # Allow network access (TCP)
  network inet tcp,
  network inet6 tcp,

  # Deny everything else by default
  deny /etc/shadow r,
  deny /etc/passwd w,
  deny /proc/*/mem rw,
  deny /sys/** w,
}
PROFILE
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Load the profile:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo apparmor_parser -r /etc/apparmor.d/docker-python-api
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Apply it to your container:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker run --rm \
  --security-opt apparmor=docker-python-api \
  your-python-api
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id="verifying-apparmor-is-active"&gt;Verifying AppArmor Is Active&lt;/h3&gt;
&lt;p&gt;Check the status of loaded profiles:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo aa-status | grep docker
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You should see your profile listed under "enforced." If it appears under "complain," the profile logs violations but does not block them — useful for testing but not for production.&lt;/p&gt;
&lt;h2 id="docker-content-trust-%E2%80%94-image-signing-and-verification"&gt;Docker Content Trust — Image Signing and Verification&lt;/h2&gt;
&lt;p&gt;How do you know the image you pulled is the one the author published? Docker Content Trust (DCT) uses digital signatures to verify image integrity and publisher identity. Without it, you trust the registry implicitly — and registries have been compromised before.&lt;/p&gt;
&lt;h3 id="enabling-docker-content-trust"&gt;Enabling Docker Content Trust&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;export DOCKER_CONTENT_TRUST=1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With this set, &lt;code&gt;docker pull&lt;/code&gt; and &lt;code&gt;docker push&lt;/code&gt; will enforce signature verification. Unsigned images will be rejected.&lt;/p&gt;
&lt;p&gt;For permanent enforcement, add this to &lt;code&gt;/etc/environment&lt;/code&gt; or your shell profile:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;echo 'DOCKER_CONTENT_TRUST=1' | sudo tee -a /etc/environment
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id="signing-your-own-images"&gt;Signing Your Own Images&lt;/h3&gt;
&lt;p&gt;Generate a signing key:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker trust key generate &amp;lt;YOUR_NAME&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Add yourself as a signer for a repository:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker trust signer add --key &amp;lt;YOUR_NAME&amp;gt;.pub &amp;lt;YOUR_NAME&amp;gt; &amp;lt;YOUR_REGISTRY&amp;gt;/&amp;lt;YOUR_REPO&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Sign and push:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker trust sign &amp;lt;YOUR_REGISTRY&amp;gt;/&amp;lt;YOUR_REPO&amp;gt;:latest
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id="inspecting-trust-data"&gt;Inspecting Trust Data&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;docker trust inspect --pretty &amp;lt;YOUR_REGISTRY&amp;gt;/&amp;lt;YOUR_REPO&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This shows who signed the image and when. In a team environment, require multiple signers before an image can be deployed — this prevents a single compromised CI/CD pipeline from pushing malicious images.&lt;/p&gt;
&lt;blockquote&gt;
&lt;strong&gt;Note:&lt;/strong&gt; Docker Content Trust does not verify the &lt;em&gt;contents&lt;/em&gt; of the image are vulnerability-free. It only verifies that the image has not been tampered with since signing. You still need vulnerability scanning, which is covered next.&lt;/blockquote&gt;
&lt;h2 id="vulnerability-scanning-with-trivy"&gt;Vulnerability Scanning with Trivy&lt;/h2&gt;
&lt;p&gt;Trivy is an open-source scanner from Aqua Security that finds &lt;strong&gt;CVEs in OS packages, language-specific dependencies, and misconfigurations&lt;/strong&gt; inside container images. It is fast, accurate, and integrates into CI/CD pipelines with zero configuration.&lt;/p&gt;
&lt;h3 id="installation"&gt;Installation&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;sudo apt install -y wget apt-transport-https gnupg lsb-release
wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | gpg --dearmor | sudo tee /usr/share/keyrings/trivy.gpg &amp;gt; /dev/null
echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb $(lsb_release -sc) main" | sudo tee /etc/apt/sources.list.d/trivy.list
sudo apt update &amp;amp;&amp;amp; sudo apt install -y trivy
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id="scanning-an-image"&gt;Scanning an Image&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;trivy image nginx:latest
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Sample output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;nginx:latest (debian 12.4)
Total: 85 (UNKNOWN: 0, LOW: 52, MEDIUM: 27, HIGH: 5, CRITICAL: 1)

┌──────────────────┬──────────────────┬──────────┬───────────────────┐
│     Library      │  Vulnerability   │ Severity │  Fixed Version    │
├──────────────────┼──────────────────┼──────────┼───────────────────┤
│ libssl3          │ CVE-2024-XXXXX   │ CRITICAL │ 3.0.13-1~deb12u2  │
│ curl             │ CVE-2024-XXXXX   │ HIGH     │ 7.88.1-10+deb12u6 │
└──────────────────┴──────────────────┴──────────┴───────────────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id="failing-cicd-on-critical-vulnerabilities"&gt;Failing CI/CD on Critical Vulnerabilities&lt;/h3&gt;
&lt;p&gt;Add Trivy to your build pipeline with a severity threshold:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;trivy image --exit-code 1 --severity CRITICAL,HIGH your-image:latest
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Exit code 1 means vulnerabilities were found at the specified severity. Your CI pipeline treats this as a build failure.&lt;/p&gt;
&lt;h3 id="scanning-docker-compose-projects"&gt;Scanning Docker Compose Projects&lt;/h3&gt;
&lt;p&gt;Scan every image in a Compose file:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;for image in $(docker compose config --images); do
  echo "=== Scanning $image ==="
  trivy image --severity HIGH,CRITICAL "$image"
done
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id="scanning-filesystem-and-config-files"&gt;Scanning Filesystem and Config Files&lt;/h3&gt;
&lt;p&gt;Trivy also detects misconfigurations in Dockerfiles and Compose files:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;trivy config .
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This catches issues like running as root, using &lt;code&gt;latest&lt;/code&gt; tags, and missing health checks — problems I covered in the &lt;a href="https://blog.byte-guard.net/docker-security-best-practices/" rel="noopener noreferrer"&gt;fundamentals post&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id="runtime-security-with-falco"&gt;Runtime Security with Falco&lt;/h2&gt;
&lt;p&gt;Everything so far operates at build time or deploy time. &lt;strong&gt;Falco&lt;/strong&gt; is the runtime layer — it watches what containers actually do and alerts on suspicious behavior. Think of it as an intrusion detection system for containers.&lt;/p&gt;
&lt;p&gt;Falco monitors Linux system calls in real time using eBPF and triggers alerts when behavior matches predefined rules. Examples: a shell spawning inside a container, a process reading &lt;code&gt;/etc/shadow&lt;/code&gt;, an outbound connection to a known malicious IP.&lt;/p&gt;
&lt;h3 id="installation-via-docker"&gt;Installation via Docker&lt;/h3&gt;
&lt;p&gt;Ironically, the best way to run Falco is in a container — with the required privileges:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;services:
  falco:
    image: falcosecurity/falco:latest
    container_name: falco
    privileged: true
    volumes:
      - /var/run/docker.sock:/host/var/run/docker.sock:ro
      - /proc:/host/proc:ro
      - /dev:/host/dev
      - /etc:/host/etc:ro
      - ./falco-rules:/etc/falco/rules.d:ro
    environment:
      - HOST_ROOT=/host
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;strong&gt;Note:&lt;/strong&gt; Yes, Falco requires privileged mode and access to the Docker socket. This is the trade-off — to monitor all containers, the monitoring tool itself needs elevated access. Isolate Falco on a dedicated network and restrict access to its container.&lt;/blockquote&gt;
&lt;h3 id="custom-falco-rules"&gt;Custom Falco Rules&lt;/h3&gt;
&lt;p&gt;Create a rules file to detect common attack patterns:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# falco-rules/custom-rules.yaml
- rule: Shell Spawned in Container
  desc: Detect shell process started in a container
  condition: &amp;gt;
    spawned_process and container and
    proc.name in (bash, sh, zsh, dash, ash) and
    not proc.pname in (cron, supervisord, entrypoint.sh)
  output: &amp;gt;
    Shell spawned in container
    (user=%user.name container=%container.name shell=%proc.name
     parent=%proc.pname cmdline=%proc.cmdline)
  priority: WARNING

- rule: Sensitive File Read in Container
  desc: Detect reads of sensitive files
  condition: &amp;gt;
    open_read and container and
    fd.name in (/etc/shadow, /etc/sudoers, /proc/1/environ)
  output: &amp;gt;
    Sensitive file read in container
    (user=%user.name file=%fd.name container=%container.name)
  priority: ERROR

- rule: Outbound Connection to Non-Standard Port
  desc: Detect containers connecting to unusual ports
  condition: &amp;gt;
    outbound and container and
    not fd.sport in (80, 443, 53, 5432, 3306, 6379, 27017)
  output: &amp;gt;
    Unexpected outbound connection
    (container=%container.name connection=%fd.name port=%fd.sport)
  priority: WARNING
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id="monitoring-falco-alerts"&gt;Monitoring Falco Alerts&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;docker logs -f falco
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In production, pipe Falco alerts to a SIEM or alerting system. Falco supports output to Slack, PagerDuty, and generic webhooks out of the box.&lt;/p&gt;
&lt;h2 id="docker-socket-protection"&gt;Docker Socket Protection&lt;/h2&gt;
&lt;p&gt;The Docker socket (&lt;code&gt;/var/run/docker.sock&lt;/code&gt;) is the single most dangerous file on a Docker host. Any process with access to the socket has &lt;strong&gt;full control over the Docker daemon&lt;/strong&gt; — it can create privileged containers, mount the host filesystem, and effectively gain root on the host.&lt;/p&gt;
&lt;h3 id="never-mount-the-socket-unless-required"&gt;Never Mount the Socket Unless Required&lt;/h3&gt;
&lt;p&gt;Review your Docker Compose files and remove socket mounts from any container that does not strictly need them:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;grep -r "docker.sock" /opt/*/docker-compose.yml
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id="use-a-socket-proxy"&gt;Use a Socket Proxy&lt;/h3&gt;
&lt;p&gt;If a container needs limited Docker API access (monitoring, auto-updates), use a socket proxy like Tecnativa's docker-socket-proxy:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;services:
  docker-proxy:
    image: tecnativa/docker-socket-proxy
    container_name: docker-socket-proxy
    environment:
      - CONTAINERS=1      # Allow listing containers
      - IMAGES=0          # Deny image operations
      - NETWORKS=0        # Deny network operations
      - VOLUMES=0         # Deny volume operations
      - POST=0            # Deny all POST requests (read-only)
      - BUILD=0
      - COMMIT=0
      - EXEC=0            # Deny exec into containers
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
    networks:
      - docker-proxy

  watchtower:
    image: containrrr/watchtower
    environment:
      - DOCKER_HOST=tcp://docker-proxy:2375
    depends_on:
      - docker-proxy
    networks:
      - docker-proxy

networks:
  docker-proxy:
    driver: bridge
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This gives Watchtower the ability to list containers but prevents it from executing commands, building images, or managing networks. If Watchtower is compromised, the blast radius is minimal.&lt;/p&gt;
&lt;h2 id="multi-stage-builds-for-minimal-attack-surface"&gt;Multi-Stage Builds for Minimal Attack Surface&lt;/h2&gt;
&lt;p&gt;The fewer files in your final image, the fewer things an attacker can exploit. &lt;strong&gt;Multi-stage builds&lt;/strong&gt; let you compile your application in a fat builder image, then copy only the binary into a minimal runtime image.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Stage 1: Build
FROM golang:1.22-bookworm AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/server .

# Stage 2: Runtime
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /app/server /server
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/server"]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The final image contains only the compiled binary and CA certificates. No shell, no package manager, no &lt;code&gt;curl&lt;/code&gt;, no &lt;code&gt;wget&lt;/code&gt;. An attacker who gains code execution has almost nothing to work with.&lt;/p&gt;
&lt;p&gt;Compare the image sizes:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker images | grep myapp
# myapp-full     latest   850MB
# myapp-minimal  latest   12MB
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A smaller image is faster to pull, cheaper to store, and has a dramatically smaller CVE surface.&lt;/p&gt;
&lt;h2 id="security-headers-for-containerized-web-apps"&gt;Security Headers for Containerized Web Apps&lt;/h2&gt;
&lt;p&gt;If your containers serve web traffic, verify they return proper security headers. I covered this in detail in my &lt;a href="https://blog.byte-guard.net/check-security-headers/" rel="noopener noreferrer"&gt;security headers guide&lt;/a&gt;, but here is the container-specific angle.&lt;/p&gt;
&lt;p&gt;Add headers in your reverse proxy configuration rather than in each application. In Nginx:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'" always;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id="security-considerations"&gt;Security Considerations&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Docker container security&lt;/strong&gt; is not a single tool or configuration — it is a layered approach:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Build time:&lt;/strong&gt; Multi-stage builds, Trivy scanning, minimal base images&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deploy time:&lt;/strong&gt; Seccomp profiles, AppArmor policies, Content Trust, dropped capabilities&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Runtime:&lt;/strong&gt; Falco monitoring, socket protection, read-only filesystems, network policies&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The biggest risk I see in self-hosted environments is complacency after initial setup. Security is not a checklist you complete once. Schedule weekly Trivy scans, review Falco alerts daily, and update base images monthly at minimum.&lt;/p&gt;
&lt;p&gt;When running on a VPS, these container-level protections layer on top of host-level hardening. If you have not secured your host yet, start with my &lt;a href="https://blog.byte-guard.net/harden-linux-vps-10-minutes/" rel="noopener noreferrer"&gt;Linux VPS hardening guide&lt;/a&gt; — container security means nothing if the host is compromised.&lt;/p&gt;
&lt;h2 id="troubleshooting"&gt;Troubleshooting&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Problem:&lt;/strong&gt; Container crashes with &lt;code&gt;EPERM&lt;/code&gt; after applying a seccomp profile. &lt;strong&gt;Cause:&lt;/strong&gt; Your custom seccomp profile is missing a required syscall. &lt;strong&gt;Fix:&lt;/strong&gt; Run the container temporarily with &lt;code&gt;--security-opt seccomp=unconfined&lt;/code&gt; and strace to identify the blocked syscall. Add it to your profile and retest.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Problem:&lt;/strong&gt; Trivy scan shows vulnerabilities in the base image that have no fix available. &lt;strong&gt;Cause:&lt;/strong&gt; The upstream distribution has not released a patch yet. &lt;strong&gt;Fix:&lt;/strong&gt; Switch to a different base image (e.g., Alpine instead of Debian, or distroless). If the CVE does not apply to your use case (e.g., a library vulnerability in a package your app does not use), document the exception.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Problem:&lt;/strong&gt; Falco generates excessive alerts for normal container behavior. &lt;strong&gt;Cause:&lt;/strong&gt; Default Falco rules are broad and trigger on legitimate operations. &lt;strong&gt;Fix:&lt;/strong&gt; Create exception lists in your custom rules using the &lt;code&gt;not&lt;/code&gt; operator. Start Falco in "tap" mode to observe before enforcing.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Problem:&lt;/strong&gt; Docker Content Trust blocks pulls of images you know are safe. &lt;strong&gt;Cause:&lt;/strong&gt; The image publisher has not signed their images. &lt;strong&gt;Fix:&lt;/strong&gt; Temporarily disable DCT for that pull with &lt;code&gt;DOCKER_CONTENT_TRUST=0 docker pull image:tag&lt;/code&gt;, then re-enable it. Long-term, prefer signed images or build your own from a trusted Dockerfile.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Problem:&lt;/strong&gt; Socket proxy blocks a legitimate API call from a monitoring tool. &lt;strong&gt;Cause:&lt;/strong&gt; The proxy's environment variables are too restrictive for the tool's requirements. &lt;strong&gt;Fix:&lt;/strong&gt; Review the tool's documentation for required Docker API endpoints and enable only those in the proxy configuration. Never set &lt;code&gt;POST=1&lt;/code&gt; unless the tool genuinely needs write access.&lt;/p&gt;
&lt;h2 id="conclusion"&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;The fundamentals from my &lt;a href="https://blog.byte-guard.net/docker-security-best-practices/" rel="noopener noreferrer"&gt;Docker security best practices&lt;/a&gt; post give you a solid foundation. This guide adds the advanced layers: &lt;strong&gt;seccomp&lt;/strong&gt; restricts system calls, &lt;strong&gt;AppArmor&lt;/strong&gt; restricts resource access, &lt;strong&gt;Trivy&lt;/strong&gt; catches vulnerabilities before deployment, &lt;strong&gt;Falco&lt;/strong&gt; detects threats at runtime, and &lt;strong&gt;Content Trust&lt;/strong&gt; ensures image integrity.&lt;/p&gt;
&lt;p&gt;No single tool makes containers secure. The strength is in layering — each control catches what the others miss. Start by adding Trivy to your build pipeline (it takes five minutes) and work outward from there.&lt;/p&gt;
&lt;p&gt;If you are running these containers on a VPS, I use Hetzner for all my projects. The CPX22 handles a full Docker stack with monitoring comfortably, and you can follow my &lt;a href="https://blog.byte-guard.net/building-byteguard-from-scratch-hetzner-vps/" rel="noopener noreferrer"&gt;VPS setup guide&lt;/a&gt; to get started.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>containersecurity</category>
      <category>devsecops</category>
      <category>selfhosting</category>
    </item>
    <item>
      <title>OWASP Top 10 Explained with Real Examples</title>
      <dc:creator>byteguard</dc:creator>
      <pubDate>Tue, 23 Jun 2026 18:18:25 +0000</pubDate>
      <link>https://dev.to/byte-guard/owasp-top-10-explained-with-real-examples-13kl</link>
      <guid>https://dev.to/byte-guard/owasp-top-10-explained-with-real-examples-13kl</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://blog.byte-guard.net/owasp-top-10-explained/" rel="noopener noreferrer"&gt;byte-guard.net&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If you build anything for the web -- APIs, SPAs, server-rendered apps, microservices -- the OWASP Top 10 is the baseline you need to know. It is the industry-standard list of the most critical web application security risks, maintained by the Open Worldwide Application Security Project and updated every few years based on real-world data.&lt;/p&gt;
&lt;p&gt;This guide is part of our &lt;a href="https://blog.byte-guard.net/best-free-security-tools-2026/" rel="noopener noreferrer"&gt;&lt;strong&gt;Security Tools Series&lt;/strong&gt;&lt;/a&gt; — hands-on guides for the tools every security-minded developer needs.&lt;/p&gt;
&lt;p&gt;The 2021 edition reshuffled the list significantly. &lt;strong&gt;Broken Access Control&lt;/strong&gt; jumped to the number one spot. Three new categories appeared. And some old favorites got merged or renamed.&lt;/p&gt;
&lt;p&gt;I have seen too many developers treat the OWASP Top 10 as an abstract checklist they skim once during a compliance audit. That misses the point. Every item on this list represents thousands of actual breaches. In this post, I will walk through each of the &lt;strong&gt;OWASP Top 10 explained&lt;/strong&gt; with real code showing the vulnerability and the fix -- in Python and JavaScript, because those are what most web developers work with daily.&lt;/p&gt;
&lt;h2 id="prerequisites"&gt;Prerequisites&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Basic understanding of HTTP requests and responses&lt;/li&gt;
&lt;li&gt;Familiarity with Python (Flask/FastAPI) or JavaScript (Node.js/Express)&lt;/li&gt;
&lt;li&gt;A general understanding of how web applications work (client-server model)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you are running your own servers, my &lt;a href="https://blog.byte-guard.net/ssh-hardening-guide/" rel="noopener noreferrer"&gt;SSH hardening guide&lt;/a&gt; and &lt;a href="https://blog.byte-guard.net/harden-linux-vps-10-minutes/" rel="noopener noreferrer"&gt;VPS hardening guide&lt;/a&gt; cover the infrastructure side of security.&lt;/p&gt;
&lt;h2 id="a01-broken-access-control"&gt;A01: Broken Access Control&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;What it is:&lt;/strong&gt; Users can act outside their intended permissions. This includes accessing other users' data, modifying records they should not touch, or escalating privileges.&lt;/p&gt;
&lt;p&gt;Broken Access Control moved from #5 to #1 in 2021. It is the most common vulnerability found in real applications.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Vulnerable code (Python/FastAPI):&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@app.get("/api/users/{user_id}/profile")
async def get_profile(user_id: int):
    # No check: any authenticated user can view any profile
    user = await db.get_user(user_id)
    return user.to_dict()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The problem: the endpoint accepts any &lt;code&gt;user_id&lt;/code&gt; and returns the data. There is no check that the requesting user is authorized to see that profile. This is an &lt;strong&gt;Insecure Direct Object Reference (IDOR)&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Fixed code:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@app.get("/api/users/{user_id}/profile")
async def get_profile(user_id: int, current_user: User = Depends(get_current_user)):
    if current_user.id != user_id and not current_user.is_admin:
        raise HTTPException(status_code=403, detail="Forbidden")
    user = await db.get_user(user_id)
    return user.to_dict()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Key defenses:&lt;/strong&gt; Deny by default. Enforce ownership checks on every data access. Disable directory listing. Log access control failures and alert on repeated attempts.&lt;/p&gt;
&lt;h2 id="a02-cryptographic-failures"&gt;A02: Cryptographic Failures&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;What it is:&lt;/strong&gt; Sensitive data exposed due to weak or missing encryption. This covers data in transit (no TLS), data at rest (plaintext passwords in the database), and weak algorithms (MD5, SHA1 for passwords).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Vulnerable code (JavaScript/Node.js):&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const crypto = require('crypto');

// Storing password with MD5 -- no salt, fast hash, trivially crackable
function hashPassword(password) {
  return crypto.createHash('md5').update(password).digest('hex');
}

// Connecting to database without TLS
const connection = mysql.createConnection({
  host: 'db.example.com',
  user: 'app',
  password: 'secret123',  // Hardcoded credential
  database: 'production'
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Fixed code:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const bcrypt = require('bcrypt');

// bcrypt: slow by design, includes salt automatically
async function hashPassword(password) {
  const saltRounds = 12;
  return await bcrypt.hash(password, saltRounds);
}

async function verifyPassword(password, hash) {
  return await bcrypt.compare(password, hash);
}

// TLS-enabled connection, credentials from environment
const connection = mysql.createConnection({
  host: process.env.DB_HOST,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  ssl: { rejectUnauthorized: true }
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Key defenses:&lt;/strong&gt; Use bcrypt, scrypt, or Argon2 for passwords. Enforce TLS everywhere. Never hardcode secrets -- use environment variables. Classify data and apply encryption based on sensitivity.&lt;/p&gt;
&lt;h2 id="a03-injection"&gt;A03: Injection&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;What it is:&lt;/strong&gt; Untrusted data sent to an interpreter as part of a command or query. SQL injection is the classic example, but this also covers NoSQL injection, OS command injection, and LDAP injection.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Vulnerable code (Python):&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@app.get("/api/search")
async def search_users(username: str):
    # String concatenation -- classic SQL injection
    query = f"SELECT * FROM users WHERE username = '{username}'"
    results = await db.execute(query)
    return results
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;An attacker sends &lt;code&gt;username=' OR '1'='1&lt;/code&gt; and gets every user in the database. Or worse: &lt;code&gt;'; DROP TABLE users; --&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Fixed code:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@app.get("/api/search")
async def search_users(username: str):
    # Parameterized query -- the database handles escaping
    query = "SELECT * FROM users WHERE username = :username"
    results = await db.execute(query, {"username": username})
    return results
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Key defenses:&lt;/strong&gt; Use parameterized queries or an ORM for all database access. Validate and sanitize all input. Use allowlists for expected input patterns. If you must run OS commands, never pass user input to them directly.&lt;/p&gt;
&lt;h2 id="a04-insecure-design"&gt;A04: Insecure Design&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;What it is:&lt;/strong&gt; Flaws in the architecture and design of the application, not in the implementation. This category (new in 2021) covers missing security controls that should have been planned from the start.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Example scenario:&lt;/strong&gt; An e-commerce site lets users reset their password by answering "What is your mother's maiden name?" This is an insecure design -- the security question can be researched on social media. No amount of secure coding fixes a broken design.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Another example -- no rate limiting on a sensitive endpoint:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Insecure design: no rate limit on login attempts
@app.post("/api/login")
async def login(credentials: LoginRequest):
    user = await db.get_user_by_email(credentials.email)
    if user and verify_password(credentials.password, user.password_hash):
        return {"token": create_jwt(user)}
    raise HTTPException(status_code=401, detail="Invalid credentials")
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Improved design:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from slowapi import Limiter

limiter = Limiter(key_func=get_remote_address)

@app.post("/api/login")
@limiter.limit("5/minute")  # Max 5 attempts per minute per IP
async def login(request: Request, credentials: LoginRequest):
    user = await db.get_user_by_email(credentials.email)
    if user and verify_password(credentials.password, user.password_hash):
        await db.reset_failed_attempts(user.id)
        return {"token": create_jwt(user)}
    if user:
        await db.increment_failed_attempts(user.id)
        if await db.get_failed_attempts(user.id) &amp;gt; 10:
            await db.lock_account(user.id)
    raise HTTPException(status_code=401, detail="Invalid credentials")
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Key defenses:&lt;/strong&gt; Threat model your application before writing code. Use abuse-case stories alongside user stories. Implement rate limiting, account lockout, and monitoring from the start.&lt;/p&gt;
&lt;h2 id="a05-security-misconfiguration"&gt;A05: Security Misconfiguration&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;What it is:&lt;/strong&gt; Insecure default configurations, incomplete configurations, open cloud storage, misconfigured HTTP headers, verbose error messages that leak sensitive information.&lt;/p&gt;
&lt;p&gt;This is extremely common in self-hosted setups. I wrote about this in the context of containers in my &lt;a href="https://blog.byte-guard.net/docker-security-best-practices/" rel="noopener noreferrer"&gt;Docker security guide&lt;/a&gt; -- default Docker configurations are not production-ready.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Vulnerable example (Express.js):&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const express = require('express');
const app = express();

// Stack traces exposed to users
app.use((err, req, res, next) =&amp;gt; {
  res.status(500).json({
    error: err.message,
    stack: err.stack,       // Leaks internal paths and code structure
    query: req.query        // Echoes back user input
  });
});

// Default headers not removed
// X-Powered-By: Express  -- tells attackers your framework
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Fixed example:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const express = require('express');
const helmet = require('helmet');
const app = express();

// helmet sets security headers and removes X-Powered-By
app.use(helmet());

// Generic error handler for production
app.use((err, req, res, next) =&amp;gt; {
  console.error(`[${new Date().toISOString()}] ${err.stack}`);  // Log internally
  res.status(500).json({
    error: 'Internal server error'  // Generic message to client
  });
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Key defenses:&lt;/strong&gt; Remove or change all default credentials. Disable directory listing, debug modes, and stack traces in production. Automate configuration hardening. Review cloud storage permissions.&lt;/p&gt;
&lt;h2 id="a06-vulnerable-and-outdated-components"&gt;A06: Vulnerable and Outdated Components&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;What it is:&lt;/strong&gt; Using libraries, frameworks, or other software components with known vulnerabilities. This includes operating system packages, application dependencies, and container base images.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Detecting vulnerable dependencies (Python):&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Install safety (Python vulnerability scanner)
pip install safety

# Scan your requirements
safety check -r requirements.txt

# Or scan the current environment
safety check
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;For JavaScript projects:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Built-in npm audit
npm audit

# For a detailed report
npm audit --json | jq '.vulnerabilities | keys'

# Fix automatically where possible
npm audit fix
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;For Docker images:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Scan a Docker image with Trivy
trivy image your-app:latest

# Scan with severity filter
trivy image --severity HIGH,CRITICAL your-app:latest
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Key defenses:&lt;/strong&gt; Run dependency scanners in your CI/CD pipeline. Subscribe to security advisories for your stack. Remove unused dependencies. Use specific version pins, not ranges, for critical libraries.&lt;/p&gt;
&lt;h2 id="a07-identification-and-authentication-failures"&gt;A07: Identification and Authentication Failures&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;What it is:&lt;/strong&gt; Weaknesses in authentication mechanisms -- permitting weak passwords, credential stuffing, missing multi-factor authentication, or improper session management.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Vulnerable session handling (Python/Flask):&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from flask import Flask, session
import os

app = Flask(__name__)
app.secret_key = "super-secret-key"  # Hardcoded, predictable

@app.route("/login", methods=["POST"])
def login():
    user = authenticate(request.form["email"], request.form["password"])
    if user:
        session["user_id"] = user.id
        # Session never expires
        # Session ID not rotated after login
        # No check for brute force
        return redirect("/dashboard")
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Fixed implementation:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from flask import Flask, session
from datetime import timedelta
import os
import secrets

app = Flask(__name__)
app.secret_key = os.environ["FLASK_SECRET_KEY"]  # From environment
app.config.update(
    SESSION_COOKIE_HTTPONLY=True,
    SESSION_COOKIE_SECURE=True,       # HTTPS only
    SESSION_COOKIE_SAMESITE="Lax",
    PERMANENT_SESSION_LIFETIME=timedelta(hours=1),
)

@app.route("/login", methods=["POST"])
def login():
    user = authenticate(request.form["email"], request.form["password"])
    if user:
        session.clear()                  # Invalidate old session
        session.regenerate()             # New session ID (prevents fixation)
        session["user_id"] = user.id
        session.permanent = True         # Enables expiry
        return redirect("/dashboard")
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Key defenses:&lt;/strong&gt; Implement multi-factor authentication. Enforce password complexity and check against breached password lists (haveibeenpwned API). Rotate session IDs after login. Set session timeouts.&lt;/p&gt;
&lt;h2 id="a08-software-and-data-integrity-failures"&gt;A08: Software and Data Integrity Failures&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;What it is:&lt;/strong&gt; Code and infrastructure that does not verify integrity. This includes insecure CI/CD pipelines, auto-update mechanisms without signature verification, and deserialization of untrusted data.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Vulnerable deserialization (Python):&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import pickle
import base64

@app.post("/api/import")
async def import_data(data: str):
    # NEVER unpickle untrusted data -- arbitrary code execution
    obj = pickle.loads(base64.b64decode(data))
    return {"imported": len(obj)}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Pickle deserialization executes arbitrary Python code. An attacker can craft a payload that runs system commands on your server.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Fixed approach:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import json

@app.post("/api/import")
async def import_data(data: str):
    try:
        obj = json.loads(base64.b64decode(data))
        # Validate structure with Pydantic or similar
        validated = ImportSchema(**obj)
        return {"imported": len(validated.items)}
    except (json.JSONDecodeError, ValidationError) as e:
        raise HTTPException(status_code=400, detail="Invalid data format")
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Key defenses:&lt;/strong&gt; Use JSON instead of binary serialization formats. Verify digital signatures on software updates. Secure your CI/CD pipeline -- it is a high-value target. Use Subresource Integrity (SRI) for external scripts.&lt;/p&gt;
&lt;h2 id="a09-security-logging-and-monitoring-failures"&gt;A09: Security Logging and Monitoring Failures&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;What it is:&lt;/strong&gt; Insufficient logging, detection, monitoring, and active response. Without proper logging, breaches go undetected for months. The median time to detect a breach is still over 200 days.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Minimal (bad) logging:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@app.post("/api/login")
async def login(credentials: LoginRequest):
    user = await authenticate(credentials.email, credentials.password)
    if user:
        return {"token": create_jwt(user)}
    # No log of the failed attempt
    return {"error": "Invalid credentials"}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Proper security logging:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import logging
from datetime import datetime

security_logger = logging.getLogger("security")
security_logger.setLevel(logging.INFO)
handler = logging.FileHandler("/var/log/app/security.log")
handler.setFormatter(logging.Formatter(
    '%(asctime)s %(levelname)s %(message)s'
))
security_logger.addHandler(handler)

@app.post("/api/login")
async def login(request: Request, credentials: LoginRequest):
    client_ip = request.client.host
    user = await authenticate(credentials.email, credentials.password)

    if user:
        security_logger.info(
            f"LOGIN_SUCCESS email={credentials.email} ip={client_ip}"
        )
        return {"token": create_jwt(user)}

    security_logger.warning(
        f"LOGIN_FAILURE email={credentials.email} ip={client_ip}"
    )
    # Alert on repeated failures from same IP
    await check_brute_force(client_ip)
    raise HTTPException(status_code=401, detail="Invalid credentials")
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What to log:&lt;/strong&gt; All authentication events (success and failure), authorization failures, input validation failures, and any server-side errors. &lt;strong&gt;What not to log:&lt;/strong&gt; Passwords, credit card numbers, session tokens, or personal data.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Key defenses:&lt;/strong&gt; Log security events in a structured format. Ship logs to a central location. Set up alerts for anomalous patterns. Test that your monitoring actually catches attacks. I run &lt;a href="https://blog.byte-guard.net/uptime-kuma-setup-guide/" rel="noopener noreferrer"&gt;Uptime Kuma&lt;/a&gt; for availability monitoring, but application-level logging requires dedicated tools like the ELK stack, Loki, or Graylog.&lt;/p&gt;
&lt;h2 id="a10-server-side-request-forgery-ssrf"&gt;A10: Server-Side Request Forgery (SSRF)&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;What it is:&lt;/strong&gt; The application fetches a remote resource based on user-supplied input without validating the destination. Attackers can make the server send requests to internal services, cloud metadata endpoints, or other systems behind the firewall.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Vulnerable code (JavaScript/Node.js):&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const axios = require('axios');

app.post('/api/fetch-url', async (req, res) =&amp;gt; {
  const { url } = req.body;
  // No validation -- attacker can target internal services
  const response = await axios.get(url);
  res.json({ data: response.data });
});

// Attacker sends: {"url": "http://169.254.169.254/latest/meta-data/"}
// This fetches AWS instance metadata including IAM credentials
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Fixed code:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const axios = require('axios');
const { URL } = require('url');
const dns = require('dns').promises;
const ipaddr = require('ipaddr.js');

const ALLOWED_PROTOCOLS = ['http:', 'https:'];
const BLOCKED_HOSTS = ['metadata.google.internal', '169.254.169.254'];

async function isPrivateIP(hostname) {
  const addresses = await dns.resolve4(hostname);
  return addresses.some(addr =&amp;gt; {
    const parsed = ipaddr.parse(addr);
    return parsed.range() !== 'unicast';  // Blocks private, loopback, link-local
  });
}

app.post('/api/fetch-url', async (req, res) =&amp;gt; {
  const { url } = req.body;

  try {
    const parsed = new URL(url);

    if (!ALLOWED_PROTOCOLS.includes(parsed.protocol)) {
      return res.status(400).json({ error: 'Protocol not allowed' });
    }
    if (BLOCKED_HOSTS.includes(parsed.hostname)) {
      return res.status(400).json({ error: 'Host not allowed' });
    }
    if (await isPrivateIP(parsed.hostname)) {
      return res.status(400).json({ error: 'Internal addresses not allowed' });
    }

    const response = await axios.get(url, { timeout: 5000, maxRedirects: 0 });
    res.json({ data: response.data });
  } catch (err) {
    res.status(400).json({ error: 'Invalid URL or fetch failed' });
  }
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Key defenses:&lt;/strong&gt; Validate and sanitize all user-supplied URLs. Block requests to private IP ranges and cloud metadata endpoints. Use allowlists when the set of target URLs is known. Disable HTTP redirects or validate each redirect destination.&lt;/p&gt;
&lt;h2 id="security-considerations"&gt;Security Considerations&lt;/h2&gt;
&lt;p&gt;Understanding the OWASP Top 10 is a starting point, not a finish line. These are the &lt;strong&gt;most common&lt;/strong&gt; risks, not the &lt;strong&gt;only&lt;/strong&gt; risks. Application security is layered:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Infrastructure layer:&lt;/strong&gt; Harden your server (&lt;a href="https://blog.byte-guard.net/harden-linux-vps-10-minutes/" rel="noopener noreferrer"&gt;VPS hardening guide&lt;/a&gt;), lock down SSH (&lt;a href="https://blog.byte-guard.net/ssh-hardening-guide/" rel="noopener noreferrer"&gt;SSH hardening guide&lt;/a&gt;), containerize applications (&lt;a href="https://blog.byte-guard.net/docker-security-best-practices/" rel="noopener noreferrer"&gt;Docker security guide&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Application layer:&lt;/strong&gt; Follow the OWASP Top 10, implement defense in depth, validate all input&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Process layer:&lt;/strong&gt; Code reviews, dependency scanning, automated security testing in CI/CD&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;No single control stops every attack. The goal is to make exploitation expensive, detection fast, and recovery possible.&lt;/p&gt;
&lt;h2 id="troubleshooting"&gt;Troubleshooting&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Problem:&lt;/strong&gt; Your parameterized query still seems vulnerable to injection. &lt;strong&gt;Cause:&lt;/strong&gt; You are using string formatting to build the query and then passing it to the parameterized query function. The parameterization only works if the query string itself uses placeholders. &lt;strong&gt;Fix:&lt;/strong&gt; Verify that you are using &lt;code&gt;:param&lt;/code&gt; (SQLAlchemy), &lt;code&gt;?&lt;/code&gt; (sqlite3), or &lt;code&gt;$1&lt;/code&gt; (PostgreSQL) in the query string itself, not f-strings or &lt;code&gt;.format()&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Problem:&lt;/strong&gt; bcrypt is extremely slow in your application. &lt;strong&gt;Cause:&lt;/strong&gt; The salt rounds are set too high for your server's CPU. &lt;strong&gt;Fix:&lt;/strong&gt; 10-12 rounds is the standard recommendation. Each increment doubles the computation time. Benchmark on your hardware: &lt;code&gt;time python -c "import bcrypt; bcrypt.hashpw(b'test', bcrypt.gensalt(rounds=12))"&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Problem:&lt;/strong&gt; CSP violations flooding your logs after deploying Content-Security-Policy-Report-Only. &lt;strong&gt;Cause:&lt;/strong&gt; Browser extensions inject scripts that violate your CSP. These are not real attacks. &lt;strong&gt;Fix:&lt;/strong&gt; Filter out known extension patterns (e.g., &lt;code&gt;chrome-extension://&lt;/code&gt;) from your CSP reports. Focus on violations from your application's actual resources.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Problem:&lt;/strong&gt; Rate limiting blocks legitimate users behind a shared IP (corporate NAT, VPN). &lt;strong&gt;Cause:&lt;/strong&gt; IP-based rate limiting cannot distinguish between users sharing an IP. &lt;strong&gt;Fix:&lt;/strong&gt; Combine IP-based limits with account-based limits. Use &lt;code&gt;X-Forwarded-For&lt;/code&gt; carefully (it can be spoofed). Consider CAPTCHA challenges instead of hard blocks for borderline cases.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Problem:&lt;/strong&gt; npm audit reports hundreds of vulnerabilities, mostly in dev dependencies. &lt;strong&gt;Cause:&lt;/strong&gt; Many audit findings are in transitive dependencies used only during development, not in production code. &lt;strong&gt;Fix:&lt;/strong&gt; Use &lt;code&gt;npm audit --production&lt;/code&gt; to filter to production dependencies only. Prioritize direct dependencies with HIGH or CRITICAL severity.&lt;/p&gt;
&lt;h2 id="conclusion"&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;The &lt;strong&gt;OWASP Top 10&lt;/strong&gt; is not a compliance checkbox -- it is a map of how real applications get breached. Every item on the list corresponds to thousands of real incidents, and the code examples above represent patterns I have seen in production codebases.&lt;/p&gt;
&lt;p&gt;The most impactful changes you can make right now: use parameterized queries everywhere, enforce access control checks at every endpoint, hash passwords with bcrypt or Argon2, and log all authentication events.&lt;/p&gt;
&lt;p&gt;For infrastructure-level hardening to complement your application security, check out my &lt;a href="https://blog.byte-guard.net/harden-linux-vps-10-minutes/" rel="noopener noreferrer"&gt;VPS hardening 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;Building a secure application from scratch? You will need a solid server foundation. I run all my projects on Hetzner -- reliable, affordable, and great performance. &lt;a href="https://www.hetzner.com/cloud" rel="noopener noreferrer"&gt;Check out their cloud plans&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>security</category>
      <category>owasp</category>
      <category>webdev</category>
      <category>applicationsecurity</category>
    </item>
    <item>
      <title>How to Monitor Your Server with Uptime Kuma</title>
      <dc:creator>byteguard</dc:creator>
      <pubDate>Tue, 23 Jun 2026 18:17:21 +0000</pubDate>
      <link>https://dev.to/byte-guard/how-to-monitor-your-server-with-uptime-kuma-5438</link>
      <guid>https://dev.to/byte-guard/how-to-monitor-your-server-with-uptime-kuma-5438</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://blog.byte-guard.net/uptime-kuma-setup-guide/" rel="noopener noreferrer"&gt;byte-guard.net&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Deploy Uptime Kuma with Docker Compose and monitor your websites, APIs, and Docker containers in minutes. Get instant alerts via Telegram, email, or Slack when anything goes down — and publish a public status page for your users.&lt;/p&gt;

&lt;p&gt;You deploy your blog, your password manager, your VPN — and then what? You assume it's all running fine until someone tells you it's down. Maybe that someone is a reader. Maybe it's you, three hours later, wondering why your site feels slow.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Uptime Kuma&lt;/strong&gt; fixes this. It's a &lt;a href="https://blog.byte-guard.net/self-hosted-alternatives-saas/" rel="noopener noreferrer"&gt;self-hosted&lt;/a&gt; monitoring tool that checks your services on a schedule and alerts you the moment something goes down. I run it at &lt;a href="https://status.byte-guard.net" rel="noopener noreferrer"&gt;status.byte-guard.net&lt;/a&gt; to monitor every service in my stack. This &lt;strong&gt;Uptime Kuma setup guide&lt;/strong&gt; walks you through installation, configuring monitors, setting up notifications, and creating a public status page.&lt;/p&gt;
&lt;p&gt;If you run anything on a server, you need monitoring. Here's how to set it up in under 10 minutes.&lt;/p&gt;
&lt;h2 id="what-do-you-need-to-run-uptime-kuma-with-docker"&gt;What Do You Need to Run Uptime Kuma with Docker?&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;A Linux VPS with Docker and Docker Compose (&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;A reverse proxy for SSL (&lt;a href="https://blog.byte-guard.net/nginx-proxy-manager-vs-traefik-vs-caddy/" rel="noopener noreferrer"&gt;Nginx Proxy Manager&lt;/a&gt;, Caddy, or Traefik)&lt;/li&gt;
&lt;li&gt;At least one service to monitor (your blog, an API, anything)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id="how-do-you-deploy-uptime-kuma-with-docker-compose"&gt;How Do You Deploy Uptime Kuma with Docker Compose?&lt;/h2&gt;
&lt;p&gt;Create a directory:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo mkdir -p /opt/uptime-kuma
cd /opt/uptime-kuma
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Create the compose file:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# docker-compose.yml
services:
  uptime-kuma:
    image: louislam/uptime-kuma:1
    container_name: uptime-kuma
    volumes:
      - kuma_data:/app/data
    ports:
      - "127.0.0.1:3001:3001"
    restart: unless-stopped

volumes:
  kuma_data:
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Start it:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker compose up -d
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The container binds to &lt;code&gt;127.0.0.1:3001&lt;/code&gt; — accessible only from localhost. Your reverse proxy handles the public-facing SSL.&lt;/p&gt;
&lt;h3 id="set-up-the-reverse-proxy"&gt;Set Up the Reverse Proxy&lt;/h3&gt;
&lt;p&gt;In &lt;strong&gt;Nginx Proxy Manager&lt;/strong&gt;, add a proxy host: - Domain: &lt;code&gt;status.yourdomain.com&lt;/code&gt; - Forward hostname: &lt;code&gt;uptime-kuma&lt;/code&gt; (or &lt;code&gt;127.0.0.1&lt;/code&gt;) - Forward port: &lt;code&gt;3001&lt;/code&gt; - SSL: enable, request Let's Encrypt certificate - WebSocket support: &lt;strong&gt;enable&lt;/strong&gt; (required for real-time updates)&lt;/p&gt;
&lt;p&gt;In &lt;strong&gt;Caddy&lt;/strong&gt;, add to your Caddyfile:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;status.yourdomain.com {
    reverse_proxy uptime-kuma:3001
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Open &lt;code&gt;https://status.yourdomain.com&lt;/code&gt; and create your admin account. Do this immediately — the first person to visit creates the admin account.&lt;/p&gt;

&lt;h2 id="how-do-you-add-your-first-uptime-monitors"&gt;How Do You Add Your First Uptime Monitors?&lt;/h2&gt;
&lt;p&gt;Click &lt;strong&gt;"Add New Monitor"&lt;/strong&gt; in the dashboard. Uptime Kuma supports many monitor types:&lt;/p&gt;
&lt;h3 id="https-monitor"&gt;HTTP(s) Monitor&lt;/h3&gt;
&lt;p&gt;The most common type. It checks a URL and verifies the response code.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Monitor Type:&lt;/strong&gt; HTTP(s)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;URL:&lt;/strong&gt; &lt;code&gt;https://blog.byte-guard.net&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Heartbeat Interval:&lt;/strong&gt; 60 seconds&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retries:&lt;/strong&gt; 3 (avoids false positives from temporary network blips)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Accepted Status Codes:&lt;/strong&gt; 200-299&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This checks your blog every 60 seconds. If it returns a non-2xx status code 3 times in a row, it's marked as down.&lt;/p&gt;
&lt;h3 id="tcp-port-monitor"&gt;TCP Port Monitor&lt;/h3&gt;
&lt;p&gt;Checks if a specific port is open. Useful for databases, mail servers, or any service that doesn't serve HTTP.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Monitor Type:&lt;/strong&gt; TCP Port&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hostname:&lt;/strong&gt; &lt;code&gt;127.0.0.1&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Port:&lt;/strong&gt; &lt;code&gt;3306&lt;/code&gt; (MySQL) or &lt;code&gt;5432&lt;/code&gt; (PostgreSQL)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Heartbeat Interval:&lt;/strong&gt; 120 seconds&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="ping-monitor"&gt;Ping Monitor&lt;/h3&gt;
&lt;p&gt;Simple ICMP ping. Checks if a host is reachable.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Monitor Type:&lt;/strong&gt; Ping&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hostname:&lt;/strong&gt; &lt;code&gt;10.0.0.1&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Heartbeat Interval:&lt;/strong&gt; 60 seconds&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="docker-container-monitor"&gt;Docker Container Monitor&lt;/h3&gt;
&lt;p&gt;Checks if a Docker container is running. Requires mounting the Docker socket.&lt;/p&gt;
&lt;p&gt;Update your compose file to mount the socket:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;services:
  uptime-kuma:
    image: louislam/uptime-kuma:1
    container_name: uptime-kuma
    volumes:
      - kuma_data:/app/data
      - /var/run/docker.sock:/var/run/docker.sock:ro
    ports:
      - "127.0.0.1:3001:3001"
    restart: unless-stopped
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;strong&gt;Security note:&lt;/strong&gt; Mounting the Docker socket gives Uptime Kuma read access to all your containers. Mount it read-only (&lt;code&gt;:ro&lt;/code&gt;) and be aware of the &lt;a href="https://blog.byte-guard.net/docker-security-best-practices/" rel="noopener noreferrer"&gt;Docker security implications&lt;/a&gt;. If this concerns you, use HTTP monitors instead — they verify the service is actually responding, not just that the container is running.&lt;/blockquote&gt;
&lt;p&gt;Restart and add a Docker container monitor: - &lt;strong&gt;Monitor Type:&lt;/strong&gt; Docker Container - &lt;strong&gt;Container Name:&lt;/strong&gt; &lt;code&gt;ghost&lt;/code&gt;&lt;/p&gt;

&lt;h2 id="what-does-a-real-world-uptime-kuma-monitor-setup-look-like"&gt;What Does a Real-World Uptime Kuma Monitor Setup Look Like?&lt;/h2&gt;
&lt;p&gt;Here's what I actually monitor:&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;Monitor Type&lt;/th&gt;
&lt;th&gt;Interval&lt;/th&gt;
&lt;th&gt;URL/Target&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Blog&lt;/td&gt;
&lt;td&gt;HTTP(s)&lt;/td&gt;
&lt;td&gt;60s&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://blog.byte-guard.net&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Status page&lt;/td&gt;
&lt;td&gt;HTTP(s)&lt;/td&gt;
&lt;td&gt;60s&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://status.byte-guard.net&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ghost Admin&lt;/td&gt;
&lt;td&gt;HTTP(s)&lt;/td&gt;
&lt;td&gt;120s&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://blog.byte-guard.net/ghost/&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NPM Dashboard&lt;/td&gt;
&lt;td&gt;HTTP(s)&lt;/td&gt;
&lt;td&gt;120s&lt;/td&gt;
&lt;td&gt;Internal IP:81&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSH&lt;/td&gt;
&lt;td&gt;TCP Port&lt;/td&gt;
&lt;td&gt;120s&lt;/td&gt;
&lt;td&gt;Server IP, port 22&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VPS Ping&lt;/td&gt;
&lt;td&gt;Ping&lt;/td&gt;
&lt;td&gt;60s&lt;/td&gt;
&lt;td&gt;Server IP&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Six monitors, all running on the same VPS they're monitoring. Yes, if the VPS goes down, Uptime Kuma goes down too — it can't alert you. For critical infrastructure, you'd run Uptime Kuma on a separate server. For a blog, this is fine. I'd know from the DNS monitoring on my other setup.&lt;/p&gt;

&lt;h2 id="how-do-you-configure-uptime-kuma-notifications"&gt;How Do You Configure Uptime Kuma Notifications?&lt;/h2&gt;
&lt;p&gt;Monitoring without alerts is a dashboard you never check. Set up notifications so you know the moment something breaks.&lt;/p&gt;
&lt;p&gt;Go to &lt;strong&gt;Settings → Notifications&lt;/strong&gt; and click &lt;strong&gt;"Setup Notification."&lt;/strong&gt;&lt;/p&gt;
&lt;h3 id="discord-webhook"&gt;Discord Webhook&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;In your Discord server, go to channel settings → Integrations → Webhooks&lt;/li&gt;
&lt;li&gt;Create a webhook, copy the URL&lt;/li&gt;
&lt;li&gt;In Uptime Kuma: Notification Type → Discord, paste the webhook URL&lt;/li&gt;
&lt;li&gt;Test and save&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id="telegram-bot"&gt;Telegram Bot&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;Message &lt;code&gt;@BotFather&lt;/code&gt; on Telegram, create a bot, get the token&lt;/li&gt;
&lt;li&gt;Get your Chat ID by messaging &lt;code&gt;@userinfobot&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;In Uptime Kuma: Notification Type → Telegram, enter bot token and chat ID&lt;/li&gt;
&lt;li&gt;Test and save&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id="email-smtp"&gt;Email (SMTP)&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SMTP Host:&lt;/strong&gt; your mail server (e.g., &lt;code&gt;smtp.gmail.com&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SMTP Port:&lt;/strong&gt; 587&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;From/To:&lt;/strong&gt; your email addresses&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Username/Password:&lt;/strong&gt; your SMTP credentials&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="apply-notifications-to-monitors"&gt;Apply Notifications to Monitors&lt;/h3&gt;
&lt;p&gt;After creating a notification channel, edit each monitor and check the notification methods you want. You can assign different notifications to different monitors — critical services get Telegram + Discord, less important ones get email only.&lt;/p&gt;

&lt;h2 id="how-do-you-create-an-uptime-kuma-public-status-page"&gt;How Do You Create an Uptime Kuma Public Status Page?&lt;/h2&gt;
&lt;p&gt;This is one of Uptime Kuma's best features. A public page showing your service status — like &lt;a href="https://status.byte-guard.net" rel="noopener noreferrer"&gt;status.byte-guard.net&lt;/a&gt;.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Go to &lt;strong&gt;Status Pages&lt;/strong&gt; in the sidebar&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;"New Status Page"&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Set the title and slug (e.g., "ByteGuard Status" at &lt;code&gt;/status&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Add monitor groups — drag your monitors into sections&lt;/li&gt;
&lt;li&gt;Customize: description, footer text, and theme&lt;/li&gt;
&lt;li&gt;Save and publish&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The status page shows: - Current status of each service (up/down/maintenance) - Uptime percentage over the last 90 days - Incident history - A heartbeat graph for each monitor&lt;/p&gt;
&lt;h3 id="maintenance-windows"&gt;Maintenance Windows&lt;/h3&gt;
&lt;p&gt;When you're doing planned maintenance, create a maintenance window so your monitors don't fire false alerts:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Go to &lt;strong&gt;Maintenance&lt;/strong&gt; in the sidebar&lt;/li&gt;
&lt;li&gt;Create a new maintenance window with start/end times&lt;/li&gt;
&lt;li&gt;Select which monitors are affected&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;During the window, affected monitors show "Maintenance" instead of "Down" on the status page.&lt;/p&gt;

&lt;h2 id="what-are-uptime-kuma%E2%80%99s-advanced-configuration-options"&gt;What Are Uptime Kuma’s Advanced Configuration Options?&lt;/h2&gt;
&lt;h3 id="monitor-groups-and-tags"&gt;Monitor Groups and Tags&lt;/h3&gt;
&lt;p&gt;Organize monitors with tags (e.g., "production", "staging", "internal") and group them on your status page. This keeps things manageable as your monitor count grows.&lt;/p&gt;
&lt;h3 id="upside-down-mode"&gt;Upside-Down Mode&lt;/h3&gt;
&lt;p&gt;Inverts the monitor logic — alerts when a service comes &lt;strong&gt;up&lt;/strong&gt; instead of going down. Useful for monitoring ports that should be closed or services that should be off.&lt;/p&gt;
&lt;h3 id="certificate-expiry-monitoring"&gt;Certificate Expiry Monitoring&lt;/h3&gt;
&lt;p&gt;HTTP(s) monitors automatically track SSL certificate expiry. You'll see the expiry date in the monitor details and get notified when it's approaching expiry (configurable threshold, default 30 days).&lt;/p&gt;
&lt;h3 id="api-and-integrations"&gt;API and Integrations&lt;/h3&gt;
&lt;p&gt;Uptime Kuma has a push monitor type where your service sends heartbeats to Uptime Kuma instead of Kuma polling the service. Useful for cron jobs and batch processes:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Add to the end of your cron script
curl -s "https://status.yourdomain.com/api/push/&amp;lt;PUSH_TOKEN&amp;gt;?status=up&amp;amp;msg=OK" &amp;gt; /dev/null
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If the push doesn't arrive within the expected interval, the monitor marks as down.&lt;/p&gt;

&lt;h2 id="how-do-you-secure-your-uptime-kuma-instance"&gt;How Do You Secure Your Uptime Kuma Instance?&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Authentication.&lt;/strong&gt; Uptime Kuma requires login, but make sure you set a strong password. There's no 2FA built in — protect it with your reverse proxy (IP whitelist or HTTP basic auth on top).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker socket.&lt;/strong&gt; If mounted, it's read-only but still exposes container metadata. Consider whether HTTP monitors are sufficient for your needs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Status page visibility.&lt;/strong&gt; Public status pages are just that — public. Don't include internal service names, IP addresses, or ports that you don't want exposed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data retention.&lt;/strong&gt; Monitor data grows over time. Uptime Kuma stores heartbeats in SQLite. For long-running instances, check the database size periodically: &lt;code&gt;docker exec uptime-kuma ls -lh /app/data/kuma.db&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id="how-do-you-troubleshoot-uptime-kuma-issues"&gt;How Do You Troubleshoot Uptime Kuma Issues?&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Problem: Dashboard shows "WebSocket connection failed."&lt;/strong&gt; Cause: Your reverse proxy isn't forwarding WebSocket connections. Fix: In NPM, enable "WebSocket Support" for the proxy host. 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: Monitor shows "Down" but the service works fine in browser.&lt;/strong&gt; Cause: The monitor is checking the wrong URL or port, or the service responds differently to non-browser requests. Fix: Verify the URL is exactly right (including &lt;code&gt;https://&lt;/code&gt;). Check if the service requires specific headers — some apps return 403 without a proper User-Agent.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Problem: Notifications not sending.&lt;/strong&gt; Cause: SMTP credentials wrong, Discord webhook expired, or Telegram bot token invalid. Fix: Use the "Test" button on each notification channel. Check Uptime Kuma logs: &lt;code&gt;docker compose logs uptime-kuma&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Problem: Status page shows blank.&lt;/strong&gt; Cause: No monitors assigned to the status page groups. Fix: Edit the status page, add groups, and drag monitors into them. Save and refresh.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Problem: High CPU usage.&lt;/strong&gt; Cause: Too many monitors with very short intervals. Fix: Increase heartbeat intervals for non-critical monitors. 60 seconds is aggressive — 120-300 seconds is fine for most services.&lt;/p&gt;

&lt;h2 id="why-should-you-self-host-uptime-kuma-instead-of-using-a-saas-service"&gt;Why Should You Self-Host Uptime Kuma Instead of Using a SaaS Service?&lt;/h2&gt;
&lt;p&gt;Uptime Kuma takes 10 minutes to set up and saves you from the embarrassment of finding out your site is down from a reader's complaint — or worse, not finding out at all.&lt;/p&gt;
&lt;p&gt;I've been running it for the entire ByteGuard stack and it's caught two outages before anyone noticed. The public status page builds trust with readers, and the notification system is reliable across Discord, Telegram, and email.&lt;/p&gt;
&lt;p&gt;For next steps:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Follow &lt;a href="https://blog.byte-guard.net/docker-security-best-practices/" rel="noopener noreferrer"&gt;Docker security best practices&lt;/a&gt; to lock down the container&lt;/li&gt;
&lt;li&gt;Set up &lt;a href="https://blog.byte-guard.net/fail2ban-setup-guide/" rel="noopener noreferrer"&gt;Fail2Ban&lt;/a&gt; on your server for broader monitoring&lt;/li&gt;
&lt;li&gt;Check my &lt;a href="https://blog.byte-guard.net/building-byteguard-from-scratch-hetzner-vps/" rel="noopener noreferrer"&gt;original VPS setup guide&lt;/a&gt; if you're starting from scratch&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you need a VPS, I use &lt;a href="https://www.hetzner.com/cloud/" rel="noopener noreferrer"&gt;Hetzner&lt;/a&gt; — their CPX22 handles all my services including Uptime Kuma with plenty of headroom.&lt;/p&gt;

</description>
      <category>uptimekuma</category>
      <category>monitoring</category>
      <category>selfhosting</category>
      <category>docker</category>
    </item>
  </channel>
</rss>
