<?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: Dowithsudo</title>
    <description>The latest articles on DEV Community by Dowithsudo (@dowithsudo).</description>
    <link>https://dev.to/dowithsudo</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%2F4078081%2F36338a54-bd14-4223-8304-56b45ecd9880.png</url>
      <title>DEV Community: Dowithsudo</title>
      <link>https://dev.to/dowithsudo</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dowithsudo"/>
    <language>en</language>
    <item>
      <title>Proxmox: Compromised VM vs Hypervisor</title>
      <dc:creator>Dowithsudo</dc:creator>
      <pubDate>Fri, 14 Aug 2026 18:41:43 +0000</pubDate>
      <link>https://dev.to/dowithsudo/proxmox-compromised-vm-vs-hypervisor-2c9b</link>
      <guid>https://dev.to/dowithsudo/proxmox-compromised-vm-vs-hypervisor-2c9b</guid>
      <description>&lt;p&gt;There is a certain pride and peace of mind when looking at a Proxmox Virtual Environment (PVE) dashboard with all green status indicators. The cluster is healthy, Ceph storage synchronization is smooth, weekly backups run without errors, and most importantly public internet access to management port 8006 has been completely sealed off with a hardware firewall in front of it.&lt;/p&gt;

&lt;p&gt;As an admin, I thought that fortress was impenetrable. My logic was simple: "Who can get in and mess up the cluster configuration if all management ports are tightly closed from public access?"&lt;/p&gt;

&lt;p&gt;However, the reality in the field often slaps us hard. That morning, the cluster security logs showed an anomaly that made the hair on the back of my neck stand up, there were thousands of &lt;em&gt;failed login attempts&lt;/em&gt; to the user &lt;code&gt;root@pam&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The question was: &lt;strong&gt;Where were they coming from?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I immediately checked the attacker's &lt;em&gt;source IP&lt;/em&gt;. It wasn't an IP from China, Russia, or some other country. It was a local subnet IP &lt;code&gt;192.168.x.x&lt;/code&gt;. My heart raced as I realized the terrifying fact. That IP belonged to one of the client's Virtual Machines (VM) running inside that very Proxmox node.&lt;/p&gt;

&lt;p&gt;I realized I had made a fatal mistake common among beginners and careless seniors alike &lt;strong&gt;assuming the internal network (LAN) is a safe zone free from threats.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I forgot that the biggest threat to a Hypervisor is often not a genius hacker breaching the perimeter firewall from across the ocean, but a "roommate", a VM that has been compromised by bad actors.&lt;/p&gt;

&lt;h2&gt;
  
  
  The illusion of internal security
&lt;/h2&gt;

&lt;p&gt;Let's dissect the scenario. This attack scenario is very simple, requires no sophisticated tools, but the impact is deadly.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;Entry Point:&lt;/strong&gt; A VM running a web server (e.g., Ubuntu Server with WordPress) gets hacked. The cause is classic, outdated plugins, weak &lt;code&gt;admin&lt;/code&gt; user passwords, or poor upload filters. The hacker gets in and gains shell access on that VM.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Reconnaissance:&lt;/strong&gt; From inside the compromised VM, the hacker performs simple &lt;em&gt;network scanning&lt;/em&gt; using &lt;code&gt;nmap&lt;/code&gt; or even a &lt;code&gt;ping&lt;/code&gt; sweep of the local subnet.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Discovery:&lt;/strong&gt; The hacker finds the gateway IP or the Proxmox host IP residing on the same network bridge (&lt;code&gt;vmbr0&lt;/code&gt;) subnet.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Attack:&lt;/strong&gt; Because the default Proxmox installation often leaves management port 8006 open to &lt;em&gt;all&lt;/em&gt; interfaces on bridge &lt;code&gt;vmbr0&lt;/code&gt;, the hacker can see our Proxmox login page from inside the VM.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;From this point, it's just a matter of time. They can run a password &lt;em&gt;brute force&lt;/em&gt; against the Proxmox root account without being blocked by external firewalls (since the attack is coming from "inside"), or exploit unpatched PVE vulnerabilities.&lt;/p&gt;

&lt;p&gt;If they manage to breach the Hypervisor level, it is &lt;strong&gt;Game Over&lt;/strong&gt;.&lt;br&gt;
They can delete all VMs, steal backups, format storage, or plant ransomware at the host level that will lock the entire company infrastructure.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This reminds me of the basic defense principles I wrote about in &lt;a href="https://dowithsudo.com/blog/linux-server-hardening-best-practices/" rel="noopener noreferrer"&gt;Linux Server Hardening Best Practices&lt;/a&gt;: never trust default settings, even on internal networks.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  Isolating the "bad apples" with vlans
&lt;/h2&gt;

&lt;p&gt;This incident taught me an expensive lesson, we must treat every VM as if it were a potential enemy. &lt;strong&gt;Zero Trust Architecture&lt;/strong&gt; is not just vendor marketing jargon, it is an absolute necessity in the cloud computing era.&lt;/p&gt;

&lt;p&gt;After that incident, I completely overhauled my virtualization network architecture. Here are the practical steps I implemented that saved me in subsequent incidents:&lt;/p&gt;
&lt;h3&gt;
  
  
  Separate management path and guest traffic
&lt;/h3&gt;

&lt;p&gt;Never let user VM traffic mix (same subnet/same bridge) with the Proxmox management interface. Separate them physically or logically using VLANs (Virtual LANs).&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;VLAN 10 (Management):&lt;/strong&gt; Strictly for Proxmox Host IP, iDRAC/IPMI. Accessible only from the Admin laptop or a dedicated VPN.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;VLAN 20 (VM Public):&lt;/strong&gt; Strictly for VM internet traffic.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;VLAN 30 (VM Private/DB):&lt;/strong&gt; Strictly for backend database communication.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With this separation, even if a VM in VLAN 20 is hacked, the hacker inside cannot ping or see the Proxmox management IP in VLAN 10. The path is cut off at the switch or router level.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Internal network attack cases like this share a similar pattern with DDoS attacks I discussed in the &lt;a href="https://dowithsudo.com/blog/ddos-mikrotik-case-study/" rel="noopener noreferrer"&gt;DDoS Mikrotik Case Study&lt;/a&gt;, where traffic separation is the key to mitigation.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;
  
  
  Activate firewall at datacenter/node level
&lt;/h3&gt;

&lt;p&gt;Proxmox VE has an incredibly powerful built-in firewall feature (&lt;code&gt;pve-firewall&lt;/code&gt;) integrated directly into the Linux kernel. Unfortunately, this feature is often turned off or untouched by admins out of "fear of misconfiguration" that could lead to a &lt;em&gt;lockout&lt;/em&gt; (admin locked out of their own server).&lt;/p&gt;

&lt;p&gt;Don't be afraid. Turn it on!&lt;br&gt;
The safest strategy is to create "Whitelist" rules at the Datacenter or Node level.&lt;/p&gt;

&lt;p&gt;Example safe Proxmox firewall logic:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;Drop by Default:&lt;/strong&gt; Set Input Policy to DROP.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Allow Management IP:&lt;/strong&gt; Allow ports 8006 (GUI) and 22 (SSH) ONLY from the static source IP of the admin laptop or office VPN IP.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Drop Internal:&lt;/strong&gt; Ensure DROP rules catch traffic from VM subnets.
&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Rule logic illustration (not copy-paste command):&lt;/span&gt;
IN ACCEPT &lt;span class="nt"&gt;-p&lt;/span&gt; tcp &lt;span class="nt"&gt;--dport&lt;/span&gt; 8006 &lt;span class="nt"&gt;-s&lt;/span&gt; 192.168.10.5 &lt;span class="o"&gt;(&lt;/span&gt;Admin Laptop&lt;span class="o"&gt;)&lt;/span&gt;
IN ACCEPT &lt;span class="nt"&gt;-p&lt;/span&gt; tcp &lt;span class="nt"&gt;--dport&lt;/span&gt; 22 &lt;span class="nt"&gt;-s&lt;/span&gt; 192.168.10.5
IN DROP &lt;span class="nt"&gt;-p&lt;/span&gt; tcp &lt;span class="nt"&gt;--dport&lt;/span&gt; 8006 &lt;span class="nt"&gt;-s&lt;/span&gt; 0.0.0.0/0 &lt;span class="o"&gt;(&lt;/span&gt;Deny all &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;With these rules, even if a hacker inside a VM can "see" the Proxmox Host IP, their request packets to port 8006 will be immediately discarded by the host kernel firewall.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Important Tip:&lt;/strong&gt; Before applying DROP rules on management ports, ensure you have physical access (monitor+keyboard) or IPMI access to the server. Just in case you accidentally block yourself.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  Never trust "localhost"
&lt;/h2&gt;

&lt;p&gt;Security is layered (&lt;em&gt;Defense in Depth&lt;/em&gt;). Securing Proxmox from public internet access is just step 1. Securing it from "backstabbing" attacks by its own VMs is step 2, which is often forgotten due to excessive trust in internal networks.&lt;/p&gt;

&lt;p&gt;This case changed my mindset to be more paranoid in a positive way. Client VMs can be compromised at any time. App bugs can appear at any time.&lt;br&gt;
My job as a sysadmin isn't to ensure the system is 100% unbreachable (because that's impossible), but to ensure that &lt;strong&gt;even if one hotel room catches fire, the flames won't burn down the whole building, and most importantly won't burn down the main control room.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Detecting internal reconnaissance
&lt;/h2&gt;

&lt;p&gt;How do you know if one of your VMs is actively probing the hypervisor? Look for these telltale signs.&lt;/p&gt;
&lt;h3&gt;
  
  
  Check hypervisor access logs
&lt;/h3&gt;

&lt;p&gt;Proxmox logs every authentication attempt. If you see repeated failed logins from an unexpected internal IP, investigate immediately.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'proxmox.*authentication failure'&lt;/span&gt; /var/log/syslog | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $NF}'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;uniq&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This command shows the source IPs of failed auth attempts. If you see VM subnet IPs here, you have a compromised guest.&lt;/p&gt;

&lt;h3&gt;
  
  
  Monitor bridge traffic
&lt;/h3&gt;

&lt;p&gt;Install &lt;code&gt;fail2ban&lt;/code&gt; or &lt;code&gt;crowdsec&lt;/code&gt; on the Proxmox host, configured to detect port scanning patterns originating from bridge interfaces.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;iptables &lt;span class="nt"&gt;-I&lt;/span&gt; INPUT &lt;span class="nt"&gt;-i&lt;/span&gt; vmbr0 &lt;span class="nt"&gt;-p&lt;/span&gt; tcp &lt;span class="nt"&gt;--dport&lt;/span&gt; 8006 &lt;span class="nt"&gt;-m&lt;/span&gt; state &lt;span class="nt"&gt;--state&lt;/span&gt; NEW &lt;span class="nt"&gt;-j&lt;/span&gt; LOG &lt;span class="nt"&gt;--log-prefix&lt;/span&gt; &lt;span class="s2"&gt;"PVE-ACCESS: "&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Audit SSH key trust
&lt;/h3&gt;

&lt;p&gt;A common pivot technique is stealing SSH private keys from a compromised VM and using them to access the host. Regularly rotate keys and enforce passphrase protection.&lt;/p&gt;

