<?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>Forem: byte-guard</title>
    <description>The latest articles on Forem by byte-guard (@byte-guard).</description>
    <link>https://forem.com/byte-guard</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3874887%2F0b20b60b-2582-4d1f-b098-8efd21b4295c.png</url>
      <title>Forem: byte-guard</title>
      <link>https://forem.com/byte-guard</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/byte-guard"/>
    <language>en</language>
    <item>
      <title>Docker Security Best Practices for Self-Hosters in 2026</title>
      <dc:creator>byte-guard</dc:creator>
      <pubDate>Sat, 18 Apr 2026 11:29:06 +0000</pubDate>
      <link>https://forem.com/byte-guard/docker-security-best-practices-for-self-hosters-in-2026-35k3</link>
      <guid>https://forem.com/byte-guard/docker-security-best-practices-for-self-hosters-in-2026-35k3</guid>
      <description>&lt;p&gt;Docker makes self-hosting feel effortless. Pull an image, write a compose file, run &lt;code&gt;docker compose up -d&lt;/code&gt;, and you have a production service in minutes. That's exactly how I built the entire ByteGuard stack — &lt;a href="https://blog.byte-guard.net/building-byteguard-from-scratch-hetzner-vps/" rel="noopener noreferrer"&gt;Ghost, Nginx Proxy Manager, and Uptime Kuma on a single Hetzner VPS&lt;/a&gt;.&lt;/p&gt;

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

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

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

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

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




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

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

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

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

&lt;/div&gt;



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

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

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




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

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

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

&lt;/div&gt;



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

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

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




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

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

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

&lt;/div&gt;



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

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

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

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

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




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

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

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

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

&lt;/div&gt;



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

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

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

&lt;/div&gt;





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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

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




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

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

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

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

&lt;/div&gt;



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

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

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

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

&lt;/div&gt;



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

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

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

&lt;/div&gt;



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




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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

&lt;/div&gt;



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

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




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

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

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

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

&lt;/div&gt;



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

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

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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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




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

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

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

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

&lt;/div&gt;



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

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




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

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

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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

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

&lt;/div&gt;



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




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

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

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

&lt;/div&gt;



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

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

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

&lt;/div&gt;



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




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

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

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

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

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

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

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

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

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

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

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




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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

&lt;/div&gt;



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

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

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

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

&lt;/div&gt;



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

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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

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

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

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

&lt;/div&gt;



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

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

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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

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

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

&lt;/div&gt;



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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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




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

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

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




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

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