<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>System Administration | John Nessime</title>
	<atom:link href="https://john-nessime.com/blog/tag/system-administration/feed/" rel="self" type="application/rss+xml" />
	<link>https://john-nessime.com/blog/tag/system-administration/</link>
	<description>Cloud, DevOps, Data &#38; AI — Built, Tested, Explained</description>
	<lastBuildDate>Sun, 20 Sep 2026 16:38:56 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.1.4</generator>

<image>
	<url>https://john-nessime.com/blog/wp-content/uploads/2026/07/cropped-jn-32x32.png</url>
	<title>System Administration | John Nessime</title>
	<link>https://john-nessime.com/blog/tag/system-administration/</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>You Probably Didn&#8217;t Harden That Server: A VPS Setup and Hardening Checklist That Verifies Itself</title>
		<link>https://john-nessime.com/blog/system-administration/vps-hardening-checklist/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Sat, 01 Aug 2026 09:24:24 +0000</pubDate>
				<category><![CDATA[Linux]]></category>
		<category><![CDATA[System Administration]]></category>
		<category><![CDATA[Web Security]]></category>
		<category><![CDATA[Bash]]></category>
		<category><![CDATA[Fail2ban]]></category>
		<category><![CDATA[Firewall]]></category>
		<category><![CDATA[Infrastructure]]></category>
		<category><![CDATA[OpenSSH]]></category>
		<category><![CDATA[Production]]></category>
		<category><![CDATA[Self Hosting]]></category>
		<category><![CDATA[Server Hardening]]></category>
		<category><![CDATA[SSH]]></category>
		<category><![CDATA[Sysadmin]]></category>
		<category><![CDATA[Systemd]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[UFW]]></category>
		<category><![CDATA[VPS]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=62</guid>

					<description><![CDATA[<p>You set PasswordAuthentication no, restarted SSH, and moved on. Months later the logs show successful password logins, because a vendor drop-in file sorted earlier and won. A VPS setup and hardening checklist where every step comes with the command that proves it worked.</p>
<p>The post <a href="https://john-nessime.com/blog/system-administration/vps-hardening-checklist/">You Probably Didn&#8217;t Harden That Server: A VPS Setup and Hardening Checklist That Verifies Itself</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">You spun up a new box, worked through a hardening guide, set <code>PasswordAuthentication no</code>, restarted SSH, and moved on. Six months later you are reading auth logs for an unrelated reason and there it is: successful password logins. Not attempts. Logins.</p>



<p class="wp-block-paragraph">The line you wrote was real. The file was saved. What you did not know is that your distro puts an <code>Include /etc/ssh/sshd_config.d/*.conf</code> near the top of <code>sshd_config</code>, that sshd uses the <em>first</em> value it finds for any given keyword, and that your cloud provider&#8217;s image dropped a <code>50-cloud-init.conf</code> in that directory setting <code>PasswordAuthentication yes</code>. It sorts earlier. It wins. Your line at the bottom of the main file was never reached.</p>



<p class="wp-block-paragraph">You never noticed because you log in with a key anyway. Nothing broke. The control just was not there.</p>



<p class="wp-block-paragraph">That is the thing most guides get wrong, and it is the idea this <strong>VPS hardening checklist</strong> is built around: a hardening step is not done when you have edited the file. It is done when you have checked the effective configuration and seen the value you expected. Everything below comes with the command that proves it.</p>



<p class="wp-block-paragraph">This covers the order to do things in so you do not lock yourself out, SSH keys and config, the firewall, automatic updates, fail2ban and whether it is worth it, the unglamorous things that matter more than any of it, and what I would skip.</p>



<h2 class="wp-block-heading">Before you touch anything</h2>



<p class="wp-block-paragraph">Two minutes of preparation that saves an evening.</p>



<p class="wp-block-paragraph"><strong>Find your provider&#8217;s console before you need it.</strong> Whether you are on InterServer, Hetzner, DigitalOcean or anything else, there is a web-based serial or VNC console that works when SSH does not. Open it now, log in as root, and confirm it works. This is your parachute. If you are going to lock yourself out, you want to already know where it is.</p>



<p class="wp-block-paragraph"><strong>Never close the session you are working in.</strong> Every SSH change gets tested from a second terminal while the first one stays open. If the new settings are broken, the old session is still authenticated and you can undo them. Close it too early and your only route back in is the console.</p>



<p class="wp-block-paragraph">Then update the system, because everything else assumes current packages:</p>



<pre class="wp-block-code"><code>sudo apt update &amp;&amp; sudo apt upgrade -y
sudo reboot   # if a kernel update landed</code></pre>



<h2 class="wp-block-heading">1. A real user, and keys instead of passwords</h2>



<p class="wp-block-paragraph">Generate the key on your own machine, not on the server. A private key that has been on a server is not a private key any more.</p>



<pre class="wp-block-code"><code># On your laptop. ed25519 is the sane default: short keys,
# fast verification, no parameter choices to get wrong.
ssh-keygen -t ed25519 -C "deploy@my-laptop"</code></pre>



<pre class="wp-block-code"><code># On the server, as root.
adduser deploy
usermod -aG sudo deploy        # 'wheel' instead of 'sudo' on RHEL-family

# Back on your laptop. ssh-copy-id appends to authorized_keys
# and sets the permissions correctly, which is the part people
# get wrong when they paste the key by hand.
ssh-copy-id deploy@203.0.113.10</code></pre>



<p class="wp-block-paragraph">Now open a second terminal and confirm <code>ssh deploy@203.0.113.10</code> works, and that <code>sudo -v</code> works once you are in. Only then move on. If either fails, fix it before you disable anything, because in a minute you will be removing the fallback.</p>



<h2 class="wp-block-heading">2. SSH config, and why your edit might do nothing</h2>



<p class="wp-block-paragraph">This is the section that actually matters. Two mechanisms silently override what you write.</p>



<h3 class="wp-block-heading">Drop-in files and first-match-wins</h3>



<p class="wp-block-paragraph">Modern OpenSSH packages ship an <code>Include</code> line near the top of <code>/etc/ssh/sshd_config</code>, which pulls in every <code>.conf</code> file from <code>/etc/ssh/sshd_config.d/</code> in alphabetical order, before the rest of the main file is read. Combine that with sshd&#8217;s rule that the first value found for a keyword wins, and the consequence is specific: <strong>your drop-in has to sort earlier than the vendor&#8217;s, not later.</strong></p>



<p class="wp-block-paragraph">Most guides tell you to name it <code>99-something.conf</code>, which is the convention almost everywhere else in Linux and exactly backwards here.</p>



<pre class="wp-block-code"><code># /etc/ssh/sshd_config.d/00-hardening.conf
# Sorts before 50-cloud-init.conf, so these values are seen first.

PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes

# Optional: restrict to the accounts that should have shell access.
AllowUsers deploy</code></pre>



<p class="wp-block-paragraph">A note on <code>KbdInteractiveAuthentication</code>: older guides and older OpenSSH call this <code>ChallengeResponseAuthentication</code>. Setting only <code>PasswordAuthentication no</code> can still leave a keyboard-interactive path open on some configurations, which is why both are here.</p>



<p class="wp-block-paragraph">Now the part nobody does. Validate the syntax, then read the effective config:</p>



<pre class="wp-block-code"><code># Syntax check. Prints nothing if the config is valid.
# Run this BEFORE restarting, always.
sudo sshd -t

# Dump the effective configuration with every include resolved.
# This is the only output that tells you the truth.
sudo sshd -T | grep -Ei 'permitrootlogin|passwordauthentication|kbdinteractive|pubkeyauth'</code></pre>



<p class="wp-block-paragraph">If <code>passwordauthentication yes</code> comes back, your file lost to something else in that directory. Look at what is in there with <code>ls /etc/ssh/sshd_config.d/</code> and either rename yours to sort earlier or edit the offending file directly.</p>



<h3 class="wp-block-heading">Socket activation, and the port that will not change</h3>



<p class="wp-block-paragraph">The second trap. On several current distro releases, SSH is started by systemd socket activation rather than as a persistent service: <code>ssh.socket</code> holds the listening port and starts <code>sshd</code> only when a connection arrives. On those systems the <code>Port</code> and <code>ListenAddress</code> directives in <code>sshd_config</code> may be ignored entirely, because systemd owns the socket.</p>



<p class="wp-block-paragraph">People change the port, restart the service, see no error, and assume it worked. Check what is actually bound:</p>



<pre class="wp-block-code"><code># -t TCP, -l listening, -n numeric, -p show the owning process
sudo ss -tlnp</code></pre>



<p class="wp-block-paragraph">If you genuinely want a different port on a socket-activated system, override the socket unit with a proper drop-in rather than editing files under <code>/lib/systemd/system/</code>, which a package update will overwrite:</p>



<pre class="wp-block-code"><code>sudo systemctl edit ssh.socket</code></pre>



<pre class="wp-block-code"><code># In the editor that opens:
[Socket]
# The empty assignment clears the inherited value. Without it
# you end up listening on both the old port and the new one.
ListenStream=
ListenStream=2222</code></pre>



<pre class="wp-block-code"><code>sudo systemctl daemon-reload
sudo systemctl restart ssh.socket
sudo ss -tlnp        # confirm before you trust it</code></pre>



<p class="wp-block-paragraph">Honest assessment of port changes: this is not security. Anyone scanning your address finds an SSH daemon on a non-standard port in seconds. What it genuinely does is cut the volume of automated login noise in your logs by a large margin, which makes real events easier to see. That is a maintenance benefit, not a defence, and it is worth doing for that reason alone. Just do not count it as one of your controls.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">3. Firewall, in the right order</h2>



<p class="wp-block-paragraph">The ordering here is the whole trick. Set the default policy and add your rules <em>before</em> you enable the firewall. Enable first and you will drop your own connection with the policy that denies everything.</p>



<pre class="wp-block-code"><code>sudo ufw default deny incoming
sudo ufw default allow outgoing

# Allow SSH BEFORE enabling. If you changed the port,
# allow that port instead of the OpenSSH profile.
sudo ufw allow OpenSSH
# sudo ufw allow 2222/tcp

sudo ufw allow 80,443/tcp

sudo ufw enable
sudo ufw status verbose</code></pre>



<p class="wp-block-paragraph">Read that <code>status verbose</code> output rather than assuming. It shows the default policies and every rule, and it is the proof that this step is done.</p>



<p class="wp-block-paragraph">One thing worth knowing if you run containers: Docker writes its own rules directly into netfilter and bypasses ufw. A container published with <code>-p 5432:5432</code> is reachable from the internet even though ufw says that port is denied. Bind container ports to <code>127.0.0.1</code> explicitly, or put the service behind a reverse proxy, and verify from outside the box with a port scan rather than trusting the firewall status.</p>



<p class="wp-block-paragraph">A stronger option worth considering: do not expose SSH to the internet at all. Put the server on a private overlay network with something like Tailscale or a WireGuard tunnel, bind sshd to that interface, and close port 22 to the world entirely. More setup, and it removes an entire class of problem rather than filtering it.</p>



<h2 class="wp-block-heading">4. Automatic security updates</h2>



<p class="wp-block-paragraph">Unpatched packages are a far more realistic route into your server than an SSH brute force. This is the highest-value item on the list and it takes two commands.</p>



<pre class="wp-block-code"><code>sudo apt install unattended-upgrades
sudo dpkg-reconfigure --priority=low unattended-upgrades

# Verify. Note the binary is singular, the package is plural.
# --dry-run makes no changes.
sudo unattended-upgrade --dry-run --debug</code></pre>



<p class="wp-block-paragraph">By default this applies security updates only, which is the right scope. Enabling automatic reboots is a real trade-off: it closes kernel vulnerabilities without you doing anything, and it will restart your server at whatever hour you configure. On a single web server with a proper service startup order, I turn it on. On anything holding state that does not recover cleanly from a hard restart, I leave it off and patch by hand.</p>



<h2 class="wp-block-heading">5. fail2ban, and what it is actually for</h2>



<p class="wp-block-paragraph">Install it, but be clear about the role. Once password authentication is off and root login is disabled, a brute force against sshd cannot succeed. There is no password to guess. fail2ban is not what is keeping those attackers out; your key-only config is.</p>



<p class="wp-block-paragraph">What it does earn its place for is reducing log noise and CPU spent on connection handling, and protecting the services where credentials <em>do</em> exist: a mail server, a web application login form, an FTP daemon.</p>



<pre class="wp-block-code"><code>sudo apt install fail2ban

# Do not edit jail.conf; it gets replaced on upgrade.
sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local

sudo systemctl enable --now fail2ban
sudo systemctl status fail2ban
sudo fail2ban-client status sshd</code></pre>



<p class="wp-block-paragraph">Check that <code>status</code> output properly. fail2ban has shipped in states where the service fails to start on a given distro release because the packaged version does not match the system Python, and it fails loudly in the journal and silently everywhere else. A stopped fail2ban looks exactly like a working one from the outside. If it is not running, <code>journalctl -u fail2ban</code> will tell you why.</p>



<p class="wp-block-paragraph">Set <code>ignoreip</code> in <code>jail.local</code> to include your office or home address. Banning yourself is a rite of passage but it is avoidable.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">6. The boring items that matter more than the exciting ones</h2>



<p class="wp-block-paragraph">Nobody writes blog posts about these, and they cause more real outages than any of the above.</p>



<h3 class="wp-block-heading">Provider account security</h3>



<p class="wp-block-paragraph">Turn on two-factor authentication in your hosting control panel and your domain registrar. Someone with your provider login does not need to defeat sshd; they can reset the root password from the console or attach your disk to a machine they control. This is the weakest link on most setups and it takes two minutes.</p>



<h3 class="wp-block-heading">Time and timezone</h3>



<pre class="wp-block-code"><code>sudo timedatectl set-timezone UTC
timedatectl status       # look for "System clock synchronized: yes"</code></pre>



<p class="wp-block-paragraph">UTC on servers, always. Correlating logs across machines in different local times, one of which observes daylight saving, is a genuinely miserable way to spend an incident.</p>



<h3 class="wp-block-heading">Swap on small instances</h3>



<pre class="wp-block-code"><code># A little swap stops the OOM killer choosing your database
# during a traffic spike. It is not a substitute for RAM.
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
free -h</code></pre>



<h3 class="wp-block-heading">Backups that leave the machine</h3>



<p class="wp-block-paragraph">Your provider&#8217;s snapshots live in the same account as the server. If that account is compromised, or you delete the wrong thing, they go with it. A snapshot is a rollback mechanism, not a backup.</p>



<p class="wp-block-paragraph">Get database dumps and application data onto storage under a different set of credentials, on a schedule, and then do the part everyone skips: restore one. A backup you have never restored is a hypothesis. Point a dead man&#8217;s switch service at the backup job so you find out when it stops running, rather than the day you need it.</p>



<h3 class="wp-block-heading">Knowing the box is alive</h3>



<p class="wp-block-paragraph">At minimum, an external uptime check that alerts you. Better, node metrics into Prometheus with Grafana dashboards, or a hosted equivalent. Disk filling up is the single most common way a small VPS falls over, and it is completely predictable if anything is watching the graph.</p>



<h2 class="wp-block-heading">What I would skip</h2>



<p class="wp-block-paragraph">Not everything in the long hardening guides earns its cost on a single web server.</p>



<ul class="wp-block-list">
<li><strong>Large sysctl hardening blocks pasted from a gist.</strong> Most of the values are already the default on a current kernel, several are cargo-culted from a decade ago, and one of them will break something in a way that takes hours to trace. Change kernel parameters when you understand what each one does.</li>
<li><strong>AIDE, auditd and file-integrity monitoring</strong> on a small server nobody is watching. These produce a large volume of output that needs a human to review. If nobody reads it, it is not a control, it is just disk usage.</li>
<li><strong>Rebuilding OpenSSH&#8217;s cipher list.</strong> Current defaults are good. Guides that hand you a <code>Ciphers</code> line are usually copying advice written for a version that shipped years ago, and you can pin yourself to something weaker than the default without realising.</li>
<li><strong>Disabling ping.</strong> It breaks your own monitoring and hides nothing.</li>
</ul>



<h2 class="wp-block-heading">Troubleshooting</h2>



<h3 class="wp-block-heading">Locked out after an SSH change</h3>



<p class="wp-block-paragraph">Provider console, log in as root, and revert. If you cannot remember what you changed, <code>ls -lt /etc/ssh/sshd_config.d/</code> shows the most recently modified file. Run <code>sshd -t</code> to see whether the config is even valid, since a syntax error stops sshd starting at all.</p>



<h3 class="wp-block-heading">Key auth rejected, password prompt appears instead</h3>



<p class="wp-block-paragraph">Almost always permissions. The home directory must not be group-writable, <code>~/.ssh</code> needs 700 and <code>authorized_keys</code> 600, both owned by the user. sshd refuses keys from world-writable locations and does not explain itself on the client side. Run <code>ssh -v</code> to see how far it gets, and check <code>journalctl -u ssh</code> on the server for the actual reason.</p>



<h3 class="wp-block-heading">The setting I applied is not in effect</h3>



<p class="wp-block-paragraph"><code>sudo sshd -T</code> and search for the keyword. If the effective value differs from your file, something in <code>sshd_config.d/</code> sorts earlier, or a <code>Match</code> block later in the config is overriding it for your connection.</p>



<h3 class="wp-block-heading">A service is reachable that the firewall says is blocked</h3>



<p class="wp-block-paragraph">Docker, or another daemon writing netfilter rules directly. Check with <code>sudo iptables -S</code> or <code>sudo nft list ruleset</code>, and always verify from a machine outside the server rather than trusting <code>ufw status</code>.</p>



<h3 class="wp-block-heading">Nothing obviously wrong, but who has been logging in?</h3>



<pre class="wp-block-code"><code>last              # successful logins
sudo lastb        # failed attempts, from /var/log/btmp
journalctl -u ssh --since "24 hours ago"</code></pre>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li>Closing your only SSH session before testing the new configuration from a second one.</li>
<li>Enabling the firewall before adding the SSH rule.</li>
<li>Naming your sshd drop-in <code>99-</code>, so a vendor file that sorts earlier wins.</li>
<li>Trusting the file you edited instead of running <code>sshd -T</code>.</li>
<li>Generating the SSH key on the server.</li>
<li>Changing the SSH port and counting it as a security control.</li>
<li>Disabling password auth for SSH while leaving a web control panel with a weak password exposed.</li>
<li>No two-factor authentication on the hosting account that can reset root.</li>
<li>Treating provider snapshots as backups.</li>
<li>Installing fail2ban and never checking that it started.</li>
<li>Publishing container ports without binding them to localhost, then assuming ufw covers it.</li>
<li>Pasting a sysctl block you cannot explain line by line.</li>
</ul>



<h2 class="wp-block-heading">Best practices</h2>



<ul class="wp-block-list">
<li>Verify every control after applying it, with a command that reads the running state.</li>
<li>Keep a second session open and the provider console tested before making SSH changes.</li>
<li>Key-only authentication, root login disabled, and an explicit <code>AllowUsers</code> list.</li>
<li>Default-deny inbound firewall, with rules added before the firewall is enabled.</li>
<li>Automatic security updates on, with a deliberate decision about automatic reboots.</li>
<li>Two-factor authentication on the hosting account and the registrar.</li>
<li>Backups off the machine, on separate credentials, and restore-tested at least once.</li>
<li>UTC everywhere, with clock sync confirmed.</li>
<li>Monitoring that pages you on disk usage before it pages you on downtime.</li>
<li>Script the whole build, so the next server is identical and you are not repeating this from memory.</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">FAQ</h2>



<h3 class="wp-block-heading">What is the single most important step?</h3>



<p class="wp-block-paragraph">Key-only SSH with root login disabled, verified with <code>sshd -T</code>. It removes the entire category of credential guessing. Automatic security updates is a close second, because unpatched software is the more realistic way in once passwords are off the table.</p>



<h3 class="wp-block-heading">Should I change the SSH port?</h3>



<p class="wp-block-paragraph">For quieter logs, yes. As a security measure, no. A scanner finds the daemon regardless. The practical benefit is that failed-login noise drops enough that a genuine anomaly becomes visible, which has real value for anyone actually reading their logs.</p>



<h3 class="wp-block-heading">Do I need fail2ban if password authentication is off?</h3>



<p class="wp-block-paragraph">Not for SSH specifically. Install it for the services that still accept credentials, and for the reduction in log volume and connection churn. Just do not let it occupy the slot in your head reserved for an actual defence.</p>



<h3 class="wp-block-heading">Why did my sshd_config change get ignored?</h3>



<p class="wp-block-paragraph">Two likely causes. A file in <code>/etc/ssh/sshd_config.d/</code> that sorts alphabetically earlier set the same keyword first, and sshd keeps the first value it sees. Or the setting is <code>Port</code> or <code>ListenAddress</code> on a socket-activated system, where systemd owns the listener. <code>sshd -T</code> answers the first case and <code>ss -tlnp</code> answers the second.</p>



<h3 class="wp-block-heading">Is ufw enough, or should I use nftables directly?</h3>



<p class="wp-block-paragraph">ufw is enough for a single server with a handful of open ports, and it is a front end to the same machinery underneath. Write nftables rules directly when you need something ufw cannot express cleanly, such as rate limiting or complex source-based rules. Do not switch tools for its own sake; a firewall you understand beats an elegant one you do not.</p>



<h3 class="wp-block-heading">How do I harden a server I did not build?</h3>



<p class="wp-block-paragraph">Audit before you change anything. <code>sshd -T</code>, <code>ufw status verbose</code>, <code>ss -tlnp</code> for unexpected listeners, <code>systemctl list-units --type=service --state=running</code>, and the contents of every <code>authorized_keys</code> on the box. Old keys belonging to people who left are extremely common and nobody ever removes them.</p>



<h3 class="wp-block-heading">Should this be automated?</h3>



<p class="wp-block-paragraph">Yes, once you have done it manually enough times to understand each step. An Ansible playbook or a cloud-init script makes the next server identical and turns your hardening into something reviewable in version control. Automating a process you do not yet understand just makes the mistakes reproducible.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">The one thing to remember</h2>



<p class="wp-block-paragraph">Editing a config file is not the same as changing a system&#8217;s behaviour. Between your file and the running daemon sit include directives, drop-in ordering, systemd units and packages that ship their own opinions, and any one of them can quietly discard what you wrote without an error message.</p>



<p class="wp-block-paragraph">So treat this VPS hardening checklist as a set of claims to be tested rather than a list to be ticked. <code>sshd -T</code> for SSH, <code>ufw status verbose</code> for the firewall, <code>ss -tlnp</code> for what is listening, <code>systemctl status</code> for anything you installed. If you cannot show the control from the running system, you have not applied it. You have only intended to.</p>



<h2 class="wp-block-heading">Want a second pair of eyes on your server?</h2>



<p class="wp-block-paragraph">Most of the servers I get handed were hardened once, by someone who has since moved on, and nobody has verified anything since. Work I take on:</p>



<ul class="wp-block-list">
<li>Auditing an existing VPS and reporting which controls are genuinely in effect versus configured but overridden.</li>
<li>Building a new server from scratch: users, SSH, firewall, automatic updates, monitoring, backups, all verified.</li>
<li>Turning a manual build into an Ansible playbook or cloud-init script so the next server matches this one.</li>
<li>Moving SSH off the public internet onto a private overlay network or bastion.</li>
<li>Backup strategy that survives the hosting account being compromised, including a tested restore.</li>
<li>Firewall and container networking, especially where published Docker ports are bypassing the firewall.</li>
</ul>



<p class="wp-block-paragraph">Send me the output of <code>sshd -T</code>, <code>ufw status verbose</code> and <code>ss -tlnp</code>, and I will tell you what stands out.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/system-administration/vps-hardening-checklist/">You Probably Didn&#8217;t Harden That Server: A VPS Setup and Hardening Checklist That Verifies Itself</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Docker Issues &#038; Troubleshooting: Real-World Solutions</title>
		<link>https://john-nessime.com/blog/devops/docker-issues-troubleshooting-real-world-solutions/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Wed, 15 Jul 2026 17:05:30 +0000</pubDate>
				<category><![CDATA[Articles]]></category>
		<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Docker]]></category>
		<category><![CDATA[Linux]]></category>
		<category><![CDATA[Tutorials]]></category>
		<category><![CDATA[Apache]]></category>
		<category><![CDATA[Cloud]]></category>
		<category><![CDATA[Containers]]></category>
		<category><![CDATA[Docker Compose]]></category>
		<category><![CDATA[Grafana]]></category>
		<category><![CDATA[Infrastructure]]></category>
		<category><![CDATA[Logs]]></category>
		<category><![CDATA[Monitoring]]></category>
		<category><![CDATA[Networking]]></category>
		<category><![CDATA[Nginx]]></category>
		<category><![CDATA[Production]]></category>
		<category><![CDATA[Prometheus]]></category>
		<category><![CDATA[Reverse Proxy]]></category>
		<category><![CDATA[Self Hosting]]></category>
		<category><![CDATA[System Administration]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[Volumes]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=10</guid>

					<description><![CDATA[<p>Docker containers rarely fail without leaving clues. This practical guide explores common Docker issues involving networking, storage, permissions, ports, logs, health checks, reverse proxies, and resource usage—and explains how to troubleshoot them methodically.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/docker-issues-troubleshooting-real-world-solutions/">Docker Issues &amp; Troubleshooting: Real-World Solutions</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[

<p class="article-introduction wp-block-paragraph">Docker feels almost magical when you first start using it. You write a configuration file, run one command, and an entire application stack appears.</p>



<pre class="wp-block-code"><code>docker compose up -d</code></pre>



<p class="wp-block-paragraph">A web application, database, cache, monitoring service, and reverse proxy can all be launched in seconds. Everything is isolated, repeatable, and relatively easy to move between environments.</p>



<p class="wp-block-paragraph">Then, eventually, something breaks.</p>



<p class="wp-block-paragraph">A container refuses to start. A port is already occupied. A database loses access to its storage directory. One service cannot resolve the hostname of another. A reverse proxy returns a 502 error even though the application appears to be running.</p>



<p class="wp-block-paragraph">That is usually the point where Docker stops feeling like a convenient deployment tool and starts becoming a real infrastructure skill.</p>



<p class="wp-block-paragraph">This guide is based on the way I approach Docker issues on real Linux servers. It is not a list of commands to copy blindly. It is a troubleshooting method for discovering what failed, understanding why it failed, and solving it without making the situation worse.</p>



<h2 class="wp-block-heading">Docker Is Usually Not the Real Problem</h2>



<p class="wp-block-paragraph">When a container fails, Docker is often blamed first. In many cases, however, Docker is only exposing a problem somewhere else in the system.</p>



<ul class="wp-block-list">
<li>Incorrect Linux file permissions</li>
<li>A missing environment variable</li>
<li>An unavailable database</li>
<li>A port conflict on the host</li>
<li>An incorrect volume mount</li>
<li>A DNS or network configuration problem</li>
<li>A reverse proxy pointing to the wrong destination</li>
<li>Insufficient memory or disk space</li>
<li>An application-level configuration error</li>
</ul>



<p class="wp-block-paragraph">The container runtime may be working exactly as designed. The application inside the container may simply be unable to operate under the conditions it was given.</p>



<p class="wp-block-paragraph">This distinction matters because repeatedly deleting and rebuilding containers rarely fixes the underlying issue. It may temporarily hide it, but the same failure usually returns.</p>



<h2 class="wp-block-heading">My First Rule of Docker Troubleshooting: Do Not Delete Anything Yet</h2>



<p class="wp-block-paragraph">When a container suddenly exits, the first instinct is often to remove it, rebuild the image, or restart the entire stack. That destroys useful evidence.</p>



<p class="wp-block-paragraph">Before changing anything, I start by collecting information.</p>



<pre class="wp-block-code"><code>docker ps -a</code></pre>



<p class="wp-block-paragraph">This shows running and stopped containers, their current states, exposed ports, names, and recent exit information.</p>



<pre class="wp-block-code"><code>docker logs container-name</code></pre>



<p class="wp-block-paragraph">The logs frequently reveal the immediate cause: a missing file, invalid password, failed database connection, permission error, or malformed configuration.</p>



<pre class="wp-block-code"><code>docker inspect container-name</code></pre>



<p class="wp-block-paragraph">Inspection provides the container&#8217;s network settings, environment variables, volume mounts, restart policy, health status, image information, and runtime configuration.</p>



<p class="wp-block-paragraph">These three commands answer a large percentage of Docker troubleshooting questions before any corrective action is required.</p>



<h2 class="wp-block-heading">Understanding Docker Container States</h2>



<p class="wp-block-paragraph">A container can exist in several states, and each state tells a different story.</p>



<h3 class="wp-block-heading">Running</h3>



<p class="wp-block-paragraph">The main process inside the container is active. This does not automatically mean the application is healthy or ready to receive traffic.</p>



<h3 class="wp-block-heading">Exited</h3>



<p class="wp-block-paragraph">The container&#8217;s main process stopped. It may have completed normally, crashed, or failed during startup. The exit code and logs provide the next clues.</p>



<h3 class="wp-block-heading">Restarting</h3>



<p class="wp-block-paragraph">The container repeatedly starts and fails while Docker applies its restart policy. This commonly indicates an application crash, invalid configuration, inaccessible dependency, or permission problem.</p>



<h3 class="wp-block-heading">Created</h3>



<p class="wp-block-paragraph">The container was created but its main process has not started successfully.</p>



<h3 class="wp-block-heading">Dead</h3>



<p class="wp-block-paragraph">Docker could not properly stop or remove the container. This is less common and may indicate a lower-level runtime, storage, or host issue.</p>



<p class="wp-block-paragraph">Knowing the state prevents random troubleshooting. A networking issue requires a different investigation from a process that exits because of a syntax error.</p>



<h2 class="wp-block-heading">Start With the Container Logs</h2>



<p class="wp-block-paragraph">Logs are normally the fastest route to the cause of a Docker issue.</p>



<pre class="wp-block-code"><code>docker logs --tail 100 container-name</code></pre>



<p class="wp-block-paragraph">This displays the latest 100 lines without flooding the terminal with the container&#8217;s entire history.</p>



<pre class="wp-block-code"><code>docker logs -f container-name</code></pre>



<p class="wp-block-paragraph">The follow option streams new log entries in real time. It is useful while restarting a service or reproducing a problem.</p>



<pre class="wp-block-code"><code>docker logs --since 30m container-name</code></pre>



<p class="wp-block-paragraph">This limits the output to a recent period, which is especially useful for long-running containers.</p>



<p class="wp-block-paragraph">When reading logs, I do not look only at the final line. The true cause often appears several lines earlier. The last error may simply be a consequence of the first failure.</p>



<h2 class="wp-block-heading">Docker Networking: Where Many Problems Begin</h2>



<p class="wp-block-paragraph">Docker networking is one of the most common sources of confusion, especially when several containers need to communicate.</p>



<p class="wp-block-paragraph">Imagine a stack containing:</p>



<ul class="wp-block-list">
<li>Grafana</li>
<li>Prometheus</li>
<li>A web application</li>
<li>MySQL or PostgreSQL</li>
<li>Redis</li>
<li>Nginx or Apache</li>
</ul>



<p class="wp-block-paragraph">Each service may work independently while communication between them fails.</p>



<h3 class="wp-block-heading">The Localhost Mistake</h3>



<p class="wp-block-paragraph">Inside a container, <code>localhost</code> refers to that container itself. It does not refer to the Docker host, and it does not refer to another container.</p>



<p class="wp-block-paragraph">For example, if Grafana needs to connect to Prometheus, the data source address should usually use the Docker Compose service name:</p>



<pre class="wp-block-code"><code>http://prometheus:9090</code></pre>



<p class="wp-block-paragraph">Using the following from inside the Grafana container would normally point back to Grafana itself:</p>



<pre class="wp-block-code"><code>http://localhost:9090</code></pre>



<p class="wp-block-paragraph">This small misunderstanding causes a surprising number of connection failures.</p>



<h3 class="wp-block-heading">Confirm That Containers Share a Network</h3>



<pre class="wp-block-code"><code>docker network ls</code></pre>



<pre class="wp-block-code"><code>docker network inspect network-name</code></pre>



<p class="wp-block-paragraph">The inspection output shows which containers are attached to a network and which IP addresses were assigned.</p>



<p class="wp-block-paragraph">Containers on unrelated networks cannot automatically communicate by service name. They must share a Docker network or use another explicitly configured route.</p>



<h3 class="wp-block-heading">Test Connectivity From Inside the Container</h3>



<pre class="wp-block-code"><code>docker exec -it container-name sh</code></pre>



<p class="wp-block-paragraph">Depending on the image, Bash may be available instead:</p>



<pre class="wp-block-code"><code>docker exec -it container-name bash</code></pre>



<p class="wp-block-paragraph">From inside the container, test name resolution and connectivity using the utilities available in that image:</p>



<pre class="wp-block-code"><code>getent hosts database
curl http://application:8080
wget -qO- http://prometheus:9090/-/ready</code></pre>



<p class="wp-block-paragraph">A successful result confirms that Docker DNS and network routing are functioning. A failed result narrows the investigation considerably.</p>



<h2 class="wp-block-heading">Host Ports and Container Ports Are Different</h2>



<p class="wp-block-paragraph">A Docker Compose port mapping such as the following is read from left to right:</p>



<pre class="wp-block-code"><code>ports:
  - "8080:80"</code></pre>



<p class="wp-block-paragraph">Port <code>8080</code> belongs to the Docker host. Port <code>80</code> belongs to the application inside the container.</p>



<p class="wp-block-paragraph">Traffic sent to the host on port 8080 is forwarded to port 80 inside the container.</p>



<p class="wp-block-paragraph">If another service already occupies host port 8080, Docker cannot bind to it.</p>



<pre class="wp-block-code"><code>ss -tulpn</code></pre>



<p class="wp-block-paragraph">To check a specific port:</p>



<pre class="wp-block-code"><code>ss -tulpn | grep :8080</code></pre>



<p class="wp-block-paragraph">A common mistake is changing the container&#8217;s internal port when only the host-side mapping needs to change.</p>



<p class="wp-block-paragraph">For example, this avoids a conflict while leaving the application untouched:</p>



<pre class="wp-block-code"><code>ports:
  - "8081:80"</code></pre>



<h2 class="wp-block-heading">Published Ports Are Not Required for Container-to-Container Traffic</h2>



<p class="wp-block-paragraph">Containers connected to the same Docker network can usually communicate through their internal ports without publishing those ports to the host.</p>



<p class="wp-block-paragraph">For example, Prometheus can communicate with an exporter on the shared Docker network without exposing that exporter publicly.</p>



<p class="wp-block-paragraph">This reduces unnecessary exposure and produces a cleaner architecture. Publish only the ports that must be reached from the host, a reverse proxy, or an external client.</p>



<h2 class="wp-block-heading">Docker Volumes: Persistent Storage Done Properly</h2>



<p class="wp-block-paragraph">Containers should be treated as disposable. Persistent data should not depend on the writable filesystem inside a container.</p>



<p class="wp-block-paragraph">Without a correctly configured volume, deleting a database container may also delete the database stored inside it.</p>



<h3 class="wp-block-heading">Named Volumes</h3>



<pre class="wp-block-code"><code>services:
  database:
    image: mariadb:latest
    volumes:
      - database-data:/var/lib/mysql

volumes:
  database-data:</code></pre>



<p class="wp-block-paragraph">Docker manages the storage location while the application writes to its expected internal directory.</p>



<h3 class="wp-block-heading">Bind Mounts</h3>



<pre class="wp-block-code"><code>volumes:
  - ./config:/etc/application</code></pre>



<p class="wp-block-paragraph">A bind mount maps a specific host path into the container. It is useful for configuration files, development directories, and data that administrators need to access directly.</p>



<p class="wp-block-paragraph">Bind mounts also introduce more responsibility. The host directory must exist with the correct permissions, ownership, security context, and contents.</p>



<h3 class="wp-block-heading">Inspect Existing Volumes</h3>



<pre class="wp-block-code"><code>docker volume ls</code></pre>



<pre class="wp-block-code"><code>docker volume inspect volume-name</code></pre>



<p class="wp-block-paragraph">Before deleting an apparently unused volume, confirm which application created it and whether it contains irreplaceable data.</p>



<h2 class="wp-block-heading">Permission Problems Are Quiet but Destructive</h2>



<p class="wp-block-paragraph">Permission errors can prevent databases from initializing, web applications from uploading files, monitoring tools from writing data, or services from reading configuration files.</p>



<p class="wp-block-paragraph">The first checks are simple:</p>



<pre class="wp-block-code"><code>ls -lah
stat path-to-file
stat path-to-directory</code></pre>



<p class="wp-block-paragraph">The user inside the container may have a different numeric user ID from the owner of the host directory. The names do not matter as much as the numeric UID and GID.</p>



<pre class="wp-block-code"><code>docker exec container-name id</code></pre>



<p class="wp-block-paragraph">Compare that result with the ownership of the mounted host path.</p>



<p class="wp-block-paragraph">Changing permissions to <code>777</code> may appear to solve the issue, but it is usually an unsafe shortcut rather than a proper solution. Correct ownership and the minimum required permissions are preferable.</p>



<h2 class="wp-block-heading">SELinux Can Affect Docker Bind Mounts</h2>



<p class="wp-block-paragraph">On distributions such as AlmaLinux, Rocky Linux, CentOS Stream, Fedora, and Red Hat Enterprise Linux, standard Unix permissions may look correct while SELinux still blocks access.</p>



<p class="wp-block-paragraph">Docker Compose bind mounts may require an SELinux relabeling option:</p>



<pre class="wp-block-code"><code>volumes:
  - ./data:/var/lib/application:Z</code></pre>



<p class="wp-block-paragraph">The uppercase <code>Z</code> assigns a private SELinux label for the container. A lowercase <code>z</code> is used when the content must be shared between multiple containers.</p>



<p class="wp-block-paragraph">SELinux should not be disabled simply to make a container work. The better approach is to identify the denied access and apply the correct labeling or policy.</p>

<br><img decoding="async" src="https://john-nessime.com/blog/wp-content/uploads/2026/07/Docker-Issues-Troubleshooting-Real-World-Solutions-2.png" alt="Docker Issues &#038; Troubleshooting - Real-World Solutions | John Nessime">
<br>

<h2 class="wp-block-heading">Environment Variables Can Break an Entire Stack</h2>



<p class="wp-block-paragraph">Modern containerized applications depend heavily on environment variables for database credentials, API keys, application URLs, encryption secrets, feature flags, and runtime settings.</p>



<p class="wp-block-paragraph">One missing or incorrectly escaped value can stop a service from starting.</p>



<pre class="wp-block-code"><code>docker compose config</code></pre>



<p class="wp-block-paragraph">This command renders the resolved Docker Compose configuration. It helps reveal missing substitutions, merged settings, malformed values, and unexpected defaults.</p>



<p class="wp-block-paragraph">To inspect the environment available inside a running container:</p>



<pre class="wp-block-code"><code>docker exec container-name env</code></pre>



<p class="wp-block-paragraph">Be careful when sharing this output because it may include passwords, tokens, and private service credentials.</p>



<h2 class="wp-block-heading">Docker Compose Configuration Errors</h2>



<p class="wp-block-paragraph">YAML is readable, but it is sensitive to indentation and structure. A service can be valid YAML while still being configured incorrectly for Docker Compose.</p>



<p class="wp-block-paragraph">Before deploying a change, validate the file:</p>



<pre class="wp-block-code"><code>docker compose config --quiet</code></pre>



<p class="wp-block-paragraph">If the command returns without an error, the configuration is structurally valid.</p>



<p class="wp-block-paragraph">For a complete rendered version:</p>



<pre class="wp-block-code"><code>docker compose config</code></pre>



<p class="wp-block-paragraph">This is especially valuable when Compose files use environment substitutions, overrides, extension fields, or multiple configuration files.</p>



<h2 class="wp-block-heading">Health Checks Reveal More Than Running Status</h2>



<p class="wp-block-paragraph">A container can be running while the application inside it is unusable.</p>



<p class="wp-block-paragraph">A database may still be initializing. A web service may be listening but unable to connect to its database. An API may be running but returning errors for every request.</p>



<p class="wp-block-paragraph">A health check lets Docker test actual application readiness.</p>



<pre class="wp-block-code"><code>healthcheck:
  test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
  interval: 30s
  timeout: 10s
  retries: 3
  start_period: 20s</code></pre>



<p class="wp-block-paragraph">The application should expose a lightweight endpoint that confirms its critical dependencies are available.</p>



<p class="wp-block-paragraph">To inspect the current status:</p>



<pre class="wp-block-code"><code>docker inspect --format='{{json .State.Health}}' container-name</code></pre>



<p class="wp-block-paragraph">A health check does not repair an unhealthy service by itself, but it provides a far more accurate operational signal than process status alone.</p>



<h2 class="wp-block-heading">Restart Policies Can Hide Repeated Failures</h2>



<p class="wp-block-paragraph">Restart policies are useful in production, but they can create the impression that a service is stable when it is actually crashing continuously.</p>



<pre class="wp-block-code"><code>restart: unless-stopped</code></pre>



<p class="wp-block-paragraph">If the application exits every few seconds, Docker restarts it every few seconds. The container may appear in the process list, but the service may never become ready.</p>



<p class="wp-block-paragraph">Check the restart count:</p>



<pre class="wp-block-code"><code>docker inspect --format='{{.RestartCount}}' container-name</code></pre>



<p class="wp-block-paragraph">A rapidly increasing number indicates that the restart policy is masking an unresolved failure.</p>



<h2 class="wp-block-heading">Reverse Proxy Errors: 502, 504, SSL Problems, and Redirect Loops</h2>



<p class="wp-block-paragraph">Reverse proxies such as Nginx, Apache, Traefik, and Caddy often sit between users and containerized applications.</p>



<p class="wp-block-paragraph">When the public website fails, the application container may still be perfectly healthy. The proxy may simply be unable to reach it.</p>



<h3 class="wp-block-heading">Common Reverse Proxy Problems</h3>



<ul class="wp-block-list">
<li>The upstream hostname is incorrect.</li>
<li>The proxy uses the host port when it should use the container port.</li>
<li>The proxy and application do not share a Docker network.</li>
<li>The application listens only on <code>127.0.0.1</code> inside the container.</li>
<li>The proxy forwards HTTP while the backend expects HTTPS.</li>
<li>The application is unaware that the original request used HTTPS.</li>
<li>The public hostname does not match the certificate.</li>
<li>Both the proxy and application force redirects, creating a loop.</li>
</ul>



<p class="wp-block-paragraph">Test the application directly before blaming the proxy:</p>



<pre class="wp-block-code"><code>curl -I http://127.0.0.1:published-port</code></pre>



<p class="wp-block-paragraph">If the application responds directly but fails through the domain, the reverse proxy, firewall, DNS, or TLS configuration becomes the main area of investigation.</p>



<h2 class="wp-block-heading">An Application Must Listen on the Correct Interface</h2>



<p class="wp-block-paragraph">An application inside a container may start successfully while listening only on <code>127.0.0.1</code>. In that situation, Docker&#8217;s network interface may not be able to reach it.</p>



<p class="wp-block-paragraph">Containerized web applications should usually listen on:</p>



<pre class="wp-block-code"><code>0.0.0.0</code></pre>



<p class="wp-block-paragraph">This allows the process to accept connections through the container&#8217;s network interfaces.</p>



<p class="wp-block-paragraph">This setting is commonly controlled by application arguments or environment variables such as <code>HOST</code>, <code>BIND_ADDRESS</code>, or <code>LISTEN_ADDRESS</code>.</p>



<h2 class="wp-block-heading">Resource Exhaustion Can Look Like a Docker Failure</h2>



<p class="wp-block-paragraph">Containers share the CPU, memory, storage, and network resources of the host. One service can consume enough resources to affect every other workload.</p>



<pre class="wp-block-code"><code>docker stats</code></pre>



<p class="wp-block-paragraph">This provides a live view of CPU, memory, network, and block I/O usage for running containers.</p>



<p class="wp-block-paragraph">On the host, I also check:</p>



<pre class="wp-block-code"><code>free -h
df -h
df -i
uptime</code></pre>



<p class="wp-block-paragraph">Disk space is not the only storage limit. A filesystem can also run out of inodes, preventing new files from being created even when gigabytes remain available.</p>



<p class="wp-block-paragraph">Kernel logs may reveal out-of-memory termination:</p>



<pre class="wp-block-code"><code>dmesg | grep -i -E "out of memory|killed process|oom"</code></pre>



<p class="wp-block-paragraph">If the kernel killed the process, the application logs may end suddenly without a useful explanation.</p>



<h2 class="wp-block-heading">Docker Disk Usage Grows Quietly</h2>



<p class="wp-block-paragraph">Old images, stopped containers, build cache, unused volumes, and unbounded logs can gradually consume the host&#8217;s storage.</p>



<pre class="wp-block-code"><code>docker system df</code></pre>



<p class="wp-block-paragraph">For more detail:</p>



<pre class="wp-block-code"><code>docker system df -v</code></pre>



<p class="wp-block-paragraph">Cleanup commands should be used carefully:</p>



<pre class="wp-block-code"><code>docker image prune
docker container prune
docker builder prune</code></pre>



<p class="wp-block-paragraph">A broad command such as <code>docker system prune</code> can remove more than expected. Never add the volume-removal option unless the contents of unused volumes have been verified and backed up.</p>



<h2 class="wp-block-heading">Container Logs Can Fill the Server</h2>



<p class="wp-block-paragraph">Docker&#8217;s default JSON logging driver can create large log files when applications produce continuous output.</p>



<p class="wp-block-paragraph">Log rotation can be configured in Docker Compose:</p>



<pre class="wp-block-code"><code>logging:
  driver: "json-file"
  options:
    max-size: "10m"
    max-file: "5"</code></pre>



<p class="wp-block-paragraph">This limits the number and size of local log files for that container.</p>



<p class="wp-block-paragraph">For production systems, centralized logging with tools such as Grafana Loki, Elasticsearch, OpenSearch, or a managed logging service makes incident investigation considerably easier.</p>



<h2 class="wp-block-heading">Image Architecture and Compatibility Problems</h2>



<p class="wp-block-paragraph">An image built for one CPU architecture may not run correctly on another. This becomes relevant when moving between AMD64 servers, ARM-based cloud instances, Apple Silicon machines, and single-board computers.</p>



<p class="wp-block-paragraph">Check the host architecture:</p>



<pre class="wp-block-code"><code>uname -m</code></pre>



<p class="wp-block-paragraph">Inspect the image:</p>



<pre class="wp-block-code"><code>docker image inspect image-name</code></pre>



<p class="wp-block-paragraph">Errors mentioning an invalid executable format frequently indicate an architecture mismatch.</p>



<p class="wp-block-paragraph">Multi-platform builds can be produced using Docker Buildx:</p>



<pre class="wp-block-code"><code>docker buildx build --platform linux/amd64,linux/arm64 .</code></pre>



<h2 class="wp-block-heading">A New Image Tag Does Not Always Mean a Safe Upgrade</h2>



<p class="wp-block-paragraph">Using <code>latest</code> may be convenient, but it makes deployments less predictable. A future image update can introduce a breaking configuration change, database migration, removed feature, or incompatible dependency.</p>



<p class="wp-block-paragraph">Production deployments are usually safer with an explicit version:</p>



<pre class="wp-block-code"><code>image: grafana/grafana:12.0.2</code></pre>



<p class="wp-block-paragraph">Before upgrading:</p>



<ul class="wp-block-list">
<li>Read the release notes.</li>
<li>Confirm supported upgrade paths.</li>
<li>Back up persistent data.</li>
<li>Record the current working version.</li>
<li>Test the change outside production where possible.</li>
<li>Prepare a rollback procedure.</li>
</ul>



<p class="wp-block-paragraph">Containers make software easier to replace, but they do not make every application upgrade reversible.</p>



<h2 class="wp-block-heading">Dependency Startup Order Is Not Readiness</h2>



<p class="wp-block-paragraph">Docker Compose can start services in a defined order, but starting a database container does not mean the database is immediately ready for connections.</p>



<p class="wp-block-paragraph">An application may launch too early, fail to connect, and exit.</p>



<p class="wp-block-paragraph">Health checks and dependency conditions can improve this:</p>



<pre class="wp-block-code"><code>services:
  database:
    image: postgres:17
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5

  application:
    image: example/application:1.0
    depends_on:
      database:
        condition: service_healthy</code></pre>



<p class="wp-block-paragraph">The application itself should still handle temporary dependency failures gracefully. Networks fluctuate, databases restart, and external services occasionally become unavailable.</p>



<h2 class="wp-block-heading">Backups Matter More Than Recreating Containers</h2>



<p class="wp-block-paragraph">Application containers are normally easy to recreate. Persistent business data is not.</p>



<p class="wp-block-paragraph">A reliable Docker backup strategy should cover:</p>



<ul class="wp-block-list">
<li>Database-native backups</li>
<li>Named volumes and bind-mounted data</li>
<li>Docker Compose files</li>
<li>Environment files and secrets</li>
<li>Reverse proxy configurations</li>
<li>TLS and certificate configuration</li>
<li>Application-specific settings</li>
<li>A tested restoration procedure</li>
</ul>



<p class="wp-block-paragraph">A backup that has never been restored is only an assumption.</p>



<p class="wp-block-paragraph">Database backups should normally use the database&#8217;s own tools rather than copying active database files blindly. Examples include <code>pg_dump</code> for PostgreSQL and <code>mysqldump</code> or <code>mariadb-dump</code> for MySQL-compatible systems.</p>



<h2 class="wp-block-heading">Monitoring Changes Troubleshooting From Guesswork to Evidence</h2>



<p class="wp-block-paragraph">Without monitoring, administrators usually investigate after users report a failure. By then, the container may have restarted and the original conditions may no longer be visible.</p>



<p class="wp-block-paragraph">Metrics help answer better questions:</p>



<ul class="wp-block-list">
<li>When did memory consumption begin increasing?</li>
<li>Which container experienced the first error?</li>
<li>Was the disk already nearly full?</li>
<li>Did network latency rise before the application failed?</li>
<li>How many times did the service restart?</li>
<li>Did the host run out of memory?</li>
<li>Was the reverse proxy still able to reach the backend?</li>
</ul>



<p class="wp-block-paragraph">Prometheus, Grafana, node-level exporters, container metrics, uptime probes, and centralized logs provide the historical context that individual Docker commands cannot.</p>



<h2 class="wp-block-heading">A Practical Docker Troubleshooting Workflow</h2>



<p class="wp-block-paragraph">When a Docker service fails, I use a sequence rather than jumping between unrelated commands.</p>



<h3 class="wp-block-heading">1. Identify the Exact Symptom</h3>



<ul class="wp-block-list">
<li>Does the container fail to start?</li>
<li>Does it start and then exit?</li>
<li>Is it running but unreachable?</li>
<li>Is the application responding with errors?</li>
<li>Is the public domain failing while the local service works?</li>
<li>Is the problem intermittent?</li>
</ul>



<h3 class="wp-block-heading">2. Check Container Status</h3>



<pre class="wp-block-code"><code>docker ps -a</code></pre>



<h3 class="wp-block-heading">3. Read the Logs</h3>



<pre class="wp-block-code"><code>docker logs --tail 200 container-name</code></pre>



<h3 class="wp-block-heading">4. Inspect Runtime Configuration</h3>



<pre class="wp-block-code"><code>docker inspect container-name</code></pre>



<h3 class="wp-block-heading">5. Validate Docker Compose</h3>



<pre class="wp-block-code"><code>docker compose config</code></pre>



<h3 class="wp-block-heading">6. Check Host Resources</h3>



<pre class="wp-block-code"><code>docker stats
free -h
df -h
df -i</code></pre>



<h3 class="wp-block-heading">7. Test Internal Connectivity</h3>



<p class="wp-block-paragraph">Enter the relevant container and test DNS resolution, ports, and HTTP endpoints from the same network context as the application.</p>



<h3 class="wp-block-heading">8. Check Volumes and Permissions</h3>



<p class="wp-block-paragraph">Confirm that mounts point to the expected locations and that the container user can read or write them as required.</p>



<h3 class="wp-block-heading">9. Isolate the Reverse Proxy</h3>



<p class="wp-block-paragraph">Test the backend directly. If the backend works, investigate the proxy, DNS, TLS, firewall, and forwarded headers.</p>



<h3 class="wp-block-heading">10. Change One Thing at a Time</h3>



<p class="wp-block-paragraph">Multiple simultaneous changes make it difficult to identify the actual solution. Apply one correction, test it, and record the result.</p>



<h2 class="wp-block-heading">Commands I Frequently Use During Docker Investigations</h2>



<pre class="wp-block-code"><code># Show running containers
docker ps

# Show all containers
docker ps -a

# Read recent logs
docker logs --tail 100 container-name

# Follow logs
docker logs -f container-name

# Inspect a container
docker inspect container-name

# Enter a container
docker exec -it container-name sh

# View live resource use
docker stats

# List networks
docker network ls

# Inspect a network
docker network inspect network-name

# List volumes
docker volume ls

# Inspect a volume
docker volume inspect volume-name

# Validate Compose configuration
docker compose config

# View Compose service status
docker compose ps

# Restart one service
docker compose restart service-name

# Recreate one service
docker compose up -d --force-recreate service-name

# View Docker disk usage
docker system df

# View host listening ports
ss -tulpn

# Check disk usage
df -h

# Check inode usage
df -i

# Check memory
free -h</code></pre>



<h2 class="wp-block-heading">Docker Troubleshooting Mistakes to Avoid</h2>



<h3 class="wp-block-heading">Deleting Containers Before Reading Their Logs</h3>



<p class="wp-block-paragraph">This removes evidence and often leaves the original problem unresolved.</p>



<h3 class="wp-block-heading">Using Docker System Prune Without Reviewing the Impact</h3>



<p class="wp-block-paragraph">Cleanup commands can remove useful images, build cache, stopped containers, and potentially important storage.</p>



<h3 class="wp-block-heading">Using Latest Everywhere</h3>



<p class="wp-block-paragraph">Uncontrolled image updates reduce reproducibility and can introduce breaking changes unexpectedly.</p>



<h3 class="wp-block-heading">Opening Every Port Publicly</h3>



<p class="wp-block-paragraph">Internal services should remain internal unless external access is genuinely required.</p>



<h3 class="wp-block-heading">Setting Permissions to 777</h3>



<p class="wp-block-paragraph">This weakens security and avoids fixing the actual ownership or access-control issue.</p>



<h3 class="wp-block-heading">Restarting the Entire Server Too Early</h3>



<p class="wp-block-paragraph">A restart may temporarily restore service while destroying evidence about the original failure.</p>



<h3 class="wp-block-heading">Changing Several Variables at Once</h3>



<p class="wp-block-paragraph">Even when the issue disappears, it becomes impossible to know which change solved it.</p>



<h2 class="wp-block-heading">Docker Compose Is Also Infrastructure Documentation</h2>



<p class="wp-block-paragraph">A well-organized Compose file documents the structure of an application environment.</p>



<ul class="wp-block-list">
<li>Which services exist</li>
<li>Which image versions are deployed</li>
<li>Which networks connect them</li>
<li>Which ports are externally accessible</li>
<li>Where persistent data is stored</li>
<li>Which environment variables are required</li>
<li>Which services depend on others</li>
<li>Which health checks are available</li>
<li>How containers restart after failure</li>
</ul>



<p class="wp-block-paragraph">Six months after a deployment, this information is often more useful than a separate document that was never updated.</p>



<p class="wp-block-paragraph">Readable service names, comments for non-obvious decisions, pinned image versions, organized environment files, and clear volume names make future troubleshooting much faster.</p>



<h2 class="wp-block-heading">What Docker Failures Actually Teach</h2>



<p class="wp-block-paragraph">Some of the most useful infrastructure lessons come from deployments that did not work as expected.</p>



<p class="wp-block-paragraph">A failed container may teach you how Linux permissions interact with numeric user IDs. A 502 error may reveal the difference between host ports and container ports. A disappearing database may demonstrate why persistent volumes matter. An unreachable service may expose a misunderstanding about Docker DNS or network boundaries.</p>



<p class="wp-block-paragraph">These incidents are frustrating, but they build practical knowledge that clean tutorial environments rarely provide.</p>



<p class="wp-block-paragraph">Production systems do not normally fail in textbook ways. Several small issues may combine: a nearly full disk, oversized logs, an automatic image upgrade, and an aggressive restart policy can create one confusing incident.</p>



<h2 class="wp-block-heading">Final Thoughts</h2>



<p class="wp-block-paragraph">Docker makes application deployment more portable, repeatable, and manageable, but it does not remove the need to understand the systems underneath it.</p>



<p class="wp-block-paragraph">Effective Docker troubleshooting requires knowledge of Linux, networking, storage, process management, application configuration, security, and monitoring.</p>



<p class="wp-block-paragraph">The most important habit is simple: follow the evidence.</p>



<p class="wp-block-paragraph">Check the container state. Read the logs. Inspect the configuration. Test the network from the correct location. Verify volumes and permissions. Check the host&#8217;s resources. Separate the application from the proxy. Change one thing at a time.</p>



<p class="wp-block-paragraph">The next time a Docker container fails, resist the urge to delete everything and begin again. The answer is usually already present in the logs, runtime configuration, network design, storage mapping, or host environment.</p>



<p class="wp-block-paragraph">Docker troubleshooting becomes much less intimidating once every failure is treated as an investigation rather than an emergency.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Frequently Asked Questions About Docker Issues</h2>



<h3 class="wp-block-heading">Why does my Docker container exit immediately?</h3>



<p class="wp-block-paragraph">A Docker container exits when its main process stops. Common causes include invalid configuration, missing environment variables, permission errors, failed dependency connections, and incorrect startup commands. Use <code>docker logs</code> and <code>docker inspect</code> to identify the cause.</p>



<h3 class="wp-block-heading">Why is my Docker container running but not accessible?</h3>



<p class="wp-block-paragraph">The application may be listening on the wrong interface, the port may not be published correctly, a firewall may be blocking traffic, or the reverse proxy may point to the wrong hostname or port. Test the application both inside the container and directly through the host port.</p>



<h3 class="wp-block-heading">Why can one Docker container not connect to another?</h3>



<p class="wp-block-paragraph">The containers may not share a Docker network, the wrong service name may be used, or the destination service may not be listening on the expected port. Inside Docker, use the Compose service name rather than <code>localhost</code>.</p>



<h3 class="wp-block-heading">How do I find which process is using a Docker port?</h3>



<p class="wp-block-paragraph">Run <code>ss -tulpn</code> on the Docker host and filter for the relevant port. If the host port is already occupied, change the host side of the Docker port mapping or stop the conflicting service.</p>



<h3 class="wp-block-heading">Can restarting Docker solve container problems?</h3>



<p class="wp-block-paragraph">Restarting Docker may temporarily restore a service, but it can also hide the original cause. Logs, container state, disk space, memory, network connectivity, and volume permissions should be checked before restarting the Docker daemon or server.</p>



<h3 class="wp-block-heading">How can I prevent Docker logs from filling the disk?</h3>



<p class="wp-block-paragraph">Configure log rotation using the Docker logging options, such as <code>max-size</code> and <code>max-file</code>. Production environments may also benefit from centralized logging with Grafana Loki, OpenSearch, Elasticsearch, or a managed logging platform.</p>



<h3 class="wp-block-heading">Should I use the latest Docker image tag?</h3>



<p class="wp-block-paragraph">The latest tag is convenient but unpredictable. Pinning a tested image version improves repeatability and makes upgrades and rollbacks easier to control.</p>



<h3 class="wp-block-heading">Are Docker volumes automatically backed up?</h3>



<p class="wp-block-paragraph">No. Docker volumes provide persistent storage, but Docker does not automatically create external backups. Important volumes and databases need a separate, tested backup and restoration process.</p>

<p>The post <a href="https://john-nessime.com/blog/devops/docker-issues-troubleshooting-real-world-solutions/">Docker Issues &amp; Troubleshooting: Real-World Solutions</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
	</channel>
</rss>