&lt;p&gt;Once an attacker holds SSH keys, moving between servers is only a matter of technique. I walked through a complete &lt;a href="https://dowithsudo.com/blog/server-linux-pivot-attack/" rel="noopener noreferrer"&gt;Linux server pivot attack&lt;/a&gt; scenario on a real network.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recreating the attack: a minute by minute timeline
&lt;/h2&gt;

&lt;p&gt;To make the risk concrete, here is how the whole incident played out from the attacker's point of view. It is uncomfortable to read, and that is exactly the point.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Day 0, 14:02.&lt;/strong&gt; The attacker exploits an outdated WordPress plugin on the client's web VM. It is a known CVE with a published exploit script. No special skills required, just reading.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Day 0, 14:11.&lt;/strong&gt; Shell access is confirmed. The attacker runs &lt;code&gt;ip a&lt;/code&gt; and sees the VM sits on &lt;code&gt;192.168.1.0/24&lt;/code&gt;. Next, a quick &lt;code&gt;ping&lt;/code&gt; sweep maps every live host on that subnet, including the Proxmox host at &lt;code&gt;192.168.1.2&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Day 0, 14:19.&lt;/strong&gt; &lt;code&gt;curl http://192.168.1.2:8006&lt;/code&gt; returns the Proxmox login page. The management interface is reachable from the guest network because the default install binds to all interfaces.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Day 0, 14:20 to 03:40.&lt;/strong&gt; A brute force runs against &lt;code&gt;root@pam&lt;/code&gt;. Four hours later, one credential from a reused password finally matches. The attacker is now the hypervisor administrator.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Day 1, 09:00.&lt;/strong&gt; Backups are mounted and exfiltrated. Two production VMs are deleted as a proof of impact. The company does not know until users start complaining.&lt;/p&gt;

&lt;p&gt;Every step after the first one was preventable. The exploit happened, but the lateral movement, the brute force, and the final breach were all enabled by network design decisions, not by luck.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prevention checklist
&lt;/h2&gt;

&lt;p&gt;Before deploying any VM on your Proxmox cluster, run through this. These controls sit on top of the Linux security fundamentals I cover in &lt;a href="https://dowithsudo.com/blog/basic-linux-system-administrator/" rel="noopener noreferrer"&gt;basic Linux for system administrators&lt;/a&gt;.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;VM is on a dedicated VLAN&lt;/strong&gt;, never on the same bridge as management&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Host firewall enabled&lt;/strong&gt;, &lt;code&gt;pve-firewall&lt;/code&gt; with DROP default policy&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Management access restricted&lt;/strong&gt;, port 8006 and 22 only from admin IP or VPN&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;VM cannot reach host IP&lt;/strong&gt;, test with &lt;code&gt;ping&lt;/code&gt; and &lt;code&gt;curl&lt;/code&gt; from inside each VM&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backups are network-isolated&lt;/strong&gt;, separate storage network from management VLAN&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IDS/IPS on internal traffic&lt;/strong&gt;, monitor bridge traffic for anomalies&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you cannot confidently check all six, your hypervisor has a gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Control to prevention mapping
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Control&lt;/th&gt;
&lt;th&gt;What it blocks&lt;/th&gt;
&lt;th&gt;Where to configure&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;VLAN separation&lt;/td&gt;
&lt;td&gt;Lateral movement to management network&lt;/td&gt;
&lt;td&gt;Switch/router, vmbr bridges&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;pve-firewall&lt;/code&gt; DROP default&lt;/td&gt;
&lt;td&gt;Any traffic to management ports from guests&lt;/td&gt;
&lt;td&gt;Datacenter → Firewall → Options&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Source IP allowlist (8006/22)&lt;/td&gt;
&lt;td&gt;Brute force from anywhere but admin IP&lt;/td&gt;
&lt;td&gt;Node firewall rules&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSH key rotation + passphrase&lt;/td&gt;
&lt;td&gt;Pivot using stolen keys&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;~/.ssh/authorized_keys&lt;/code&gt;, key management&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Network-isolated backups&lt;/td&gt;
&lt;td&gt;Ransomware at host level deleting backups&lt;/td&gt;
&lt;td&gt;Separate storage VLAN, immutable snapshots&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IDS/IPS on bridge traffic&lt;/td&gt;
&lt;td&gt;Reconnaissance and port scans&lt;/td&gt;
&lt;td&gt;fail2ban, crowdsec, log monitoring&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each row answers three questions at once: what the attacker loses, where you turn it on, and why it matters. If a control is not implemented, write down the risk it is supposed to cover. Unmanaged risk has a way of introducing itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final verification
&lt;/h2&gt;

&lt;p&gt;Check your Proxmox firewall configuration now. Access one of your VMs, open the terminal, and try to &lt;code&gt;ping&lt;/code&gt; or &lt;code&gt;curl https://&amp;lt;PROXMOX-Host-IP&amp;gt;:8006&lt;/code&gt;.&lt;br&gt;
If the status is "Reply" or the login page appears in that VM's terminal? Congratulations, you have homework tonight before bed.&lt;/p&gt;




&lt;p&gt;I hope this guide on Proxmox VM attack helps you make better decisions in real-world situations.&lt;/p&gt;

</description>
      <category>proxmox</category>
      <category>hypervisor</category>
      <category>security</category>
      <category>vlan</category>
    </item>
    <item>
      <title>Linux Server Hardening Best Practices</title>
      <dc:creator>Dowithsudo</dc:creator>
      <pubDate>Fri, 14 Aug 2026 18:40:11 +0000</pubDate>
      <link>https://dev.to/dowithsudo/linux-server-hardening-best-practices-3lg7</link>
      <guid>https://dev.to/dowithsudo/linux-server-hardening-best-practices-3lg7</guid>
      <description>&lt;p&gt;I remember clear as day the first time I rented a cheap VPS for my personal projects. With the excitement of many beginners, I installed Linux, set a mediocre root password, and left it running overnight.&lt;/p&gt;

&lt;p&gt;The following morning, out of curiosity, I checked the system logs. My eyes widened as I saw thousands of failed SSH login attempts from random IP addresses across the globe. Thousands of bots were hammering away at my server's door every single second. It was a wake-up call. Running a server on the public internet without proper security is like leaving your front door wide open in a crowded city.&lt;/p&gt;

&lt;p&gt;Server hardening isn't just a theoretical topic in a cybersecurity textbook. It's the primary line of defense between your data and a world full of bots. These are the steps I actually run in production, refined over years of managing real servers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does server hardening actually matter?
&lt;/h2&gt;

&lt;p&gt;By default, most Linux distributions are configured for ease of access. This is great for learning on local machines, but dangerously porous for servers exposed to the internet.&lt;/p&gt;

&lt;p&gt;This guide assumes you're comfortable on the command line. If you're still building your Linux fundamentals, my &lt;a href="https://dowithsudo.com/blog/basic-linux-system-administrator/" rel="noopener noreferrer"&gt;Basic Linux System Administration&lt;/a&gt; guide covers the essentials you'll need before you harden anything.&lt;/p&gt;

&lt;p&gt;The core goal of &lt;strong&gt;Server Hardening&lt;/strong&gt; is to reduce the &lt;strong&gt;Attack Surface&lt;/strong&gt;. Imagine your server as a building. A building with 50 unguarded doors is much easier to breach than one with a single heavily guarded entrance.&lt;/p&gt;

&lt;p&gt;Hardening is the ongoing process of closing unnecessary doors, replacing weak locks with military-grade ones, and setting up surveillance in every corner. It's about being proactive rather than waiting for an incident to happen.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdowithsudo.com%2F_astro%2Fpivot-attack-diagram.BvhQAEQQ_1Br2ET.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdowithsudo.com%2F_astro%2Fpivot-attack-diagram.BvhQAEQQ_1Br2ET.webp" title="Hardening closes potential entry points for attackers" alt="Visualizing Attack Surface Reduction" width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1: securing the main entrance (SSH hardening)
&lt;/h2&gt;

&lt;p&gt;SSH (Secure Shell) is the gateway to your server. Because it's the main entry point, it's also the first place attackers will try to break into.&lt;/p&gt;

&lt;p&gt;The very first task I perform on any new server is tightening the configuration in &lt;code&gt;/etc/ssh/sshd_config&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stop using passwords: move to key-based auth
&lt;/h3&gt;

&lt;p&gt;Passwords, no matter how complex, can eventually be cracked via brute force. SSH Keys are virtually impossible to guess. If you aren't using keys, you aren't really secure.&lt;/p&gt;

&lt;h3&gt;
  
  
  Disable root login and password authentication
&lt;/h3&gt;

&lt;p&gt;Once I've verified my SSH key works, I immediately shut down the ability to log in as &lt;code&gt;root&lt;/code&gt; and disable password authentication entirely.&lt;/p&gt;

&lt;p&gt;My recommended SSH configuration looks like this:&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;# Edit /etc/ssh/sshd_config&lt;/span&gt;
&lt;span class="c"&gt;# Find and modify these parameters:&lt;/span&gt;

PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication &lt;span class="nb"&gt;yes
&lt;/span&gt;MaxAuthTries 3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After saving the changes, remember to restart the service:&lt;br&gt;
&lt;code&gt;sudo systemctl restart ssh&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;Operator's Perspective:&lt;/em&gt;&lt;/strong&gt; Many recommend changing the default SSH port (22) to something non-standard, like port 2222. While this isn't "impenetrable security," it is remarkably effective at cutting log noise by stopping millions of automated scanners that only target the default port.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdowithsudo.com%2F_astro%2Fssh-hardening-flow.BPDEsYU-_IYnrT.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdowithsudo.com%2F_astro%2Fssh-hardening-flow.BPDEsYU-_IYnrT.webp" title="Steps from key generation to disabling root access" alt="SSH Hardening Flow" width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  Extra SSH hardening people forget
&lt;/h2&gt;

&lt;p&gt;Additional &lt;code&gt;sshd_config&lt;/code&gt; parameters that tighten access:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;AllowUsers adminops deploy
LoginGraceTime 30
ClientAliveInterval 300
ClientAliveCountMax 2
X11Forwarding no
AllowTcpForwarding no
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What they do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;AllowUsers&lt;/code&gt;: only specific users can SSH.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;LoginGraceTime&lt;/code&gt;: limit login time.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ClientAlive*&lt;/code&gt;: drop idle connections.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;X11Forwarding&lt;/code&gt; and &lt;code&gt;AllowTcpForwarding&lt;/code&gt;: disable if not needed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Validate and reload:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 2: the principle of least privilege (user management)
&lt;/h2&gt;

&lt;p&gt;In security, less is more. Users should only have the permissions absolutely necessary to do their jobs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Never use the root account daily
&lt;/h3&gt;

&lt;p&gt;Always create a standard user account with &lt;code&gt;sudo&lt;/code&gt; privileges. This prevents you from accidentally running a destructive command as root and provides an audit trail of who ran what command.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Add a new user&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;adduser danu

&lt;span class="c"&gt;# Grant sudo privileges&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;usermod &lt;span class="nt"&gt;-aG&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;danu
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Make it a habit to audit your user list regularly and remove any accounts belonging to people no longer on the project.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2.1: sudoers and access audit
&lt;/h2&gt;

&lt;p&gt;Don't grant blanket sudo. Use &lt;code&gt;visudo&lt;/code&gt; and scope privileges:&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;visudo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Example rules:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;%admin  ALL=(ALL) ALL
deploy  ALL=(ALL) NOPASSWD:/bin/systemctl restart nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here, &lt;code&gt;deploy&lt;/code&gt; can only restart Nginx, not perform full root access.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: placing the perimeter fence (firewall)
&lt;/h2&gt;

&lt;p&gt;If your server is a house, the firewall is the perimeter fence. It ensures that only traffic you've explicitly invited can get through.&lt;/p&gt;

&lt;p&gt;For Ubuntu or Debian systems, I swear by &lt;strong&gt;UFW&lt;/strong&gt; (Uncomplicated Firewall). It's simple, powerful, and hard to mess up.&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;# Deny everything by default&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="c"&gt;# Only open what you need&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow 22/tcp    &lt;span class="c"&gt;# SSH&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow 80/tcp    &lt;span class="c"&gt;# HTTP&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow 443/tcp   &lt;span class="c"&gt;# HTTPS&lt;/span&gt;

&lt;span class="c"&gt;# Turn it on&lt;/span&gt;
&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;You can verify your security posture by checking which services are actually listening for connections:&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;ss &lt;span class="nt"&gt;-tulpn&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you see an app listening on a port you didn't know about, that's a red flag you need to investigate immediately.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdowithsudo.com%2F_astro%2Ffirewall-port-filtering.B9REdMXv_YQvuz.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdowithsudo.com%2F_astro%2Ffirewall-port-filtering.B9REdMXv_YQvuz.webp" title="Authorized traffic passing through the firewall wall while threats are blocked" alt="Firewall Port Filtering" width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Firewall advanced: rate limits and default deny
&lt;/h2&gt;

&lt;p&gt;UFW is simple, but underneath it's iptables/nftables. Add rate limiting to slow brute-force attempts:&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 limit 22/tcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you need full control, consider managing rules directly with &lt;code&gt;nftables&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For network-level floods like DDoS, the perimeter defense needs its own playbook. I walked through a real-world &lt;a href="https://dowithsudo.com/blog/ddos-mikrotik-case-study/" rel="noopener noreferrer"&gt;MikroTik DDoS case study&lt;/a&gt; where traffic separation was the key to keeping services up.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4: automating defense with fail2ban
&lt;/h2&gt;

&lt;p&gt;Even with keys and firewalls, bots will still hammer your server. &lt;strong&gt;Fail2ban&lt;/strong&gt; is the bouncer that kicks them out.&lt;/p&gt;

&lt;p&gt;It monitors your authentication logs and, after a few failed attempts, it automatically bans that IP address at the firewall level for a certain period. Simple, yet incredibly effective.&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A common configuration I use in &lt;code&gt;jail.local&lt;/code&gt; is banning an IP for 24 hours after 3 failed attempts. It makes your server a very unappealing target for automated scripts.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 5: kernel hardening with sysctl
&lt;/h2&gt;

&lt;p&gt;Some kernel parameters reduce network attack surface:&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;sysctl &lt;span class="nt"&gt;-w&lt;/span&gt; net.ipv4.icmp_echo_ignore_broadcasts&lt;span class="o"&gt;=&lt;/span&gt;1
&lt;span class="nb"&gt;sudo &lt;/span&gt;sysctl &lt;span class="nt"&gt;-w&lt;/span&gt; net.ipv4.conf.all.rp_filter&lt;span class="o"&gt;=&lt;/span&gt;1
&lt;span class="nb"&gt;sudo &lt;/span&gt;sysctl &lt;span class="nt"&gt;-w&lt;/span&gt; net.ipv4.conf.all.accept_source_route&lt;span class="o"&gt;=&lt;/span&gt;0
&lt;span class="nb"&gt;sudo &lt;/span&gt;sysctl &lt;span class="nt"&gt;-w&lt;/span&gt; net.ipv4.conf.all.accept_redirects&lt;span class="o"&gt;=&lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To persist settings, add them to &lt;code&gt;/etc/sysctl.d/99-hardening.conf&lt;/code&gt; and run:&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;sysctl &lt;span class="nt"&gt;--system&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 6: security through maintenance (patching)
&lt;/h2&gt;

&lt;p&gt;Security is not a "set it and forget it" task. New vulnerabilities are discovered daily. An unpatched server is a ticking time bomb.&lt;/p&gt;

&lt;p&gt;I highly recommend enabling &lt;strong&gt;Unattended Upgrades&lt;/strong&gt;. This allows your server to automatically download and install security patches the moment they're released.&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="nb"&gt;sudo &lt;/span&gt;dpkg-reconfigure &lt;span class="nt"&gt;-plow&lt;/span&gt; unattended-upgrades
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This simple setup ensures that even when you're on vacation, your server is protecting itself against newly discovered exploits.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 7: observability and log monitoring
&lt;/h2&gt;

&lt;p&gt;Security is as much about detection as it is about prevention. You need to know when someone is knocking.&lt;/p&gt;

&lt;p&gt;Take a peek at your auth logs every now and then:&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 tail&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /var/log/auth.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You'll be surprised at how active the "war" is on the internet. Additionally, a solid backup strategy is part of a security mindset. Backup isn't a preventative measure per se, but it's your only lifeline if your security layers fail or data is corrupted.&lt;/p&gt;

&lt;p&gt;Extra recommendations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Auditd&lt;/strong&gt; for system activity auditing:
&lt;/li&gt;
&lt;/ul&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;auditd
  &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; auditd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AIDE&lt;/strong&gt; for file integrity monitoring:
&lt;/li&gt;
&lt;/ul&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;aide
  &lt;span class="nb"&gt;sudo &lt;/span&gt;aideinit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Centralized logging&lt;/strong&gt; (ELK/Graylog/Cloud logging) so logs survive even if a server dies.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  My personal server hardening checklist
&lt;/h2&gt;

&lt;p&gt;Whenever I spin up a new node, I run through this checklist (or use my &lt;a href="https://dowithsudo.com/tools/hardening/" rel="noopener noreferrer"&gt;interactive hardening checklist tool&lt;/a&gt; to verify each item):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;[ ] Update all system packages.&lt;/li&gt;
&lt;li&gt;[ ] Create a non root sudo user.&lt;/li&gt;
&lt;li&gt;[ ] Set up SSH keys and disable password auth.&lt;/li&gt;
&lt;li&gt;[ ] Disable root login in SSH config.&lt;/li&gt;
&lt;li&gt;[ ] Configure firewall rules (allow 22, 80, 443).&lt;/li&gt;
&lt;li&gt;[ ] Install and configure Fail2ban.&lt;/li&gt;
&lt;li&gt;[ ] Apply baseline &lt;code&gt;sysctl&lt;/code&gt; hardening.&lt;/li&gt;
&lt;li&gt;[ ] Enable automatic security updates.&lt;/li&gt;
&lt;li&gt;[ ] Enable audit/log monitoring (auditd/central logging).&lt;/li&gt;
&lt;li&gt;[ ] Disable unused network services.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  What hardening does not solve
&lt;/h2&gt;

&lt;p&gt;There's a major trap to avoid: &lt;strong&gt;Hardening the server doesn't fix a broken application.&lt;/strong&gt; If you secure your Linux OS perfectly but your web application has an unsanitized input leading to &lt;em&gt;SQL Injection&lt;/em&gt;, or your app password is &lt;code&gt;admin123&lt;/code&gt;, an attacker can still pwn your system through the app layer.&lt;/p&gt;

&lt;p&gt;Hardening secures the "container." The content of that container (your code) is still your responsibility as a developer.&lt;/p&gt;

&lt;p&gt;And if an attacker does breach the app layer, your hardened server becomes a foothold for moving deeper into the network, exactly what I demonstrate in the &lt;a href="https://dowithsudo.com/blog/server-linux-pivot-attack/" rel="noopener noreferrer"&gt;Linux server pivot attack case study&lt;/a&gt;.&lt;/p&gt;

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

&lt;p&gt;Early in my career, I viewed these steps as tedious chores. Now, they are a mandatory ritual my price of admission to the internet. Spending an extra 15 minutes during deployment saves me days of headaches later.&lt;/p&gt;

&lt;p&gt;Security is a journey, not a destination. Stay curious, watch your logs, and never stop refining your defense layers.&lt;/p&gt;

&lt;p&gt;Hardening is the first step. Make sure you also understand how to troubleshoot production when issues arise, as I discuss in my &lt;a href="https://dowithsudo.com/blog/docker-container-crash-case-study/" rel="noopener noreferrer"&gt;Docker container crash case study&lt;/a&gt; and &lt;a href="https://dowithsudo.com/blog/lvm-error-case-study-pvs-lvs/" rel="noopener noreferrer"&gt;LVM storage failure case study&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Do you have a specific ritual when you set up a new server? Or a "war story" that changed how you view security? Let's hear about it in the comments below! Happy (and safe) hosting! &lt;/p&gt;




&lt;p&gt;I hope this guide on Linux server hardening helps you make better decisions in real-world situations.&lt;/p&gt;

</description>
      <category>security</category>
      <category>server</category>
      <category>linux</category>
      <category>hardening</category>
    </item>
    <item>
      <title>Learning Docker: A Beginner Guide</title>
      <dc:creator>Dowithsudo</dc:creator>
      <pubDate>Fri, 14 Aug 2026 18:35:22 +0000</pubDate>
      <link>https://dev.to/dowithsudo/learning-docker-a-beginner-guide-2n08</link>
      <guid>https://dev.to/dowithsudo/learning-docker-a-beginner-guide-2n08</guid>
      <description>&lt;p&gt;Have you ever experienced this classic scenario? You write code on your laptop, and everything runs perfectly. But the moment you hand it over to a colleague or deploy it to a server, everything crashes. And then comes the legendary excuse &lt;em&gt;"But it works on my machine!"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It's frustrating, right? The problem is often not your code, but the environment. Maybe the Node.js version is different, the database config doesn't match, or the OS is a different distro.&lt;/p&gt;

&lt;p&gt;Enter &lt;strong&gt;Docker&lt;/strong&gt;. It exists to solve this environment drama once and for all. If you are in IT, whether as a Sysadmin or a Developer, Docker is no longer just a "nice to have" skill. It's a necessity.&lt;/p&gt;

&lt;p&gt;So here is what Docker is, why it's everywhere, and how to use it, step by step, with a clear technical focus.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is Docker, really?
&lt;/h2&gt;

&lt;p&gt;Ideally: &lt;strong&gt;Docker is Tupperware for your applications.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Imagine you want to deliver food (your app) to a friend's house (the server). If you carry the food on an open plate while riding a motorcycle, it might spill or get dusty by the time you arrive.&lt;/p&gt;

&lt;p&gt;But if you seal that food in a Tupperware container, you can transport it by bike, car, or plane, and it will arrive safe, intact, and tasting exactly the same.&lt;/p&gt;

&lt;p&gt;In the tech world, Docker "wraps" your application along with everything it needs (libraries, configs, dependencies) into a neat package called a &lt;strong&gt;Container&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Containers vs. virtual machines (vm)
&lt;/h3&gt;

&lt;p&gt;You might be thinking, &lt;em&gt;"Wait, isn't that what VMs (like VirtualBox/VMware) do?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Similar goal, but very different approach.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Virtual Machine (VM)&lt;/strong&gt;: Heavy. Each VM requires a full OS installation (Guest OS). So if you have 3 apps, you need 3 full copies of Windows/Linux running. This eats up RAM and CPU.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker Container&lt;/strong&gt;: Lightweight. Containers share the host OS kernel. You don't need to install a new OS inside, you just pack the application binaries. Boot time is seconds, not minutes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdowithsudo.com%2F_astro%2Fdocker-vs-vm.BVnamkw1_Z2p0z1K.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdowithsudo.com%2F_astro%2Fdocker-vs-vm.BVnamkw1_Z2p0z1K.webp" title="Left: Heavy VM architecture. Right: Lightweight Container architecture." alt="VM vs Container Architecture" width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  How Docker works (short but technical)
&lt;/h2&gt;

&lt;p&gt;Docker relies on two core Linux features:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Namespaces&lt;/strong&gt;: isolate processes, network, filesystem, and users so containers feel like mini-VMs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cgroups&lt;/strong&gt;: control resource limits (CPU, RAM, I/O) so one container can't hog the host.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Docker images are built from &lt;strong&gt;layers&lt;/strong&gt;. Each Dockerfile instruction creates a new layer, which enables caching and faster rebuilds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key components&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Docker Engine: &lt;code&gt;dockerd&lt;/code&gt; daemon that manages images, networks, volumes.&lt;/li&gt;
&lt;li&gt;Container runtime: executes containers (OCI runtime like &lt;code&gt;runc&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Registry: stores images (&lt;code&gt;Docker Hub&lt;/code&gt;, private registry).&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Installing Docker
&lt;/h2&gt;

&lt;p&gt;Docker runs everywhere. Linux, Mac, Windows.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Linux (Ubuntu)&lt;/strong&gt;:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;  curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://get.docker.com &lt;span class="nt"&gt;-o&lt;/span&gt; get-docker.sh
  &lt;span class="nb"&gt;sudo &lt;/span&gt;sh get-docker.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Windows/Mac&lt;/strong&gt;: Download &lt;strong&gt;Docker Desktop&lt;/strong&gt; from the official website. It's a simple install wizard.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Verify installation:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;






&lt;h2&gt;
  
  
  Your first container
&lt;/h2&gt;

&lt;p&gt;Let's try running an Nginx web server. No need to install Nginx on your actual laptop, just summon it with Docker.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; 8080:80 nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deciphering the spell:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;docker run&lt;/code&gt;: The command to start a container.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-d&lt;/code&gt; (detached): Run in the background (don't lock up my terminal).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-p 8080:80&lt;/code&gt;: Port mapping. Port 8080 on your laptop connects to port 80 inside the container.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;nginx&lt;/code&gt;: The name of the image to use.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now open your browser and go to &lt;code&gt;http://localhost:8080&lt;/code&gt;.&lt;br&gt;
Boom! &lt;strong&gt;Welcome to nginx!&lt;/strong&gt;. You just ran a web server without polluting your operating system.&lt;/p&gt;


&lt;h2&gt;
  
  
  Critical concepts: image vs container
&lt;/h2&gt;

&lt;p&gt;Beginners often get these mixed up.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;Image&lt;/strong&gt;: This is the "blueprint" or raw file. It is &lt;em&gt;Read Only&lt;/em&gt;. (Think Windows Installer .iso)&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Container&lt;/strong&gt;: This is the actual running instance created from the Image. It is &lt;em&gt;Read Write&lt;/em&gt; (ephemeral). (Think A laptop with Windows installed).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Commands you must memorize:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Download an image: &lt;code&gt;docker pull ubuntu&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;List images you have: &lt;code&gt;docker images&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;List running containers: &lt;code&gt;docker ps&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Stop a container: &lt;code&gt;docker stop &amp;lt;CONTAINER_ID&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdowithsudo.com%2F_astro%2Fdocker-workflow.B4VPN0fP_1orwn8.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdowithsudo.com%2F_astro%2Fdocker-workflow.B4VPN0fP_1orwn8.webp" title="Flow from Dockerfile to Image to Container" alt="Docker Workflow Illustration" width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  Layers and cache: why dockerfile order matters
&lt;/h2&gt;

&lt;p&gt;Dockerfile is not just a recipe list. Order affects speed and rebuild time.&lt;/p&gt;

&lt;p&gt;Efficient pattern:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;copy &lt;code&gt;package.json&lt;/code&gt; first&lt;/li&gt;
&lt;li&gt;install dependencies&lt;/li&gt;
&lt;li&gt;then copy application code&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That way, when only code changes, Docker reuses cached dependency layers.&lt;/p&gt;

&lt;p&gt;Helpful commands:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;docker build --no-cache .&lt;/code&gt;: rebuild without cache.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker history &amp;lt;image&amp;gt;&lt;/code&gt;: inspect layer sizes.&lt;/li&gt;
&lt;/ul&gt;


&lt;h2&gt;
  
  
  Building a custom image (dockerfile)
&lt;/h2&gt;

&lt;p&gt;Using someone else's image (Nginx) is easy. But how do you package your own app? You use a file called &lt;code&gt;Dockerfile&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Suppose you have a simple Node.js app (&lt;code&gt;app.js&lt;/code&gt;). Create a file named &lt;code&gt;Dockerfile&lt;/code&gt; (no extension) in the same folder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Start from a base image (OS + Node.js)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; node:18-alpine&lt;/span&gt;

&lt;span class="c"&gt;# 2. Set working directory inside container&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;

&lt;span class="c"&gt;# 3. Copy your package.json first&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; package.json .&lt;/span&gt;

&lt;span class="c"&gt;# 4. Install dependencies&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt;

&lt;span class="c"&gt;# 5. Copy the rest of your app code&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;

&lt;span class="c"&gt;# 6. Tell Docker which port this app uses&lt;/span&gt;
&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 3000&lt;/span&gt;

&lt;span class="c"&gt;# 7. Command to run when container starts&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["node", "app.js"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now let's "cook" this recipe into an Image:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker build &lt;span class="nt"&gt;-t&lt;/span&gt; my-app:v1 &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;-t&lt;/code&gt;: Tag/Name the image (my-app version 1).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.&lt;/code&gt;: Location of the Dockerfile (dot means current directory).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Run your new creation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-p&lt;/span&gt; 3000:3000 my-app:v1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Data persistence (Docker volumes)
&lt;/h2&gt;

&lt;p&gt;By default, if you delete a container, &lt;strong&gt;all data inside it is gone forever&lt;/strong&gt;.&lt;br&gt;
Imagine running a MySQL database in a container. You fill it with data all day. Then you delete the container. When you run it again, the database is empty. Nightmare fuel.&lt;/p&gt;

&lt;p&gt;The solution: &lt;strong&gt;Volumes&lt;/strong&gt;.&lt;br&gt;
A Volume is like a USB drive plugged into the container. Even if the container is destroyed, the data in the volume stays safe on your laptop.&lt;/p&gt;

&lt;p&gt;Using a volume:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-v&lt;/span&gt; mysql_data:/var/lib/mysql mysql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now MySQL data will be stored in a volume named &lt;code&gt;mysql_data&lt;/code&gt; managed by Docker. Safe and sound.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bind mount vs volume: which one to use?
&lt;/h2&gt;

&lt;p&gt;Two ways to persist data:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Volume&lt;/strong&gt;: managed by Docker, stable, production-friendly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bind mount&lt;/strong&gt;: map a host folder into a container, great for development.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Example bind mount:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;pwd&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;:/app node:18-alpine
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rule of thumb:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Development: bind mount.&lt;/li&gt;
&lt;li&gt;Production: volume or external storage.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Networking: ports, bridges, and debugging connectivity
&lt;/h2&gt;

&lt;p&gt;By default, containers are attached to a &lt;strong&gt;bridge&lt;/strong&gt; network with internal IPs.&lt;/p&gt;

&lt;p&gt;Useful commands:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;docker network ls&lt;/code&gt;: list networks.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker network inspect &amp;lt;name&amp;gt;&lt;/code&gt;: see which containers are attached.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker port &amp;lt;container&amp;gt;&lt;/code&gt;: check port mappings.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Network modes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;bridge&lt;/code&gt;: default and safe.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;host&lt;/code&gt;: container uses host network (fast, less isolation).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;none&lt;/code&gt;: no networking at all.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Docker compose: orchestrating containers
&lt;/h2&gt;

&lt;p&gt;Modern apps rarely stand alone. You usually have a Web App + Database + Redis.&lt;br&gt;
Running them one by one with &lt;code&gt;docker run&lt;/code&gt; is tedious and hard to memorize.&lt;/p&gt;

&lt;p&gt;Meet &lt;strong&gt;Docker Compose&lt;/strong&gt;. We can define all containers in a single &lt;code&gt;docker-compose.yml&lt;/code&gt; file.&lt;/p&gt;

&lt;p&gt;Example &lt;code&gt;docker-compose.yml&lt;/code&gt; for WordPress + MySQL:&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;version&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.8"&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;db&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;mysql:5.7&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;db_data:/var/lib/mysql&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;MYSQL_ROOT_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;super_secret_password&lt;/span&gt;
      &lt;span class="na"&gt;MYSQL_DATABASE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;wordpress&lt;/span&gt;

  &lt;span class="na"&gt;wordpress&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;wordpress:latest&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8000:80"&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;WORDPRESS_DB_HOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;db:3306&lt;/span&gt;
      &lt;span class="na"&gt;WORDPRESS_DB_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;root&lt;/span&gt;
      &lt;span class="na"&gt;WORDPRESS_DB_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;super_secret_password&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;db&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;To run everything, you just need one command:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;Docker will automatically download images, set up a network so WordPress can talk to MySQL, and start everything up. Simple, elegant.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdowithsudo.com%2F_astro%2Fdocker-compose-architecture.DeTWe7cF_2jWT1J.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdowithsudo.com%2F_astro%2Fdocker-compose-architecture.DeTWe7cF_2jWT1J.webp" title="Orchestrating multiple containers" alt="Docker Compose Architecture" width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Debugging containers: a must-have habit
&lt;/h2&gt;

&lt;p&gt;Commands that save you in production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;docker logs &amp;lt;container&amp;gt;&lt;/code&gt;: view app logs.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker exec -it &amp;lt;container&amp;gt; sh&lt;/code&gt;: get a shell inside.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker inspect &amp;lt;container&amp;gt;&lt;/code&gt;: full config view.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker stats&lt;/code&gt;: realtime resource usage.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Check exit codes when a container dies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker ps &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s2"&gt;"table {{.Names}}&lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="s2"&gt;{{.Status}}&lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="s2"&gt;{{.ExitCode}}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Common beginner mistakes
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;Forgetting to Expose Ports&lt;/strong&gt;: Container running but can't access it? You probably forgot &lt;code&gt;-p 8080:80&lt;/code&gt;. Containers are isolated. You must "poke a hole" in the wall to let traffic in. Just be careful which holes you poke. For a safer way to expose services, see my comparison of &lt;a href="https://dowithsudo.com/blog/cloudflare-tunnel-vs-port-forwarding/" rel="noopener noreferrer"&gt;Cloudflare Tunnel vs port forwarding&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Mounting Code in Production&lt;/strong&gt;: In dev, we often bind mount code (&lt;code&gt;-v ./code:/app&lt;/code&gt;) so we don't have to rebuild on every edit. But for production, don't do this. Copy the code into the image (COPY) so it's immutable and consistent.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Layer Caching&lt;/strong&gt;: Order matters in Dockerfile. Always copy &lt;code&gt;package.json&lt;/code&gt; and run &lt;code&gt;npm install&lt;/code&gt; BEFORE copying the app code (&lt;code&gt;COPY . .&lt;/code&gt;). This way, if you only change code, Docker doesn't need to reinstall npm packages from scratch (it uses cache).&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Running as Root&lt;/strong&gt;: Create a non-root user in your Dockerfile (&lt;code&gt;USER node&lt;/code&gt; for example).&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;No Resource Limits&lt;/strong&gt;: In production, set &lt;code&gt;--memory&lt;/code&gt; and &lt;code&gt;--cpus&lt;/code&gt; so one container doesn't starve the host.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Want to see how these mistakes can cause chaos in production? Check out my &lt;a href="https://dowithsudo.com/blog/docker-container-crash-case-study/" rel="noopener noreferrer"&gt;real-world Docker troubleshooting case study&lt;/a&gt; based on an actual incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next?
&lt;/h2&gt;

&lt;p&gt;Congratulations! You understand Docker basics. From here, the DevOps world opens up. When you are ready for the full journey from your first container to production hardening, my &lt;a href="https://dowithsudo.com/blog/docker-complete-guide/" rel="noopener noreferrer"&gt;Docker complete guide&lt;/a&gt; is the natural next step after this tutorial.&lt;/p&gt;

&lt;p&gt;Your next steps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Deploy Docker to a VPS (DigitalOcean/AWS). I documented a full production deployment of a MERN application to DigitalOcean in the &lt;a href="https://dowithsudo.com/blog/mern-migration-digitalocean/" rel="noopener noreferrer"&gt;MERN migration case study&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;If the whole cloud/DevOps landscape feels overwhelming, my &lt;a href="https://dowithsudo.com/blog/cloud-learning-tips-beginner/" rel="noopener noreferrer"&gt;cloud learning tips for beginners&lt;/a&gt; shows how to build skills step by step without burning out.&lt;/li&gt;
&lt;li&gt;Learn &lt;strong&gt;CI/CD&lt;/strong&gt; (automate building images when you push to GitHub).&lt;/li&gt;
&lt;li&gt;If you have hundreds of containers, look into &lt;strong&gt;Kubernetes&lt;/strong&gt; (but save that for later, don't rush).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When you take Docker to production across both on-premise and cloud infrastructure, the deployment strategy changes. I walked through a real zero-downtime migration using containerized workloads in a hybrid cloud migration case study, including blue-green cutover.&lt;/p&gt;

&lt;p&gt;Remember, errors are your friends. If you get stuck, &lt;code&gt;docker logs &amp;lt;container_id&amp;gt;&lt;/code&gt; is your best buddy. Happy containerizing! &lt;/p&gt;




&lt;p&gt;I hope this guide on Docker basics helps you make better decisions in real-world situations.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>devops</category>
      <category>containers</category>
      <category>server</category>
    </item>
    <item>
      <title>Email Deliverability: SPF DKIM DMARC</title>
      <dc:creator>Dowithsudo</dc:creator>
      <pubDate>Fri, 14 Aug 2026 18:34:54 +0000</pubDate>
      <link>https://dev.to/dowithsudo/email-deliverability-spf-dkim-dmarc-2l9a</link>
      <guid>https://dev.to/dowithsudo/email-deliverability-spf-dkim-dmarc-2l9a</guid>
      <description>&lt;p&gt;My client called me with a frustration I had heard before but rarely this severe. &lt;em&gt;"Our invoices land in spam. All of them. Customers are missing payments, blaming us, and our finance team is drowning in manual follow-ups."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;They were on InMotion Hosting, a shared hosting plan. The client had already done the homework. SPF record was set. DKIM was enabled. DMARC was publishing a &lt;code&gt;p=quarantine&lt;/code&gt; policy. On paper, everything looked textbook. But every email sent to Gmail, Outlook, or Yahoo landed squarely in the spam folder.&lt;/p&gt;

&lt;p&gt;I asked the client to send a test email and forward me the full headers. This is where the story starts. Not at the DNS zone editor. Not at the hosting control panel. At the raw email headers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The moment I saw the headers
&lt;/h2&gt;

&lt;p&gt;The email headers told a story that no DNS checker could reveal.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Authentication-Results: mx.google.com;
       spf=pass (google.com: domain of billing@clientdomain.com designates 198.186.128.0/24 as permitted sender) smtp.mailfrom=clientdomain.com;
       dkim=pass header.i=@clientdomain.com;
       dmarc=pass (p=QUARANTINE sp=QUARANTINE dis=QUARANTINE) header.from=clientdomain.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All three checks passed. SPF pass. DKIM pass. DMARC pass. Yet Google still slapped a quarantine disposition. That should not happen. When all three pass, delivery is supposed to follow.&lt;/p&gt;

&lt;p&gt;Unless something deeper is at play.&lt;/p&gt;

&lt;p&gt;I compared the headers with known good email headers from a properly configured mail server. Four differences stood out. One of them was the smoking gun. But finding it required understanding three things most sysadmins never look at: shared IP reputation, SPF alignment vs header alignment, and Forward-Confirmed Reverse DNS.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three hidden culprits
&lt;/h2&gt;

&lt;p&gt;After hours of digging through headers, DNS records, and bounce logs, I traced the root cause to three distinct issues. No single one would have caused the problem alone. Together, they created a perfect storm against inbox delivery.&lt;/p&gt;

&lt;h3&gt;
  
  
  Shared IP reputation poisoning
&lt;/h3&gt;

&lt;p&gt;InMotion Hosting uses shared IP addresses for outgoing mail on standard shared plans. This means the same IP that sends my client's invoices also sends emails from hundreds of other tenants.&lt;/p&gt;

&lt;p&gt;Somewhere in that pool, one tenant runs a newsletter campaign with poor list hygiene. Another forwards spam. Another gets compromised and starts blasting phishing emails. This is the same class of problem I see in internal network attacks, where one compromised machine poisons the reputation of everything behind it. I covered a similar scenario in the &lt;a href="https://dowithsudo.com/blog/proxmox-vm-attack-hypervisor/" rel="noopener noreferrer"&gt;Proxmox VM attack case study&lt;/a&gt;, where a single compromised VM threatened the entire hypervisor.&lt;/p&gt;

&lt;p&gt;When enough abuse reports accumulate against that IP, Google and Microsoft add it to their reputation databases. My client's perfectly crafted emails then inherit that reputation by association. It does not matter how clean their domain is when the sending IP is flagged.&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;# Check IP reputation with DNSBL&lt;/span&gt;
dig +short 2.0.0.127.b.barracudacentral.org

&lt;span class="c"&gt;# Check against Spamhaus&lt;/span&gt;
dig +short 2.0.0.127.zen.spamhaus.org
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response for our IP came back positive. The shared IP was listed on two DNS blocklists.&lt;/p&gt;

&lt;h3&gt;
  
  
  SPF alignment versus header from alignment
&lt;/h3&gt;

&lt;p&gt;This one catches most people. SPF uses the &lt;code&gt;smtp.mailfrom&lt;/code&gt; address, also called the envelope sender or return-path. This is often a different address than the &lt;code&gt;From:&lt;/code&gt; header your recipient sees in their email client.&lt;/p&gt;

&lt;p&gt;When a hosting provider sends email, the envelope sender might be something like &lt;code&gt;bounce@inmotionhosting.com&lt;/code&gt; while the visible &lt;code&gt;From:&lt;/code&gt; header shows &lt;code&gt;billing@clientdomain.com&lt;/code&gt;. SPF checks the envelope sender. It does not check the visible from.&lt;/p&gt;

&lt;p&gt;Even with SPF=pass, the alignment can fail because &lt;code&gt;smtp.mailfrom&lt;/code&gt; domain and &lt;code&gt;header.from&lt;/code&gt; domain do not match. This mismatch means SPF alignment fails. Google and Outlook use alignment as a signal, not just the raw pass or fail.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Example of misaligned headers
smtp.mailfrom: bounce@inmotionhosting.com     ← SPF checks this
header.from: billing@clientdomain.com          ← User sees this
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These domains must match for full alignment. When they do not, the email loses credibility in the eyes of the receiving MTA.&lt;/p&gt;

&lt;h3&gt;
  
  
  Missing Forward-Confirmed Reverse DNS
&lt;/h3&gt;

&lt;p&gt;Forward-Confirmed Reverse DNS is a PTR record that maps the sending IP back to a hostname, and that hostname must resolve forward to the same IP.&lt;/p&gt;

&lt;p&gt;This is the most overlooked DNS record for email deliverability. Gmail and Outlook both check it. If a PTR record is missing or does not match, the receiving server flags the email as potentially forged. You can verify all of these records (SPF, DKIM, DMARC, PTR, and MX) for any domain with the &lt;a href="https://dowithsudo.com/tools/dns/" rel="noopener noreferrer"&gt;DNS health check tool&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;My client's sending IP had no PTR record at all.&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;# Check PTR record for sending IP&lt;/span&gt;
dig +short &lt;span class="nt"&gt;-x&lt;/span&gt; 198.186.128.xxx

&lt;span class="c"&gt;# Response: empty. No PTR record configured.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without a valid PTR that matches the HELO/EHLO hostname, deliverability drops significantly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The remediation roadmap
&lt;/h2&gt;

&lt;p&gt;Fixing this required changes at three layers: DNS, hosting plan, and email configuration. Each step had to be sequenced carefully to avoid downtime or bounced emails.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step one: dedicated IP and PTR record
&lt;/h3&gt;

&lt;p&gt;The first call was to InMotion Hosting support. I requested a dedicated IP address for outgoing mail. This immediately isolated my client from the shared IP reputation pool.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Request: Dedicated IP for SMTP outbound
Provider: InMotion Hosting
Timeline: Activated within 4 hours
IP: 198.186.128.xxx (dedicated, not shared)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once the dedicated IP was assigned, I submitted a reverse DNS request. The PTR record needed to match the hostname used in the HELO/EHLO handshake.&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;# PTR record requested&lt;/span&gt;
198.186.128.xxx → mail.clientdomain.com

&lt;span class="c"&gt;# Verification after propagation&lt;/span&gt;
dig +short &lt;span class="nt"&gt;-x&lt;/span&gt; 198.186.128.xxx
&lt;span class="c"&gt;# mail.clientdomain.com&lt;/span&gt;

dig +short mail.clientdomain.com
&lt;span class="c"&gt;# 198.186.128.xxx&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Forward and reverse matched. The first check passed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step two: IP warmup strategy
&lt;/h3&gt;

&lt;p&gt;A brand new dedicated IP has no reputation at all. A cold IP sending legitimate email volume is suspicious. A sudden flood triggers rate limiting and spam placement.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"An IP with no history is treated the same as an IP with bad history. The receiving server does not know which one you are yet."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I planned a two-week warmup schedule.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Week 1: Send only transactional emails (invoices, receipts), 50-100 per day
         Delay non-urgent marketing emails
Week 2: Gradually increase volume, 300-500 per day
         Monitor delivery in postmaster tools
Week 3+: Resume full volume, monitor reputation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I used Google Postmaster Tools and Microsoft SNDS to track reputation daily. The first week showed neutral reputation. By the end of week two, the dedicated IP reached green status on both platforms.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step three: header alignment fix
&lt;/h3&gt;

&lt;p&gt;SPF alignment required that &lt;code&gt;smtp.mailfrom&lt;/code&gt; and &lt;code&gt;header.from&lt;/code&gt; share the same domain. I updated the mail server configuration to use the client's own domain in the envelope sender.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Example PHP mailer configuration for alignment&lt;/span&gt;
&lt;span class="nv"&gt;$mail&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setFrom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'billing@clientdomain.com'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Client Billing'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$mail&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addReplyTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'support@clientdomain.com'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Client Support'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// Ensure return-path matches the domain&lt;/span&gt;
&lt;span class="nv"&gt;$mail&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nc"&gt;Sender&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'bounce@clientdomain.com'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And added a dedicated SPF record for the new sending IP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Updated SPF record
clientdomain.com TXT "v=spf1 include:mx.inmotionhosting.com ip4:198.186.128.xxx -all"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;-all&lt;/code&gt; hard fail at the end is critical. Many hosting providers default to &lt;code&gt;~all&lt;/code&gt; (soft fail), which tells receiving servers to treat unauthenticated email as suspicious but not reject it outright. Hard fail forces strict enforcement.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step four: DMARC reporting and monitoring
&lt;/h3&gt;

&lt;p&gt;DMARC was already set to &lt;code&gt;p=quarantine&lt;/code&gt;, which was part of the problem. With misalignment and low IP reputation, DMARC was dutifully enforcing quarantine. I switched it to &lt;code&gt;p=none&lt;/code&gt; temporarily during the warmup period, then moved to &lt;code&gt;p=quarantine&lt;/code&gt; once alignment was confirmed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Temporary monitoring mode
_dmarc.clientdomain.com TXT "v=DMARC1; p=none; rua=mailto:dmarc@clientdomain.com"

# After verification
_dmarc.clientdomain.com TXT "v=DMARC1; p=quarantine; sp=quarantine; rua=mailto:dmarc@clientdomain.com"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;DMARC aggregate reports came weekly. They confirmed SPF and DKIM pass rates above 99%, with alignment at 100% after the envelope sender fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  The results
&lt;/h2&gt;

&lt;p&gt;We measured deliverability before and after the changes. The improvement was immediate and sustained.&lt;/p&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;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Inbox placement rate (Gmail)&lt;/td&gt;
&lt;td&gt;12%&lt;/td&gt;
&lt;td&gt;98%&lt;/td&gt;
&lt;td&gt;+86pp&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Inbox placement rate (Outlook)&lt;/td&gt;
&lt;td&gt;8%&lt;/td&gt;
&lt;td&gt;95%&lt;/td&gt;
&lt;td&gt;+87pp&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spam complaint rate&lt;/td&gt;
&lt;td&gt;0.2%&lt;/td&gt;
&lt;td&gt;0.03%&lt;/td&gt;
&lt;td&gt;-85%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bounce rate&lt;/td&gt;
&lt;td&gt;4.5%&lt;/td&gt;
&lt;td&gt;0.8%&lt;/td&gt;
&lt;td&gt;-82%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IP reputation (Google)&lt;/td&gt;
&lt;td&gt;Neutral&lt;/td&gt;
&lt;td&gt;Green&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DNSBL listings&lt;/td&gt;
&lt;td&gt;2 listings&lt;/td&gt;
&lt;td&gt;0 listings&lt;/td&gt;
&lt;td&gt;Clean&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The client's finance team stopped getting calls about missing invoices within three days of the dedicated IP activation. By day five, all major providers delivered to inbox.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons for sysadmins
&lt;/h2&gt;

&lt;p&gt;This case reinforced three principles I now apply to every email deliverability engagement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SPF, DKIM, and DMARC are table stakes, not guarantees.&lt;/strong&gt; They are necessary but not sufficient. A shared IP with bad reputation bypasses all three. Always check the sending IP reputation before touching DNS records.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Headers do not lie. DNS checkers do.&lt;/strong&gt; Online SPF and DKIM checkers only verify record syntax. They do not check alignment, PTR, or SMTP handshake configuration. The true story is in the email headers. Learn to read them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A dedicated IP is not optional for transactional email.&lt;/strong&gt; Shared hosting IPs are a gamble. If you send invoices, receipts, or any customer-facing email with authentication requirements, a dedicated IP is not a premium feature. It is the baseline.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"The authentication passed. The email still failed. That is the moment you stop trusting the checkboxes and start reading the headers. The same principle applies to server security. You can configure a firewall, disable root login, and set up fail2ban. But if you never check the logs, you are trusting checkboxes. I wrote about this in the &lt;a href="https://dowithsudo.com/blog/linux-server-hardening-best-practices/" rel="noopener noreferrer"&gt;Linux server hardening guide&lt;/a&gt;. A configuration you never verify is a configuration that might not work."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you run into a similar issue, start with three commands: &lt;code&gt;dig&lt;/code&gt; for PTR, &lt;code&gt;dig&lt;/code&gt; for DNSBL, and a raw email header view. The answers are always in the protocol. You just have to look past the control panel.&lt;/p&gt;




&lt;p&gt;Six months later, the client's deliverability is still above 97%. They moved to a dedicated IP permanently. The invoices land in inboxes. The finance team stopped calling me. That is the quietest outcome and the best one.&lt;/p&gt;

&lt;p&gt;The hardest lesson in email deliverability is that doing everything right at the DNS level is only half the work. The other half is reputation, alignment, and infrastructure choices you cannot configure in a text record.&lt;/p&gt;

&lt;p&gt;Every sysadmin should have a mental checklist for email deliverability that goes beyond SPF, DKIM, and DMARC. Shared IP, PTR record, alignment, warmup schedule. Check them all before you call a configuration complete.&lt;/p&gt;

</description>
      <category>email</category>
      <category>dns</category>
      <category>security</category>
      <category>dmarc</category>
    </item>
    <item>
      <title>Docker Complete Guide: From Beginner to Production</title>
      <dc:creator>Dowithsudo</dc:creator>
      <pubDate>Fri, 14 Aug 2026 18:32:26 +0000</pubDate>
      <link>https://dev.to/dowithsudo/docker-complete-guide-from-beginner-to-production-5aee</link>
      <guid>https://dev.to/dowithsudo/docker-complete-guide-from-beginner-to-production-5aee</guid>
      <description>&lt;p&gt;I have walked into too many server rooms where the answer to every problem was "reinstall it." I have also watched teams burn entire weekends because an application ran on the developer's laptop and nowhere else. Docker ended most of those conversations for me, and it can do the same for you.&lt;/p&gt;

&lt;p&gt;This guide is the one I wish I had when I started. It covers the mental model first, then the commands, then the production habits that separate hobby containers from infrastructure you can sleep through the night with. It is long on purpose. Bookmark it and come back as you grow.&lt;/p&gt;

&lt;p&gt;If you prefer a shorter, gentler first read, start with my &lt;a href="https://dowithsudo.com/blog/learning-docker-from-scratch/" rel="noopener noreferrer"&gt;beginner Docker tutorial&lt;/a&gt; and return here when you are ready for the full picture.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Docker actually solves
&lt;/h2&gt;

&lt;p&gt;Docker packages an application with everything it needs to run, libraries, config files, environment variables, and the runtime, into a single immutable unit called a &lt;strong&gt;container image&lt;/strong&gt;. That image runs identically on a laptop, a test server, or a cloud VM. The "works on my machine" excuse dies the moment the same image produces the same behavior everywhere.&lt;/p&gt;

&lt;p&gt;Under the hood, Docker is not magic. It relies on two Linux kernel features:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Namespaces&lt;/strong&gt; isolate processes, networking, filesystems, users, and hostnames so each container believes it owns the machine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cgroups&lt;/strong&gt; limit and account for CPU, memory, and I/O so one noisy container cannot starve its neighbors.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A container shares the host kernel. That is why it boots in seconds and weighs megabytes, while a virtual machine boots an entire guest operating system and weighs gigabytes. The trade-off is isolation strength. A VM boundary is harder to cross than a container boundary, which matters for security decisions we will revisit later.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the engine is actually put together
&lt;/h2&gt;

&lt;p&gt;When you type &lt;code&gt;docker run&lt;/code&gt;, more than one piece of software is involved. Docker Engine is the client and daemon you interact with, but since Docker 1.11 the heavy lifting has been delegated to specialized components:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;containerd&lt;/strong&gt; manages the container lifecycle, image storage, and the container runtime interface.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;runc&lt;/strong&gt; is the OCI runtime that actually creates and starts the container processes using namespaces and cgroups.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This split matters because it is why Docker, containerd, and Kubernetes can coexist. Kubernetes removed the Docker daemon from its control plane years ago and talks to containerd directly. The images you build today still run there, because they follow the OCI image spec. Understanding that layers, images, and runtimes are standardized is what makes the whole ecosystem portable instead of proprietary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Image versus container
&lt;/h2&gt;

&lt;p&gt;This is the first mental model to lock in.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Image&lt;/strong&gt; is the immutable blueprint. Read-only layers stacked on top of each other. Think of an installer ISO.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Container&lt;/strong&gt; is a running instance of an image with a thin writable layer on top. Think of the machine you installed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdowithsudo.com%2F_astro%2Fimage-vs-container.B24dF3LZ_Z13AHFA.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdowithsudo.com%2F_astro%2Fimage-vs-container.B24dF3LZ_Z13AHFA.webp" title="An image is the read-only blueprint, a container is the running instance with a writable layer" alt="Image versus container comparison" width="800" height="447"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker pull ubuntu          &lt;span class="c"&gt;# download an image&lt;/span&gt;
docker images               &lt;span class="c"&gt;# list local images&lt;/span&gt;
docker run ubuntu &lt;span class="nb"&gt;echo &lt;/span&gt;hi   &lt;span class="c"&gt;# create + start a container from an image&lt;/span&gt;
docker ps                   &lt;span class="c"&gt;# list running containers&lt;/span&gt;
docker ps &lt;span class="nt"&gt;-a&lt;/span&gt;                &lt;span class="c"&gt;# list all containers, including stopped&lt;/span&gt;
docker stop &amp;lt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;            &lt;span class="c"&gt;# stop a running container&lt;/span&gt;
docker &lt;span class="nb"&gt;rm&lt;/span&gt; &amp;lt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;              &lt;span class="c"&gt;# delete a stopped container&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deleting a container removes its writable layer. Anything written inside it disappears unless it lives in a volume. We will get to volumes shortly, but remember this: containers are ephemeral by design.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Dockerfile: how images are built
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;Dockerfile&lt;/code&gt; is a recipe. Every instruction produces a layer, and layers are cached. That single fact drives most performance advice you will ever read about Dockerfiles.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; node:20-alpine&lt;/span&gt;

&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;

&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; package.json package-lock.json ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm ci

&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;

&lt;span class="k"&gt;RUN &lt;/span&gt;npm run build

&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="s"&gt; node&lt;/span&gt;

&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 3000&lt;/span&gt;

&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["node", "dist/server.js"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The order above is deliberate. Dependencies are installed before the application code is copied, so a code change reuses the cached dependency layers. If you copy everything first and install second, every code change invalidates the entire dependency cache and turns every build into a full rebuild.&lt;/p&gt;

&lt;h3&gt;
  
  
  Base image choices
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Alpine&lt;/strong&gt; is tiny and popular for final images, but its musl libc can break native binaries compiled against glibc. Test before you trust it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Distroless&lt;/strong&gt; images contain only the runtime and your binary, no shell, no package manager. Excellent for security, painful for debugging, since you cannot &lt;code&gt;exec&lt;/code&gt; into a shell easily.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Slim variants&lt;/strong&gt; (node:20-slim, python:3.12-slim) are a pragmatic middle ground.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Build with a context, and keep it small. A &lt;code&gt;.dockerignore&lt;/code&gt; file prevents your &lt;code&gt;node_modules&lt;/code&gt;, &lt;code&gt;.git&lt;/code&gt;, and build artifacts from being shipped into the build context.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# .dockerignore&lt;/span&gt;
node_modules
.git
dist
*.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Multi-stage builds
&lt;/h3&gt;

&lt;p&gt;Multi-stage builds let you compile in a fat image and copy only the artifacts into a slim final image.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;node:20-alpine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;builder&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; package.json package-lock.json ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm ci
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm run build

&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; nginx:alpine&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder /app/dist /usr/share/nginx/html&lt;/span&gt;
&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 80&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The final image contains only Nginx and your static files. The entire Node toolchain never ships.&lt;/p&gt;

&lt;h3&gt;
  
  
  Slimming images down
&lt;/h3&gt;

&lt;p&gt;Image size is not vanity. Smaller images pull faster, start faster, and expose a smaller attack surface. Beyond multi-stage builds, a few habits keep images lean:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Prefer slim bases.&lt;/strong&gt; &lt;code&gt;node:20-slim&lt;/code&gt; over &lt;code&gt;node:20&lt;/code&gt; saves hundreds of megabytes for a one-line change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compile in the builder stage, copy only the artifact.&lt;/strong&gt; Build tools never belong in the final image.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clean package caches in the same layer that creates them.&lt;/strong&gt; &lt;code&gt;RUN apt-get update &amp;amp;&amp;amp; apt-get install -y curl &amp;amp;&amp;amp; rm -rf /var/lib/apt/lists/*&lt;/code&gt; stops the layer from carrying dead weight.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build with BuildKit.&lt;/strong&gt; It is the default in modern Docker and enables &lt;code&gt;--mount=type=cache&lt;/code&gt;, so package managers reuse caches across builds instead of downloading the world every time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build multi-architecture images&lt;/strong&gt; with &lt;code&gt;docker buildx build --platform linux/amd64,linux/arm64&lt;/code&gt; so one tag works on x86 servers and ARM boxes alike.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A healthy habit: run &lt;code&gt;docker system df&lt;/code&gt; once a week and prune what you no longer use. Old images and dangling layers accumulate silently, and on a busy CI server disk fills faster than anyone expects.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running containers properly
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;docker run&lt;/code&gt; has flags you will use every day.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; web &lt;span class="nt"&gt;-p&lt;/span&gt; 8080:80 &lt;span class="nt"&gt;-v&lt;/span&gt; webdata:/data &lt;span class="nt"&gt;--restart&lt;/span&gt; unless-stopped nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;-d&lt;/code&gt; runs in the background.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--name&lt;/code&gt; gives the container a stable name.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-p host:container&lt;/code&gt; maps ports.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-v name:/path&lt;/code&gt; attaches a named volume.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--restart unless-stopped&lt;/code&gt; brings it back after reboots and crashes.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Resource limits are not optional
&lt;/h3&gt;

&lt;p&gt;An unlimited container on a shared host is an accident waiting to happen. A runaway process inside the container can exhaust host memory and trigger the OOM killer on other workloads. I wrote a full breakdown of exactly this failure mode in a Docker container crash case study, where a missing memory limit took down a production service at night.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; api &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--memory&lt;/span&gt; 512m &lt;span class="nt"&gt;--memory-swap&lt;/span&gt; 512m &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cpus&lt;/span&gt; 0.5 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; 3000:3000 &lt;span class="se"&gt;\&lt;/span&gt;
  my-api:latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set limits in Compose too, and set reservations so the scheduler knows the floor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Volumes: surviving container death
&lt;/h2&gt;

&lt;p&gt;Containers are ephemeral. Volumes are the escape hatch. A volume is storage managed by Docker that outlives any container.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker volume create dbdata
docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; dbdata:/var/lib/postgresql/data postgres:16
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Named volumes&lt;/strong&gt; are managed by Docker, stored in &lt;code&gt;/var/lib/docker/volumes&lt;/code&gt;, and are the production default for databases.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bind mounts&lt;/strong&gt; map a host directory into the container. Great for development because your editor writes directly into the container's view. Dangerous in production because host filesystem quirks leak into the runtime.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Databases in containers are a defensible choice for small and medium workloads, provided the data lives in a named volume and backups run against that volume, not against the container. If you manage storage at the OS level, the &lt;a href="https://dowithsudo.com/blog/lvm-error-case-study-pvs-lvs/" rel="noopener noreferrer"&gt;LVM recovery case study&lt;/a&gt; shows what happens when the underlying disk layer misbehaves.&lt;/p&gt;

&lt;h3&gt;
  
  
  Backing up and restoring volumes
&lt;/h3&gt;

&lt;p&gt;Backups run against the volume, not the container. The container is disposable, the data is not. The classic approach is a &lt;code&gt;tar&lt;/code&gt; pipeline while the service is stopped or quiescent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; dbdata:/data &lt;span class="nt"&gt;-v&lt;/span&gt; /backup:/backup alpine &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;tar &lt;/span&gt;czf /backup/dbdata-&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F&lt;span class="si"&gt;)&lt;/span&gt;.tar.gz &lt;span class="nt"&gt;-C&lt;/span&gt; /data &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To restore, reverse the pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; dbdata:/data &lt;span class="nt"&gt;-v&lt;/span&gt; /backup:/backup alpine &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;tar &lt;/span&gt;xzf /backup/dbdata-2026-08-17.tar.gz &lt;span class="nt"&gt;-C&lt;/span&gt; /data
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two cautions. First, &lt;code&gt;tar&lt;/code&gt; on a live database is at best a crash-consistent copy, so for Postgres and MySQL prefer their native dump tools, &lt;code&gt;pg_dump&lt;/code&gt; or &lt;code&gt;mysqldump&lt;/code&gt;, piped into the same pattern. Second, test the restore. A backup you have never restored is a rumor, not a backup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Networking
&lt;/h2&gt;

&lt;p&gt;By default, containers attach to a &lt;strong&gt;bridge&lt;/strong&gt; network and reach each other by name. Compose creates one network per project automatically.&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 &lt;span class="nb"&gt;ls
&lt;/span&gt;docker network inspect bridge
docker port web
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three network modes matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;bridge&lt;/strong&gt;: default. Isolated virtual network with port mapping to the host.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;host&lt;/strong&gt;: container shares the host network stack. Lower latency, but the container can bind any host port, which weakens isolation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;none&lt;/strong&gt;: no networking. Useful for security-focused sidecar processes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For inter-container communication, prefer service names over IP addresses. IPs change when containers restart.&lt;/p&gt;

&lt;h2&gt;
  
  
  Docker Compose: your everyday workflow
&lt;/h2&gt;

&lt;p&gt;Once an application has more than one moving part, web, database, cache, you stop typing &lt;code&gt;docker run&lt;/code&gt; repeatedly and start defining everything in &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 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;db&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;postgres:16&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${DB_PASSWORD}&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;dbdata:/var/lib/postgresql/data&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-SHELL"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pg_isready&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-U&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;postgres"&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;10s&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;5s&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;5&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;api&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres://postgres:${DB_PASSWORD}@db:5432/app&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;3000:3000"&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Three details here prevent real incidents.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Environment via variables&lt;/strong&gt;, not secrets in the file. Use an &lt;code&gt;.env&lt;/code&gt; file for local development and a secret manager in production.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Healthchecks&lt;/strong&gt; make &lt;code&gt;depends_on&lt;/code&gt; meaningful. Without &lt;code&gt;condition: service_healthy&lt;/code&gt;, the API starts before Postgres accepts connections and crashes during startup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restart policies&lt;/strong&gt; (&lt;code&gt;unless-stopped&lt;/code&gt;) keep services alive across host reboots.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The compose file in my &lt;a href="https://dowithsudo.com/blog/mern-migration-digitalocean/" rel="noopener noreferrer"&gt;MERN migration case study&lt;/a&gt; shows a full production stack, backend, frontend, managed database, and object storage, orchestrated with exactly these patterns.&lt;/p&gt;

&lt;h2&gt;
  
  
  CI/CD: build, scan, and ship on every push
&lt;/h2&gt;

&lt;p&gt;Manual &lt;code&gt;docker build&lt;/code&gt; on a server is a smell. The image you tested locally should be the image that runs in production, which means building once in a pipeline and promoting the same artifact. A minimal GitHub Actions workflow does the job:&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;build-image&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/setup-buildx-action@v3&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/login-action@v3&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;registry&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io&lt;/span&gt;
          &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.actor }}&lt;/span&gt;
          &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GITHUB_TOKEN }}&lt;/span&gt;
      &lt;span class="c1"&gt;# Build locally first, no push yet&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/build-push-action@v6&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;load&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;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-api:scan&lt;/span&gt;
      &lt;span class="c1"&gt;# Scan the local image; exit-code 1 blocks the pipeline on HIGH/CRITICAL&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aquasecurity/trivy-action@master&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;image-ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-api:scan&lt;/span&gt;
          &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HIGH,CRITICAL&lt;/span&gt;
          &lt;span class="na"&gt;exit-code&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"&lt;/span&gt;
      &lt;span class="c1"&gt;# Only now push to the registry&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/build-push-action@v6&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;push&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;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/${{ github.repository }}:latest&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The scan step runs before the push, so a broken image never reaches the registry. Whether you use GitHub Actions, GitLab CI, or a self-hosted runner, the pattern is the same: build once, scan, tag, push, and let the deployment pull that exact digest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production hardening checklist
&lt;/h2&gt;

&lt;p&gt;Move a containerized application to production and these become non-negotiable:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdowithsudo.com%2F_astro%2Fproduction-checklist.DDCHiXWX_VwFnS.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdowithsudo.com%2F_astro%2Fproduction-checklist.DDCHiXWX_VwFnS.webp" title="The essential production hardening checks for containerized applications" alt="Production hardening checklist for Docker" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Healthchecks
&lt;/h3&gt;

&lt;p&gt;Every service that serves traffic should expose a health endpoint, and Docker should probe it.&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;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:3000/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;40s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;start_period&lt;/code&gt; matters. It gives slow-starting applications time to warm up before failures count.&lt;/p&gt;

&lt;h3&gt;
  
  
  Log rotation
&lt;/h3&gt;

&lt;p&gt;By default, Docker writes container logs to JSON files without limits. A chatty application can fill the disk. The docker-crash case study above is exactly what happens when logs grow unchecked.&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;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="s2"&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;h3&gt;
  
  
  Non-root user
&lt;/h3&gt;

&lt;p&gt;Running as root inside a container is a common hardening gap. Add a dedicated user in your image.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;RUN &lt;/span&gt;addgroup &lt;span class="nt"&gt;-S&lt;/span&gt; app &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; adduser &lt;span class="nt"&gt;-S&lt;/span&gt; app &lt;span class="nt"&gt;-G&lt;/span&gt; app
&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="s"&gt; app&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Read-only filesystem
&lt;/h3&gt;

&lt;p&gt;Mark the filesystem read-only when the application does not need to write.&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;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Scan images
&lt;/h3&gt;

&lt;p&gt;Run vulnerability scanners on your images before deployment. Trivy and Grype are both free and can run in CI.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;trivy image my-api:latest &lt;span class="nt"&gt;--severity&lt;/span&gt; HIGH,CRITICAL
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the broader security picture, the &lt;a href="https://dowithsudo.com/blog/linux-server-hardening-best-practices/" rel="noopener noreferrer"&gt;Linux server hardening best practices&lt;/a&gt; guide covers the host side, SSH, firewalls, and user management, which protects the Docker host itself.&lt;/p&gt;

&lt;h3&gt;
  
  
  Monitoring containers in production
&lt;/h3&gt;

&lt;p&gt;Healthchecks tell you a service is alive, not that it is well. For the second part you need metrics. Start with the built-in tools, then graduate to a stack when the fleet grows.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;docker stats&lt;/code&gt;&lt;/strong&gt; gives instant CPU and memory per container. It is the first thing to run when something feels slow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;docker events&lt;/code&gt;&lt;/strong&gt; streams lifecycle events, container started, stopped, killed. Invaluable when an automation script restarts things behind your back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;cAdvisor&lt;/strong&gt; exposes per-container metrics in Prometheus format and is a ten-minute addition to any Compose stack.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prometheus plus Grafana&lt;/strong&gt; is the standard pairing once you want history, alerts, and dashboards. Set alerts on restart counts and memory pressure, not just uptime.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The principle is simple: you cannot fix what you cannot see, and you cannot see what you never measured. Install the dashboard before the incident, not after.&lt;/p&gt;

&lt;h2&gt;
  
  
  Debugging containers under pressure
&lt;/h2&gt;

&lt;p&gt;When something breaks at 2 AM, these are the commands you reach for, in order.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker ps &lt;span class="nt"&gt;-a&lt;/span&gt;                    &lt;span class="c"&gt;# what is actually running?&lt;/span&gt;
docker logs &lt;span class="nt"&gt;--tail&lt;/span&gt; 200 api      &lt;span class="c"&gt;# what did the app say before dying?&lt;/span&gt;
docker inspect api              &lt;span class="c"&gt;# what does Docker think the state is?&lt;/span&gt;
docker stats                    &lt;span class="c"&gt;# who is eating resources?&lt;/span&gt;
&lt;span class="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt;                           &lt;span class="c"&gt;# is the disk full?&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exit code is your first clue. Exit &lt;code&gt;137&lt;/code&gt; usually means the kernel OOM killer terminated the container. Exit &lt;code&gt;1&lt;/code&gt; means the application failed on its own. The full diagnostic workflow, including reading kernel OOM logs, is documented step by step in the &lt;a href="https://dowithsudo.com/blog/docker-container-crash-case-study/" rel="noopener noreferrer"&gt;Docker container crash case study&lt;/a&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker inspect api &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{.State.Status}} OOMKilled={{.State.OOMKilled}} Exit={{.State.ExitCode}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Updating containers without downtime
&lt;/h2&gt;

&lt;p&gt;Updating a container is not a mystery. Pull the new image, recreate the container, and verify. With Compose:&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 pull
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
docker compose ps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a single container, &lt;code&gt;docker pull&lt;/code&gt; followed by &lt;code&gt;docker rm -f&lt;/code&gt; and &lt;code&gt;docker run&lt;/code&gt; with the same flags does the same job, or &lt;code&gt;docker compose up -d&lt;/code&gt; handles the recreation for you.&lt;/p&gt;

&lt;p&gt;Three habits keep updates boring:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Always pull explicitly.&lt;/strong&gt; &lt;code&gt;docker run&lt;/code&gt; with &lt;code&gt;:latest&lt;/code&gt; uses the cached image unless you pull first. A server that has not been updated in weeks runs a very old image while everyone assumes it is current.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin versions in production.&lt;/strong&gt; &lt;code&gt;:latest&lt;/code&gt; is a moving target. Use a specific tag or digest in your deployment so a rebuild does not silently change behavior.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Have a rollback.&lt;/strong&gt; Keep the previous tag and know the one command to return to it. The five minutes spent writing down the rollback procedure will save you an hour at 3 AM.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Complete walkthrough: from a folder to production
&lt;/h2&gt;

&lt;p&gt;Theory is cheap, so here is the shortest path from a bare repository to a running service, using everything above.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Write the Dockerfile&lt;/strong&gt; with the dependency-first order and a non-root user.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add a &lt;code&gt;.dockerignore&lt;/code&gt;&lt;/strong&gt; so the build context stays small.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Define the stack in Compose&lt;/strong&gt; with a healthcheck, log limits, memory limits, and a named volume for anything persistent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test locally&lt;/strong&gt;: &lt;code&gt;docker compose up -d&lt;/code&gt;, hit the service, break it, watch the logs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build, scan, and push&lt;/strong&gt; from CI so the artifact is reproducible and vetted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pull and run on the server&lt;/strong&gt; with the same Compose file, or wire it into your orchestration of choice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify with a health check&lt;/strong&gt; from outside the host, then walk away.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every step in this list appears in this guide, and every failure mode, missing healthcheck, unbounded logs, no memory limit, root user, shows up in the case studies linked above. Build the habit on a staging service first. When the process bores you, you are ready for production.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Docker is not the answer
&lt;/h2&gt;

&lt;p&gt;Docker is a tool, not a religion. Some workloads do not belong in containers.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GUI-heavy desktop applications&lt;/strong&gt; gain little from containerization.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stateful high-IO databases&lt;/strong&gt; at large scale often perform better with dedicated instances and specialized storage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real-time kernels or specialized hardware drivers&lt;/strong&gt; can fight container isolation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Windows-only legacy applications&lt;/strong&gt; need a Windows container host, which changes the whole cost equation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For everything else, containers are the default answer. If you are still deciding whether containers or plain VMs fit a workload, think in terms of team velocity and isolation requirements, not fashion.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to go next in the Docker universe
&lt;/h2&gt;

&lt;p&gt;Docker is the foundation. The ecosystem above it is where most of the career value lives.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Orchestration&lt;/strong&gt;: when you have many containers across multiple hosts, you need a scheduler. Kubernetes is the industry standard, but start with Docker Swarm for learning the concepts on a smaller scale. Do not jump to Kubernetes until single-host Compose feels boring. If you are weighing the jump, my &lt;a href="https://dowithsudo.com/blog/docker-vs-kubernetes/" rel="noopener noreferrer"&gt;Docker vs Kubernetes&lt;/a&gt; comparison gives the honest criteria I use with clients.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CI/CD&lt;/strong&gt;: build and scan images automatically on every push. GitHub Actions and GitLab CI both have first-class Docker support.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Registry&lt;/strong&gt;: push images to Docker Hub or a private registry so deployments pull exactly the tested artifact.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secrets&lt;/strong&gt;: never bake secrets into images. Use Docker secrets, a vault, or your cloud provider's secret store.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you are planning the learning path, my guide on &lt;a href="https://dowithsudo.com/blog/how-long-does-it-take-to-learn-docker/" rel="noopener noreferrer"&gt;how long it takes to learn Docker&lt;/a&gt; gives a realistic timeline, and my &lt;a href="https://dowithsudo.com/blog/build-docker-container-from-scratch/" rel="noopener noreferrer"&gt;building a Docker container from scratch&lt;/a&gt; guide shows how to assemble an image layer by layer without a base OS.&lt;/p&gt;

&lt;p&gt;For automation beyond containers, &lt;a href="https://dowithsudo.com/blog/learning-ansible-from-scratch/" rel="noopener noreferrer"&gt;Ansible&lt;/a&gt; handles configuration management on the hosts themselves, and &lt;a href="https://dowithsudo.com/blog/learning-terraform-from-scratch/" rel="noopener noreferrer"&gt;Terraform&lt;/a&gt; provisions the infrastructure those hosts run on. Containerize the app, automate the host, provision the cloud, and you have the complete modern stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is Docker a virtual machine?&lt;/strong&gt; No. Containers share the host kernel and isolate processes, while VMs run a full guest operating system. That is why containers are lighter and faster to start, and why they are weaker at the kernel boundary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need Docker Desktop to run containers?&lt;/strong&gt; On Windows and macOS, Docker Desktop is the easiest path because it runs a Linux VM behind the scenes. On Linux, install the Docker Engine directly, no desktop layer required.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Are containers safe to run untrusted code?&lt;/strong&gt; Not by default. Treat a container as a lightweight process with a few extra walls, not as a sandbox. For untrusted code, use a dedicated VM or a gVisor-style runtime.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is the difference between Docker and Kubernetes?&lt;/strong&gt; Docker runs containers on a single host. Kubernetes schedules containers across many hosts and handles networking, storage, and self-healing. Learn Compose on one server before you touch Kubernetes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need Docker if I use serverless?&lt;/strong&gt; Different problems. Serverless hides the server from you, Docker gives you full control over the server. Most real deployments use both at different layers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final words
&lt;/h2&gt;

&lt;p&gt;Docker rewards boring discipline. Healthchecks, log limits, resource limits, non-root users, read-only filesystems, image scanning. None of these are glamorous, and all of them are what separates a demo from a service.&lt;/p&gt;

&lt;p&gt;Start small. Containerize one unimportant service first, run it with Compose, break it, and fix it. When that becomes routine, the rest of the ecosystem stops looking intimidating.&lt;/p&gt;

&lt;p&gt;I have been running containers in production for years. The containers themselves rarely cause incidents. The missing healthcheck, the unbounded log, the root user, the absent memory limit, those cause incidents. This guide exists so yours do not.&lt;/p&gt;




&lt;p&gt;I hope this complete Docker guide helps you make better decisions in real-world situations.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>containers</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>helo everyone, im the new guy here</title>
      <dc:creator>Dowithsudo</dc:creator>
      <pubDate>Fri, 14 Aug 2026 18:11:55 +0000</pubDate>
      <link>https://dev.to/dowithsudo/helo-everyone-im-the-new-guy-here-3hk0</link>
      <guid>https://dev.to/dowithsudo/helo-everyone-im-the-new-guy-here-3hk0</guid>
      <description></description>
    </item>
  </channel>
</rss>
