<?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>Logs | John Nessime</title>
	<atom:link href="https://john-nessime.com/blog/tag/logs/feed/" rel="self" type="application/rss+xml" />
	<link>https://john-nessime.com/blog/tag/logs/</link>
	<description>Cloud, DevOps, Data &#38; AI — Built, Tested, Explained</description>
	<lastBuildDate>Sun, 20 Sep 2026 16:47:57 +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>Logs | John Nessime</title>
	<link>https://john-nessime.com/blog/tag/logs/</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>Docker Logs Ate My Disk: A Working Guide to Log Drivers and Rotation</title>
		<link>https://john-nessime.com/blog/devops/docker-log-rotation/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Mon, 10 Aug 2026 18:00:00 +0000</pubDate>
				<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Docker]]></category>
		<category><![CDATA[System Administration]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[Container Runtime]]></category>
		<category><![CDATA[Containers]]></category>
		<category><![CDATA[Disk Space]]></category>
		<category><![CDATA[Docker Compose]]></category>
		<category><![CDATA[journald]]></category>
		<category><![CDATA[Log Retention]]></category>
		<category><![CDATA[Logging]]></category>
		<category><![CDATA[Logs]]></category>
		<category><![CDATA[Loki]]></category>
		<category><![CDATA[Observability]]></category>
		<category><![CDATA[Production]]></category>
		<category><![CDATA[rsyslog]]></category>
		<category><![CDATA[Self Hosting]]></category>
		<category><![CDATA[Storage]]></category>
		<category><![CDATA[Sysadmin]]></category>
		<category><![CDATA[Syslog]]></category>
		<category><![CDATA[Systemd]]></category>
		<category><![CDATA[VPS]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=182</guid>

					<description><![CDATA[<p>Docker's default logging driver writes container output to a JSON file with no size limit and no rotation. This is a practical guide to Docker log rotation: what the defaults actually do, why your daemon.json change did nothing, why deleting the log file did not free any disk, and how to choose between json-file, local, journald and shipping logs off the box.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/docker-log-rotation/">Docker Logs Ate My Disk: A Working Guide to Log Drivers and Rotation</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">Monitoring says the API is healthy. The ticket says nobody can deploy. You SSH in, run <code>df -h</code>, and the root filesystem is at 100%. A few minutes of poking around <code>/var/lib/docker</code> later you find it: one container, one file, a double-digit number of gigabytes of newline-delimited JSON, written continuously since the day that container was created.</p>



<p class="wp-block-paragraph">Nothing was broken. That is the part people get stuck on. The container did exactly what it was told, the daemon did exactly what it was told, and the disk filled anyway. Docker&#8217;s default logging driver has no size cap and no rotation, and it has been that way for a long time on purpose.</p>



<p class="wp-block-paragraph">This post covers Docker log rotation properly: what the defaults actually do, why the <code>daemon.json</code> change you already made did nothing, why deleting the log file did not give you your disk back, how the available drivers differ in the ways that matter under load, and what to do when you need space in the next sixty seconds.</p>



<h2 class="wp-block-heading">The default is unbounded, and that is documented behaviour</h2>



<p class="wp-block-paragraph">Docker&#8217;s default logging driver is <code>json-file</code>. It captures stdout and stderr from the container&#8217;s main process and appends one JSON object per line to a file on the host, one file per container. Each line carries the message, whether it came from stdout or stderr, and a timestamp.</p>



<p class="wp-block-paragraph">The important part is the option defaults. For <code>json-file</code>, <code>max-size</code> defaults to unlimited, <code>max-file</code> defaults to 1, and <code>compress</code> defaults to false. Put together, that means: one file, no rotation, grows until something else breaks.</p>



<p class="wp-block-paragraph">Docker&#8217;s own documentation is upfront about why. Keeping <code>json-file</code> unrotated by default preserves backwards compatibility with older engines and suits situations where Docker is acting as a runtime under Kubernetes, where the kubelet handles rotation itself. It is a deliberate choice that happens to be the wrong one for almost every standalone host.</p>



<p class="wp-block-paragraph">Before you change anything, find out what you actually have. These three commands answer different questions and you want all three:</p>



<pre class="wp-block-code"><code># What driver is the daemon handing to newly created containers?
docker info --format '{{.LoggingDriver}}'

# What is this specific container actually using right now?
docker inspect --format '{{.HostConfig.LogConfig}}' my-api

# Where does its log live, and how big has it got?
docker inspect --format '{{.LogPath}}' my-api
sudo du -h "$(docker inspect --format '{{.LogPath}}' my-api)"</code></pre>



<p class="wp-block-paragraph">The gap between the first command and the second is where most of the pain lives, and we will come back to it.</p>



<p class="wp-block-paragraph">To find the offenders across the whole host, look at the per-container directories under the Docker data root. Each one holds that container&#8217;s log file plus a small amount of metadata, so the sizes are close enough to be useful:</p>



<pre class="wp-block-code"><code>sudo du -sh /var/lib/docker/containers/* | sort -rh | head -10</code></pre>



<p class="wp-block-paragraph">Container IDs are not memorable, so map the winner back to a name with <code>docker ps --no-trunc</code> or by grepping the ID against <code>docker ps -aq</code>. In my experience the top entry is almost never the application you were worried about. It is usually a reverse proxy logging every request, a health-check loop firing every few seconds, or a debug flag somebody set during an incident and never turned off.</p>



<h2 class="wp-block-heading">Setting up Docker log rotation on the daemon</h2>



<p class="wp-block-paragraph">The daemon-wide fix goes in <code>/etc/docker/daemon.json</code>. If the file does not exist, create it. If it does, merge these keys in rather than overwriting it, because that file often already carries storage driver, address pool or registry mirror settings you do not want to lose.</p>



<pre class="wp-block-code"><code>{
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "10m",
    "max-file": "3",
    "compress": "true"
  }
}</code></pre>



<p class="wp-block-paragraph">What each key buys you:</p>



<ul class="wp-block-list">
<li><code>max-size</code> caps a single file. Accepts an integer plus <code>k</code>, <code>m</code> or <code>g</code>. This is the setting that stops the runaway.</li>

<li><code>max-file</code> caps how many files are kept. When rotation would create one too many, the oldest is deleted. It is <em>only effective if <code>max-size</code> is also set</em>, which is the single most common misconfiguration I see.</li>

<li><code>compress</code> gzips rotated files. The active file stays uncompressed so it can still be appended to and read. Log text compresses extremely well, so this is close to free retention.</li>
</ul>



<p class="wp-block-paragraph">Two things trip people up here. First, every value in <code>log-opts</code> must be a JSON string, including the numeric ones. Writing <code>"max-file": 3</code> without quotes is invalid and the daemon will refuse to start cleanly. Second, validate before you restart, because a malformed <code>daemon.json</code> on a production host is a much worse afternoon than a full disk:</p>



<pre class="wp-block-code"><code># Confirm the file is valid JSON before touching the daemon
sudo python3 -c 'import json,sys; json.load(open("/etc/docker/daemon.json"))' 
  &amp;&amp; echo "daemon.json parses OK"

sudo systemctl restart docker</code></pre>



<p class="wp-block-paragraph">Now do the arithmetic, because this is a budget and not a magic switch. Worst case per container is <code>max-size</code> multiplied by <code>max-file</code>. At 10m and 3, that is roughly 30 MB uncompressed per container, less once compression kicks in on the rotated files. Multiply by your container count and compare against the disk. On a small VPS plan from a provider like InterServer or Hetzner, where the whole root volume might be 20 to 40 GB shared with images and volumes, forty containers at 30 MB each is a real slice of your budget rather than a rounding error.</p>



<h2 class="wp-block-heading">Why your daemon.json change did nothing</h2>



<p class="wp-block-paragraph">This is the failure mode that costs the most time, because it looks exactly like the fix not working.</p>



<p class="wp-block-paragraph">Logging configuration is baked into a container&#8217;s host config when the container is <em>created</em>. Restarting the daemon does not rewrite it. Restarting the container does not rewrite it either, because <code>docker restart</code> stops and starts the same container object rather than making a new one. Your new limits apply to containers created after the daemon restart and to nothing else.</p>



<p class="wp-block-paragraph">So the container that filled your disk keeps filling your disk, its log file still has no cap, and <code>docker info</code> cheerfully reports the new defaults. That mismatch is exactly what the second command in the earlier block is for.</p>



<p class="wp-block-paragraph">To actually apply it, recreate the container and then verify rather than assuming:</p>



<pre class="wp-block-code"><code># Compose: recreate the service so it picks up the new daemon defaults
docker compose up -d --force-recreate web

# Verify the container is really carrying the limits
docker inspect --format '{{.HostConfig.LogConfig}}' web</code></pre>



<p class="wp-block-paragraph">You want to see the driver name followed by the map of options. An empty map means the container is still unbounded no matter what the daemon says.</p>



<p class="wp-block-paragraph">Note that recreating a container discards its existing log history along with the old container. If those logs matter for an ongoing investigation, copy them off first.</p>



<h3 class="wp-block-heading">Pinning limits per service in Compose</h3>



<p class="wp-block-paragraph">Daemon defaults are a floor, not a policy. A chatty ingress proxy and a quiet cron worker do not deserve the same budget, and per-service configuration in Compose overrides <code>daemon.json</code> entirely. A YAML anchor keeps it from turning into copy-paste sprawl:</p>



<pre class="wp-block-code"><code>x-logging: &amp;default-logging
  driver: json-file
  options:
    max-size: "10m"
    max-file: "3"
    compress: "true"

services:
  web:
    image: nginx:alpine
    logging: *default-logging

  worker:
    image: myorg/worker:latest
    logging:
      driver: json-file
      options:
        max-size: "50m"
        max-file: "3"
        compress: "true"</code></pre>



<p class="wp-block-paragraph">Be aware that the <code>logging</code> block replaces the daemon config rather than merging with it. If you specify a driver and forget the options, you get that driver with <em>its</em> defaults, which for <code>json-file</code> means straight back to unbounded. Half-specifying is worse than not specifying at all.</p>



<h2 class="wp-block-heading">You deleted the log file and df did not move</h2>



<p class="wp-block-paragraph">Classic 3am mistake, and it is worth understanding rather than memorising.</p>



<p class="wp-block-paragraph">When you <code>rm</code> the JSON log of a running container, you remove the directory entry. You do not remove the inode, because the Docker daemon still holds an open file descriptor pointing at it. The kernel keeps the data allocated until the last descriptor closes. So the file disappears from <code>ls</code>, the daemon keeps writing into a file you can no longer see, and <code>df</code> reports exactly the same usage as before. Space comes back only when you restart the daemon or recreate the container.</p>



<p class="wp-block-paragraph"><code>truncate -s 0</code> is the right tool. It zeroes the file in place without unlinking it, so the descriptor stays valid, the daemon keeps appending to the same inode, and the blocks are released immediately:</p>



<pre class="wp-block-code"><code>sudo truncate -s 0 "$(docker inspect --format '{{.LogPath}}' my-api)"</code></pre>



<p class="wp-block-paragraph">There is a cost. Truncating out from under the daemon can leave an in-flight <code>docker logs -f</code> stream reading from an offset that no longer exists, so the follow appears to hang with no new output until you reattach. It is a known behaviour rather than corruption, and reattaching clears it. Live with it during an incident, but do not build it into a cron job.</p>



<p class="wp-block-paragraph">The same reasoning applies to <code>logrotate</code> rules pointed at <code>/var/lib/docker/containers/*/*.log</code>. Rotating with <code>copytruncate</code> works mechanically, but you are reaching into files the daemon considers its own, and you inherit the follow-stream issue on every rotation cycle. Docker&#8217;s own docs warn against external tools touching those files. If the driver can rotate for you, let it.</p>



<h2 class="wp-block-heading">Choosing a driver, and what each one costs</h2>



<h3 class="wp-block-heading">json-file</h3>



<p class="wp-block-paragraph">The default. Widest tooling compatibility, since every log shipper and every scraper knows this format. <code>docker logs</code> works natively. Its genuine downside is the format itself: JSON per line is verbose, so you pay in disk and in parse cost on read. Reach for it when something downstream reads those files directly.</p>



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



<p class="wp-block-paragraph">Docker&#8217;s recommendation for standalone hosts, and the one I reach for first. It uses a more efficient on-disk format, and critically it <em>rotates and compresses by default</em>, retaining five files of 20 MB each per container out of the box. <code>docker logs</code> works exactly as normal. The trade-off is that the format is Docker&#8217;s own, so third-party agents that expect to tail JSON files cannot read it directly. If your entire log path is <code>docker logs</code> plus a shipper that talks to the daemon, this is the safest default you can pick.</p>



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



<p class="wp-block-paragraph">Hands container output to systemd&#8217;s journal, which means you get <code>journalctl</code> filtering, structured fields, and the journal&#8217;s own size management via <code>SystemMaxUse</code> in <code>journald.conf</code>. This is a good fit on hosts where you already read system logs that way and want one retention policy instead of two.</p>



<p class="wp-block-paragraph">The catch nobody mentions: the journal has rate limiting on by default. A container in a crash loop can trip it, and messages get dropped with a note saying how many were suppressed. That is fine for noise and terrible for the incident you are actually debugging. If you go this route, look at <code>RateLimitIntervalSec</code> and <code>RateLimitBurst</code> before you need them, not after.</p>



<h3 class="wp-block-heading">syslog, fluentd and the remote drivers</h3>



<p class="wp-block-paragraph">These push logs off the box to rsyslog, Fluentd, Loki, or a hosted platform. Disk pressure genuinely goes away because the host stops being the system of record. That is the right destination for anything beyond a handful of hosts.</p>



<p class="wp-block-paragraph">What you buy with it is a dependency. Your logging path now has a network hop, and the failure modes of that hop become your failure modes. Whether it is self-hosted Grafana Loki, or a managed service like Grafana Cloud, Better Stack or Datadog, the operational question is the same: what happens to the container when the collector is unreachable? Answer that during setup rather than during an outage.</p>



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



<p class="wp-block-paragraph">Discards output entirely. Legitimate for a container that already writes its own logs to a mounted volume, and a trap everywhere else, because <code>docker logs</code> returns nothing and you will waste twenty minutes concluding the container is broken.</p>



<h3 class="wp-block-heading">The blocking behaviour worth knowing about</h3>



<p class="wp-block-paragraph">By default the logging path is blocking. If the driver cannot keep up, the container&#8217;s write to stdout blocks, which means your application stalls. On a local file that is rarely an issue. Pointed at a remote collector that has gone slow, it means a logging problem becomes an application latency problem.</p>



<pre class="wp-block-code"><code>docker run -it 
  --log-opt mode=non-blocking 
  --log-opt max-buffer-size=4m 
  alpine ping 127.0.0.1</code></pre>



<p class="wp-block-paragraph">Non-blocking mode buffers in memory and drops messages once the buffer is full rather than stalling the writer. The default buffer is 1 MB. You are choosing which failure you prefer: a slow application, or missing log lines exactly when things are going wrong. There is no answer that is right everywhere, but for a user-facing service I would rather lose lines than add latency.</p>



<h3 class="wp-block-heading">Dual logging, and why docker logs still works</h3>



<p class="wp-block-paragraph">If you switch to a remote driver and find <code>docker logs</code> still returns output, that is dual logging. When the configured driver cannot serve reads, the engine keeps a local cache using the <code>local</code> driver so the command still works. That cache rotates by default, limited to five files of 20 MB each per container before compression.</p>



<p class="wp-block-paragraph">Useful to know for two reasons. It explains why disk usage does not drop to zero after you move logging off the box. And it means the local cache has its own retention that you can tune or disable separately from the driver you configured.</p>



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



<h2 class="wp-block-heading">Reclaiming space when the disk is already full</h2>



<p class="wp-block-paragraph">Order matters here. Free space first, fix the cause second, verify third.</p>



<ol class="wp-block-list">
<li>Confirm logs are actually the problem. Compare <code>du -sh /var/lib/docker/containers</code> against <code>docker system df</code>, which reports images, volumes and build cache. Images are often the real culprit and truncating logs will not help you.</li>

<li>Identify the largest log files with the <code>du</code> and <code>sort</code> command from earlier.</li>

<li>Copy anything you need out first, if the logs are part of an active investigation.</li>

<li>Truncate the offenders with <code>truncate -s 0</code>. Never <code>rm</code>.</li>

<li>Set the limits in <code>daemon.json</code>, validate the JSON, restart the daemon.</li>

<li>Recreate the containers so the limits take effect, then confirm with <code>docker inspect</code> on each one.</li>

<li>Add a disk alert. A full disk should never be discovered by a human noticing something else is broken.</li>
</ol>



<p class="wp-block-paragraph">Step seven is the one that actually stops this recurring. Rotation caps the damage, but a container that suddenly starts logging a hundred times more than usual is telling you something, and a disk-usage alert on the Docker data root is how you hear it.</p>



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



<p class="wp-block-paragraph"><strong>Rotation is configured but files still grow past the cap.</strong> The container predates the change. Check <code>docker inspect --format '{{.HostConfig.LogConfig}}'</code> and recreate it.</p>



<p class="wp-block-paragraph"><strong>The daemon will not start after editing daemon.json.</strong> Almost always invalid JSON, or unquoted values in <code>log-opts</code>. Read the actual error with <code>journalctl -u docker --no-pager -n 50</code> rather than guessing.</p>



<p class="wp-block-paragraph"><strong>max-file is set but only one file ever appears.</strong> <code>max-file</code> does nothing without <code>max-size</code>. Set both.</p>



<p class="wp-block-paragraph"><strong>docker logs -f hangs after showing existing output.</strong> Something truncated the file underneath the daemon, usually an external <code>logrotate</code> rule. Reattach to recover, then remove the rule.</p>



<p class="wp-block-paragraph"><strong>docker logs returns nothing at all.</strong> Check the driver. <code>none</code> discards everything, and some remote drivers only serve reads through the dual-logging cache.</p>



<p class="wp-block-paragraph"><strong>Disk usage did not drop after deleting log files.</strong> The daemon still holds the descriptors. Restart the daemon to release the inodes, and use <code>truncate</code> next time.</p>



<p class="wp-block-paragraph"><strong>Logs vanish sooner than the retention you configured.</strong> On <code>journald</code>, check journal rate limiting and <code>SystemMaxUse</code>. On a remote driver in non-blocking mode, check whether the buffer is overflowing.</p>



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



<ul class="wp-block-list">
<li>Assuming a daemon restart applies the new limits to running containers. It does not.</li>

<li>Setting <code>max-file</code> without <code>max-size</code> and believing rotation is on.</li>

<li>Writing numeric values unquoted in <code>log-opts</code>.</li>

<li>Overwriting an existing <code>daemon.json</code> instead of merging, and silently dropping storage or network settings.</li>

<li>Using <code>rm</code> on a live container&#8217;s log file and concluding the disk report is broken.</li>

<li>Specifying a <code>logging.driver</code> in Compose without its <code>options</code>, which resets that service back to the driver&#8217;s own unbounded defaults.</li>

<li>Pointing host <code>logrotate</code> at the Docker containers directory when the driver could have handled it.</li>

<li>Sizing rotation per container without multiplying by container count against the actual disk.</li>
</ul>



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



<ul class="wp-block-list">
<li>Set logging limits in <code>daemon.json</code> as part of host provisioning, before any container exists. This belongs in your Ansible role or cloud-init, not in a runbook.</li>

<li>Prefer the <code>local</code> driver on standalone hosts unless something specifically needs to read the JSON files.</li>

<li>Turn on <code>compress</code>. Log text compresses well enough that you get more retention for the same disk.</li>

<li>Size per service rather than uniformly. Give the noisy proxy a tight cap and the service you actually debug a generous one.</li>

<li>Do the multiplication. Worst-case host usage is <code>max-size</code> × <code>max-file</code> × container count, and it should be a fraction of your disk, not most of it.</li>

<li>Alert on Docker data root usage at a threshold that leaves you time to act.</li>

<li>Treat on-host logs as a short buffer, not an archive. Anything you need next quarter belongs in a log platform, not on the VPS.</li>

<li>Verify with <code>docker inspect</code> after any change. <code>docker info</code> tells you the daemon&#8217;s intent, not what your containers carry.</li>
</ul>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">Does Docker rotate container logs automatically?</h3>



<p class="wp-block-paragraph">Not with the default driver. <code>json-file</code> ships with <code>max-size</code> unlimited and <code>max-file</code> set to 1, so a single file grows without bound. The <code>local</code> driver does rotate and compress by default. If you have not explicitly configured Docker log rotation, assume it is off.</p>



<h3 class="wp-block-heading">Where are Docker container logs stored on the host?</h3>



<p class="wp-block-paragraph">Under the Docker data root, one directory per container, which on a standard Linux install means <code>/var/lib/docker/containers/</code>. Rather than assembling the path by hand, ask the daemon: <code>docker inspect --format '{{.LogPath}}' &lt;container&gt;</code>. That works regardless of a custom <code>data-root</code>.</p>



<h3 class="wp-block-heading">How do I clear Docker logs without stopping the container?</h3>



<p class="wp-block-paragraph">Truncate the file to zero bytes with <code>truncate -s 0</code> on the path from <code>{{.LogPath}}</code>. Do not use <code>rm</code>: the daemon holds an open descriptor, so deleting the directory entry leaves the data allocated and frees no space until the daemon restarts.</p>



<h3 class="wp-block-heading">Why did my daemon.json rotation settings not apply?</h3>



<p class="wp-block-paragraph">Because logging config is fixed at container creation time. A daemon restart changes what new containers get; it does not rewrite existing ones, and <code>docker restart</code> reuses the same container object. Recreate the container, then confirm with <code>docker inspect --format '{{.HostConfig.LogConfig}}'</code>.</p>



<h3 class="wp-block-heading">Should I use the local driver or json-file?</h3>



<p class="wp-block-paragraph">Use <code>local</code> if nothing outside Docker reads the log files directly, since it rotates and compresses by default and uses less disk for the same content. Use <code>json-file</code> if an agent or shipper on the host tails those files, because it cannot parse the <code>local</code> format. Either way <code>docker logs</code> behaves the same.</p>



<h3 class="wp-block-heading">Can I use logrotate for Docker container logs instead?</h3>



<p class="wp-block-paragraph">You can, and plenty of hosts do, but the driver options are the better tool. A <code>copytruncate</code> rule pointed at the containers directory reaches into files the daemon treats as private, and can leave <code>docker logs -f</code> streams stalled after each rotation. Use <code>logrotate</code> for logs your applications write to mounted volumes, and let the driver handle stdout.</p>



<h3 class="wp-block-heading">How much disk should I budget for container logs?</h3>



<p class="wp-block-paragraph">Worst case is <code>max-size</code> × <code>max-file</code> per container, multiplied by how many containers run on the host. At 10m and 3 files across thirty containers that is roughly 900 MB before compression. Pick numbers that leave the total at a comfortable fraction of the disk, and keep long-term retention off the box.</p>



<h2 class="wp-block-heading">The one thing worth remembering</h2>



<p class="wp-block-paragraph">Docker log rotation is not a feature you turn on once at the daemon and forget. It is a property each container carries from the moment it is created, and every container that existed before your change is still running under the old rules.</p>



<p class="wp-block-paragraph">So the working sequence is always the same: set the limits, restart the daemon, recreate the containers, then verify with <code>docker inspect</code> rather than trusting <code>docker info</code>. Add a disk alert on the Docker data root so the next surprise arrives as a notification and not as a failed deploy. Everything else in this post is detail around that.</p>



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



<h2 class="wp-block-heading">Need a hand with container logging on your hosts?</h2>



<p class="wp-block-paragraph">Log sprawl is one of those problems that is cheap to fix in an hour and expensive to leave alone. If you would rather have someone go through it with you, this is the kind of work I take on:</p>



<ul class="wp-block-list">
<li>Auditing every container on a host for logging driver and retention, and producing the recreate plan that actually applies the limits</li>

<li>Sizing a rotation budget against your real disk, container count and log volume, per service rather than one flat number</li>

<li>Emergency recovery on a full Docker host, including the cases where deleting files did not give the space back</li>

<li>Choosing between <code>json-file</code>, <code>local</code>, <code>journald</code> and shipping off-box, including the blocking behaviour trade-offs under load</li>

<li>Setting up centralised logging with Loki, rsyslog or a hosted platform, with sane retention on both ends</li>

<li>Baking logging limits into provisioning so new hosts are never born unbounded, plus the disk alerts to catch what rotation cannot</li>
</ul>



<p class="wp-block-paragraph">If you want a second opinion, send me your <code>daemon.json</code>, the output of <code>docker ps</code> and a <code>du -sh</code> of your containers directory, and I will tell you what I would change.</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/devops/docker-log-rotation/">Docker Logs Ate My Disk: A Working Guide to Log Drivers and Rotation</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>journald vs rsyslog: Where Your Linux Logs Actually Live</title>
		<link>https://john-nessime.com/blog/devops/journald-vs-rsyslog-where-logs-live/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Sat, 08 Aug 2026 18:00:00 +0000</pubDate>
				<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Linux]]></category>
		<category><![CDATA[System Administration]]></category>
		<category><![CDATA[Disk Space]]></category>
		<category><![CDATA[journalctl]]></category>
		<category><![CDATA[journald]]></category>
		<category><![CDATA[Log Retention]]></category>
		<category><![CDATA[Logging]]></category>
		<category><![CDATA[Logs]]></category>
		<category><![CDATA[Observability]]></category>
		<category><![CDATA[Production]]></category>
		<category><![CDATA[rsyslog]]></category>
		<category><![CDATA[Sysadmin]]></category>
		<category><![CDATA[Syslog]]></category>
		<category><![CDATA[Systemd]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[VPS]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=168</guid>

					<description><![CDATA[<p>On a stock Linux server, journald and rsyslog are both running and both storing the same events, with retention policies that don't know about each other. Here's where each log line actually lives, the three failure modes that bite in production, and how to decide which copy you keep.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/journald-vs-rsyslog-where-logs-live/">journald vs rsyslog: Where Your Linux Logs Actually Live</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The disk alert usually comes first. <code>/var</code> sitting at 94 percent on a box that runs three services and logs almost nothing interesting. You go digging and find the same night&#8217;s SSH failures in two places: compressed binary files under <code>/var/log/journal/</code>, and plain text in <code>/var/log/secure</code>. Same events. Two copies. Nobody decided that. It is just what the distro shipped.</p>



<p class="wp-block-paragraph">The worse version of this problem is the mirror image. You reboot a server to clear something, come back to read what led up to it, and the journal starts at the reboot. Everything before is gone. Not rotated, not compressed, not archived somewhere clever. Gone, because the journal was living in RAM and nobody ever told it otherwise.</p>



<p class="wp-block-paragraph">Both of those are the same misunderstanding wearing different clothes: not knowing where your logs actually live. This post walks through the <strong>journald vs rsyslog</strong> split on a modern Linux server, what each one really stores, the three failure modes that bite in production, and how to decide which of the two is the copy you keep.</p>



<h2 class="wp-block-heading">What actually happens when a process logs a line</h2>



<p class="wp-block-paragraph">On any systemd-based distribution, <code>systemd-journald</code> is the first stop for almost everything. It is not one option among several. It sits underneath.</p>



<ul class="wp-block-list">
<li>Anything a unit writes to stdout or stderr, because systemd wires those to the journal by default.</li>

<li>Kernel messages, read from the kernel ring buffer.</li>

<li>Classic <code>syslog(3)</code> calls from libc, which land on the <code>/dev/log</code> socket that journald owns.</li>

<li>Native journal API calls from anything linked against libsystemd, which carry structured key-value fields instead of a flat string.</li>

<li>Audit records, if <code>Audit=yes</code> is in effect.</li>
</ul>



<p class="wp-block-paragraph">journald writes all of that into indexed binary journal files. Then, on most server distributions, rsyslog gets a <em>second copy</em> of the same events and writes them out as text into <code>/var/log/messages</code>, <code>/var/log/secure</code>, <code>/var/log/maillog</code> and friends.</p>



<p class="wp-block-paragraph">That handoff happens one of two ways, and which one your box uses matters more than most people expect. More on that below. First, the part that costs you an outage.</p>



<p class="wp-block-paragraph">Before changing anything, look at the effective journald configuration rather than the file you think is authoritative. Distributions ship drop-ins under <code>/usr/lib/systemd/journald.conf.d/</code> that quietly override upstream defaults:</p>



<pre class="wp-block-code"><code># Print the merged configuration, including every drop-in, in load order
systemd-analyze cat-config systemd/journald.conf</code></pre>



<p class="wp-block-paragraph">This is the single most useful command in this whole post. Half the arguments about &#8220;what the default is&#8221; evaporate once you run it, because upstream systemd and your distribution frequently disagree about <code>ForwardToSyslog</code>.</p>



<h2 class="wp-block-heading">Failure one: the journal that was never on disk</h2>



<p class="wp-block-paragraph">journald&#8217;s <code>Storage=</code> setting defaults to <code>auto</code>. That word does more work than it looks like. Under <code>auto</code>, journald writes to <code>/var/log/journal/</code> <em>only if that directory already exists</em>. If it does not, journald falls back to <code>/run/log/journal/</code>, which is tmpfs. Memory. Wiped on reboot.</p>



<p class="wp-block-paragraph">Nothing warns you. <code>journalctl</code> works fine, colours are pretty, filters work. You just silently have no history.</p>



<p class="wp-block-paragraph">The quickest test is boot history. If the journal is persistent you see multiple boots; if it is volatile you see exactly one:</p>



<pre class="wp-block-code"><code>journalctl --list-boots</code></pre>



<p class="wp-block-paragraph">To make it persistent, create the directory, let systemd-tmpfiles apply the correct ownership and ACLs, then restart the daemon and flush anything still held in RAM:</p>



<pre class="wp-block-code"><code>sudo mkdir -p /var/log/journal
sudo systemd-tmpfiles --create --prefix /var/log/journal
sudo systemctl restart systemd-journald
sudo journalctl --flush
journalctl --list-boots</code></pre>



<p class="wp-block-paragraph">The <code>systemd-tmpfiles</code> step is the one people skip. The journal directory needs specific group ownership and ACLs so that unprivileged users can read their own entries and members of the <code>systemd-journal</code> group can read everything. Creating the directory with a bare <code>mkdir</code> and walking away usually works, but it is the kind of &#8220;usually&#8221; that produces a confusing permissions ticket six months later.</p>



<p class="wp-block-paragraph">If you want the behaviour to be explicit rather than inferred from a directory&#8217;s existence, set it in <code>/etc/systemd/journald.conf.d/00-storage.conf</code>:</p>



<pre class="wp-block-code"><code>[Journal]
Storage=persistent</code></pre>



<p class="wp-block-paragraph">A drop-in file is better than editing the main config, because package upgrades will not fight you over it.</p>



<h2 class="wp-block-heading">Failure two: paying for the same log line twice</h2>



<p class="wp-block-paragraph">Once the journal is persistent, you are storing everything twice on a default server build: once as compressed binary in the journal, once as text under <code>/var/log/</code>. Two stores, two completely separate retention policies, neither of which knows the other exists.</p>



<p class="wp-block-paragraph">The journal is capped by <code>SystemMaxUse=</code> and <code>SystemKeepFree=</code>. Left unset, they default to 10 percent and 15 percent of the filesystem respectively, and journald honours whichever is stricter. Recent systemd also caps those calculated defaults so they do not grow without bound on a large disk, which is worth knowing if you have ever wondered why a huge volume did not produce a proportionally huge journal.</p>



<p class="wp-block-paragraph">The text copy is capped by <code>logrotate</code>, configured somewhere completely different, usually <code>/etc/logrotate.d/rsyslog</code>, on a weekly or daily schedule with its own <code>rotate</code> count.</p>



<p class="wp-block-paragraph">Measure both before you tune either:</p>



<pre class="wp-block-code"><code># What the journal is actually consuming on disk
journalctl --disk-usage

# What everything under /var/log costs, sorted, biggest last
sudo du -sh /var/log/* | sort -h</code></pre>



<p class="wp-block-paragraph">On a small VPS this stops being academic quickly. A modest instance from a provider like InterServer, Hetzner or DigitalOcean often has <code>/var</code> sharing a single root volume of 20 to 40 GB. A journal allowed to take 10 percent of that, plus four weeks of rotated text logs, plus a database, plus a container image cache, is how you end up paged at two in the morning for something that is not a real incident.</p>



<p class="wp-block-paragraph">If you decide the text files are the copy you keep, put the journal on a short leash:</p>



<pre class="wp-block-code"><code>[Journal]
# Hard ceiling on total journal size
SystemMaxUse=200M
# Never let the journal push free space below this
SystemKeepFree=1G
# Discard entries older than this regardless of size
MaxRetentionSec=2day</code></pre>



<p class="wp-block-paragraph">Those three do different jobs and you generally want at least two of them. <code>SystemMaxUse</code> bounds the journal itself. <code>SystemKeepFree</code> protects the rest of the filesystem from the journal. <code>MaxRetentionSec</code> bounds it in time, which is what auditors and data-retention policies actually care about.</p>



<p class="wp-block-paragraph">To reclaim space immediately without waiting for the next rotation:</p>



<pre class="wp-block-code"><code># Trim archived journal files until the total falls below 500M
sudo journalctl --vacuum-size=500M

# Or trim by age instead
sudo journalctl --vacuum-time=7d</code></pre>



<p class="wp-block-paragraph">One caveat that surprises people: vacuuming only removes <em>archived</em> journal files. The currently active file is never deleted. If almost all your usage is in one large active file, run <code>journalctl --rotate</code> first, then vacuum.</p>



<h2 class="wp-block-heading">Failure three: two rate limiters, both silent</h2>



<p class="wp-block-paragraph">This is the one that is invisible until it matters, and it is the real reason to understand the journald vs rsyslog relationship rather than treating them as interchangeable.</p>



<p class="wp-block-paragraph">There are <em>two independent rate limiters</em> in the default pipeline, and both drop messages quietly.</p>



<h3 class="wp-block-heading">journald&#8217;s limiter</h3>



<p class="wp-block-paragraph">journald applies <code>RateLimitIntervalSec=</code> and <code>RateLimitBurst=</code> per service, defaulting to 30 seconds and 10000 messages. Exceed the burst inside the interval and the rest of that service&#8217;s messages in that window are discarded. Not queued. Discarded.</p>



<p class="wp-block-paragraph">The one piece of good news is that journald tells you, in the journal itself. Grep for it:</p>



<pre class="wp-block-code"><code>journalctl --grep="Suppressed" -n 50 --no-pager</code></pre>



<p class="wp-block-paragraph">A chatty web server or a container using the journald log driver will hit this. Rather than disabling rate limiting globally, override it for the one unit that needs it, using a drop-in:</p>



<pre class="wp-block-code"><code># /etc/systemd/system/nginx.service.d/logging.conf
[Service]
LogRateLimitIntervalSec=30s
LogRateLimitBurst=50000</code></pre>



<p class="wp-block-paragraph">Per-unit values override <code>journald.conf</code> for that unit only, which is exactly what you want. Turning the global limiter off means one runaway process can fill your disk and take the box down. Turning it up for the single service you know is noisy is a bounded decision.</p>



<h3 class="wp-block-heading">rsyslog&#8217;s limiter</h3>



<p class="wp-block-paragraph">If rsyslog is reading via the <code>imjournal</code> module, it applies <em>its own</em> rate limit on top, defaulting to 20000 messages per 600 seconds. A message can therefore survive journald&#8217;s limiter, land in the journal, and still never reach <code>/var/log/messages</code> or your remote log host.</p>



<p class="wp-block-paragraph">That is the nasty case, because your local investigation finds the events but your centralised alerting never fired. Two people look at two sources and reach opposite conclusions about whether something happened.</p>



<p class="wp-block-paragraph">rsyslog exposes counters for this through its statistics module, including how many messages it read from the journal, how many it submitted onward, and how many it discarded to rate limiting. Enable <code>impstat</code> if you run anything log-volume-sensitive; the discard counter is the number that tells you whether your pipeline is lying to you.</p>



<h2 class="wp-block-heading">How rsyslog gets its copy: imjournal or imuxsock</h2>



<p class="wp-block-paragraph">There are two mechanisms, they behave differently under load, and running both at once is a classic source of duplicate lines.</p>



<h3 class="wp-block-heading">imuxsock: journald pushes a copy to a socket</h3>



<p class="wp-block-paragraph">With <code>ForwardToSyslog=yes</code> in journald, journald writes a classic syslog-formatted copy of each message to a dedicated socket, and rsyslog reads it with <code>imuxsock</code>.</p>



<ul class="wp-block-list">
<li><strong>Where it wins:</strong> simple, no state file, no second rate limiter, and no possibility of rsyslog reading back its own output and looping.</li>

<li><strong>Where it does not:</strong> you get the flat syslog view only. Structured journal fields such as the originating unit are not carried across.</li>
</ul>



<h3 class="wp-block-heading">imjournal: rsyslog pulls from the journal</h3>



<p class="wp-block-paragraph"><code>imjournal</code> reads journal files directly and keeps a state file so it can resume where it left off after a restart.</p>



<ul class="wp-block-list">
<li><strong>Where it wins:</strong> structured fields survive the trip. If you want to route or template on the systemd unit name rather than parsing it out of a message string, this is the only option of the two.</li>

<li><strong>Where it does not:</strong> extra rate limiter to reason about, a state file that can drift, and a documented risk that a corrupted journal database causes rsyslog to re-read the same entries in a loop. rsyslog&#8217;s own documentation recommends using <code>imuxsock</code> instead unless you specifically need the structured data.</li>
</ul>



<p class="wp-block-paragraph">A representative <code>imjournal</code> load line, with the rate limit stated explicitly rather than left implicit:</p>



<pre class="wp-block-code"><code>module(load="imjournal"
       StateFile="imjournal.state"
       Ratelimit.Interval="600"
       Ratelimit.Burst="20000")</code></pre>



<p class="wp-block-paragraph">Writing the defaults out by hand is worth the two extra lines. The next person to debug missing messages will see the limiter exists instead of assuming there isn&#8217;t one.</p>



<p class="wp-block-paragraph">To find out which mechanism your box is using right now:</p>



<pre class="wp-block-code"><code>grep -R "imjournal|imuxsock" /etc/rsyslog.conf /etc/rsyslog.d/ 2&gt;/dev/null
grep -R "ForwardToSyslog" /etc/systemd/journald.conf /etc/systemd/journald.conf.d/ /usr/lib/systemd/journald.conf.d/ 2&gt;/dev/null</code></pre>



<p class="wp-block-paragraph">If the first command returns both modules and the second returns <code>ForwardToSyslog=yes</code>, you have a duplication problem. Pick one path and disable the other.</p>



<h2 class="wp-block-heading">Only one of the two leaves the box</h2>



<p class="wp-block-paragraph">This is the argument that settles most journald vs rsyslog debates in practice. journald is a local store. In a stock server install it has no mechanism for shipping logs to another host. There are separate systemd components for journal upload and reception, and third-party projects that read the journal and forward it, but none of that is in the default path.</p>



<p class="wp-block-paragraph">rsyslog forwards as a first-class feature, and it does the part that actually matters: it buffers when the destination is unreachable.</p>



<pre class="wp-block-code"><code>global(workDirectory="/var/spool/rsyslog")

action(type="omfwd"
       target="logs.example.net"
       port="514"
       protocol="tcp"
       action.resumeRetryCount="-1"
       queue.type="LinkedList"
       queue.filename="fwd_loghost"
       queue.maxDiskSpace="1g"
       queue.saveOnShutdown="on")</code></pre>



<p class="wp-block-paragraph">Line by line, because these are not decoration:</p>



<ul class="wp-block-list">
<li><code>workDirectory</code> is where spool files get written. Without it, the disk queue has nowhere to go.</li>

<li><code>protocol="tcp"</code> rather than UDP. UDP syslog silently drops under congestion, which defeats the point of forwarding at all.</li>

<li><code>queue.type="LinkedList"</code> makes the action asynchronous so a slow destination does not block local processing.</li>

<li><code>queue.filename</code> is what actually enables disk assistance. In-memory queue first, spilling to disk when it fills. The name must be unique across every action in the config.</li>

<li><code>queue.maxDiskSpace</code> caps that spool. Set it, or a long outage at the far end fills the disk you were trying to protect.</li>

<li><code>queue.saveOnShutdown="on"</code> persists whatever is still queued across a service restart.</li>
</ul>



<p class="wp-block-paragraph">One honest caveat on <code>action.resumeRetryCount="-1"</code>. Infinite retry is the right default for &#8220;never lose a log line&#8221;, but there are reported cases where a relay under sustained back-pressure stops accepting new inbound messages while retrying forever. If your host is a relay rather than a leaf, test that behaviour deliberately with the destination firewalled off before you trust it.</p>



<p class="wp-block-paragraph">rsyslog is not the only way off the box. Grafana Alloy into Loki, Vector, and Fluent Bit all read the journal directly and speak modern backends, and hosted platforms such as Better Stack, Papertrail or Datadog will take a plain syslog stream if you would rather not run the storage side. The mechanism differs; the decision does not. Something has to own the write path off the machine, and journald on its own is not it.</p>



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



<h2 class="wp-block-heading">journald vs rsyslog: how I would decide</h2>



<p class="wp-block-paragraph">Stop asking which is better. Ask which one is the copy you are willing to be judged on, then make the other one cheap.</p>



<ol class="wp-block-list">
<li><strong>Do logs need to leave this machine?</strong> If yes, rsyslog or a dedicated shipper owns the outbound path. That is settled before anything else.</li>

<li><strong>Do you troubleshoot mostly by unit?</strong> If <code>journalctl -u something -b</code> is your muscle memory, journald is your primary read path and should get the retention budget.</li>

<li><strong>Do existing tools read text files?</strong> Fail2ban, logwatch, older SIEM collectors and a lot of home-grown scripts parse <code>/var/log/*</code>. Turning rsyslog off breaks them quietly.</li>

<li><strong>How much disk do you actually have?</strong> Under about 40 GB, keeping two full copies is an unforced error. Cap one hard.</li>

<li><strong>Is tamper-evidence a requirement?</strong> journald supports Forward Secure Sealing, which detects after-the-fact modification of journal files. Shipping to an append-only remote store is the more commonly accepted answer, and the two are not mutually exclusive.</li>
</ol>



<p class="wp-block-paragraph">The configuration I reach for first on a general-purpose server: journald persistent with a hard cap for a few days of fast local querying, rsyslog kept for the syslog files that other tools expect plus reliable forwarding off-box, <code>imuxsock</code> as the handoff unless something genuinely needs structured fields, and logrotate retention trimmed down because the remote copy is the one that matters for anything older than a week.</p>



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



<ul class="wp-block-list">
<li><strong>Journal only shows the current boot.</strong> Volatile storage. <code>journalctl --list-boots</code> confirms it, and the fix is the persistent-storage sequence above.</li>

<li><strong>Events in journalctl but not in /var/log/messages.</strong> Either rsyslog is not reading from the journal at all, or the <code>imjournal</code> rate limiter is discarding. Check which input module is loaded first.</li>

<li><strong>Every line appears twice in the text logs.</strong> Both <code>imjournal</code> and <code>imuxsock</code> are active while <code>ForwardToSyslog=yes</code>. Disable one path.</li>

<li><strong>Bursts of messages disappear during incidents.</strong> That is a rate limiter, and incidents are exactly when services get loud. Search for suppression notices and raise the limit for that specific unit.</li>

<li><strong>journalctl reports corruption or behaves oddly.</strong> Run <code>journalctl --verify</code>. If files are damaged, <code>journalctl --rotate</code> starts a fresh active file so new writes are clean, then vacuum the bad archives.</li>

<li><strong>&#8220;Journal has been rotated since unit was started.&#8221;</strong> Not an error. Rotation happened mid-session, so <code>journalctl -u</code> cannot map the full range. Re-run the query without the unit filter or widen the time range.</li>

<li><strong>Disk still full after vacuuming.</strong> Vacuum skips the active journal file. Rotate first, then vacuum again.</li>
</ul>



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



<ul class="wp-block-list">
<li>Assuming the journal is persistent because <code>journalctl</code> returns results. It always returns results. The question is how far back.</li>

<li>Disabling rsyslog on a box where fail2ban, logwatch or a scraper still reads <code>/var/log/secure</code>. Nothing errors. Detection just stops.</li>

<li>Setting <code>RateLimitBurst=0</code> globally to &#8220;stop losing logs&#8221;, then having a crash-looping service fill the disk in an afternoon.</li>

<li>Editing <code>/etc/systemd/journald.conf</code> directly and being surprised when a vendor drop-in overrides it. Use a drop-in of your own with a name that sorts later.</li>

<li>Forwarding over UDP because it is the one-line version. It drops silently under exactly the load that produced the logs you wanted.</li>

<li>Enabling a disk-assisted queue without <code>queue.maxDiskSpace</code>, turning a remote outage into a local disk-full outage.</li>

<li>Tuning journald limits on a machine whose real problem is that one application logs every health check at info level. Fix the source first.</li>
</ul>



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



<ul class="wp-block-list">
<li>Make storage explicit. <code>Storage=persistent</code> or <code>Storage=volatile</code>, never left to <code>auto</code> on a server you care about.</li>

<li>Always set <code>SystemKeepFree</code> alongside <code>SystemMaxUse</code>. The first bounds the journal, the second protects everything else on the volume from it.</li>

<li>Decide consciously which store is authoritative, and write it in the runbook. Two stores with no stated owner means neither gets maintained.</li>

<li>Use exactly one journal-to-rsyslog path. Both is duplication; neither is silent data loss.</li>

<li>Override rate limits per unit, not globally.</li>

<li>Monitor <code>journalctl --disk-usage</code> as a metric, not as something you check after the alert.</li>

<li>Ship off-box with TCP and a bounded disk-assisted queue, and test it with the destination firewalled off before you rely on it.</li>

<li>Keep the local copy short and the remote copy long. Local disk is the expensive place to store history.</li>
</ul>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">Can I just disable rsyslog and use journald only?</h3>



<p class="wp-block-paragraph">On a self-contained box with no remote logging and nothing parsing text files, yes, and it saves real disk. Before you do it, grep your configuration management and cron jobs for <code>/var/log/</code> paths, and check whether fail2ban or any monitoring agent reads those files. That is where this bites people.</p>



<h3 class="wp-block-heading">Can I disable journald and use rsyslog only?</h3>



<p class="wp-block-paragraph">Not meaningfully. journald is how systemd captures unit stdout and stderr, so it stays in the path regardless. What you can do is set <code>Storage=volatile</code> with a small <code>RuntimeMaxUse</code> so it holds a short in-memory window and rsyslog owns everything durable.</p>



<h3 class="wp-block-heading">Where are journald logs stored?</h3>



<p class="wp-block-paragraph"><code>/var/log/journal/</code> when persistent, <code>/run/log/journal/</code> when volatile. They are indexed binary files, not text, so <code>grep</code> and <code>tail</code> do not work on them directly. Use <code>journalctl</code>, or <code>journalctl -o json</code> if you want to pipe structured output into something else.</p>



<h3 class="wp-block-heading">Why does journalctl show entries that never reached /var/log/messages?</h3>



<p class="wp-block-paragraph">Three usual causes: rsyslog is not reading the journal at all, the <code>imjournal</code> rate limiter dropped them, or a severity filter such as <code>MaxLevelSyslog</code> or an rsyslog rule excluded that facility or priority. Check in that order.</p>



<h3 class="wp-block-heading">Is the binary journal format a problem for log analysis?</h3>



<p class="wp-block-paragraph">Locally, no. Structured fields and fast filtering are genuinely better than parsing text with regex. It becomes a problem when you need to move logs somewhere else, because almost every collector expects text or JSON. That conversion step is precisely the role rsyslog or a modern shipper plays.</p>



<h3 class="wp-block-heading">How much disk should the journal be allowed to use?</h3>



<p class="wp-block-paragraph">Work backwards from how far back you actually query locally. If you rarely look past yesterday, a few hundred megabytes with <code>MaxRetentionSec</code> set to two or three days is plenty. Anything older belongs in a remote store where it is cheaper and survives the machine.</p>



<h3 class="wp-block-heading">Does journald compress logs automatically?</h3>



<p class="wp-block-paragraph">Yes. <code>Compress=</code> defaults to enabled and applies to entries above a size threshold, which is one reason the journal is often smaller than the equivalent text files despite carrying more metadata. It does not remove the need for a size cap.</p>



<h2 class="wp-block-heading">The one thing worth remembering</h2>



<p class="wp-block-paragraph">The journald vs rsyslog question is not a contest. On a stock server both are running, both are storing the same events, and the defaults were chosen by your distribution rather than by you. The failure modes all come from that: a journal in RAM that vanishes on reboot, two uncoordinated retention policies eating the same disk, and two rate limiters dropping messages without anyone noticing.</p>



<p class="wp-block-paragraph">Run <code>systemd-analyze cat-config systemd/journald.conf</code> and <code>journalctl --list-boots</code> on your servers. Two commands, thirty seconds, and you will know whether the logs you would reach for during an incident are actually there.</p>



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



<h2 class="wp-block-heading">Need help sorting out your logging pipeline?</h2>



<p class="wp-block-paragraph">Logging is one of those areas where the defaults work well enough to hide the problem until an incident makes it expensive. If any of the above sounded familiar, these are the things I take on:</p>



<ul class="wp-block-list">
<li>Auditing journald and rsyslog on existing servers and telling you exactly where each log line lives, how long it survives, and what is being silently dropped.</li>

<li>Fixing volatile journals, runaway <code>/var</code> growth and duplicate text-and-binary storage on small VPS instances.</li>

<li>Setting up reliable off-box forwarding with disk-assisted queues, TCP or RELP transport, and TLS where it is required.</li>

<li>Migrating from text-file scraping to a structured pipeline into Loki, Elasticsearch or a hosted log platform, without losing the alerts you already depend on.</li>

<li>Tuning rate limits and retention per service so noisy applications stop hiding the messages that matter.</li>

<li>Writing the runbook that says which store is authoritative, so the next engineer does not have to reverse-engineer it during an outage.</li>
</ul>



<p class="wp-block-paragraph">Send me your <code>journald.conf</code>, your <code>rsyslog.conf</code>, or the output of <code>journalctl --disk-usage</code> and I will tell you what I see before we talk about scope.</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/devops/journald-vs-rsyslog-where-logs-live/">journald vs rsyslog: Where Your Linux Logs Actually Live</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>One Label Too Many: Centralized Logging With Loki Without Wrecking It</title>
		<link>https://john-nessime.com/blog/devops/centralized-logging-loki/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Sat, 01 Aug 2026 09:24:17 +0000</pubDate>
				<category><![CDATA[DevOps]]></category>
		<category><![CDATA[System Administration]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Docker]]></category>
		<category><![CDATA[Grafana]]></category>
		<category><![CDATA[Grafana Alloy]]></category>
		<category><![CDATA[Infrastructure]]></category>
		<category><![CDATA[Linux]]></category>
		<category><![CDATA[Logging]]></category>
		<category><![CDATA[LogQL]]></category>
		<category><![CDATA[Logs]]></category>
		<category><![CDATA[Loki]]></category>
		<category><![CDATA[Monitoring]]></category>
		<category><![CDATA[Observability]]></category>
		<category><![CDATA[Production]]></category>
		<category><![CDATA[Prometheus]]></category>
		<category><![CDATA[Self Hosting]]></category>
		<category><![CDATA[SRE]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=68</guid>

					<description><![CDATA[<p>Loki was fast until someone added one more label. Now queries time out during incidents and the index no longer fits in memory. Here's the model you have to internalise, what replaced Promtail, and how to keep labels, retention and storage costs under control.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/centralized-logging-loki/">One Label Too Many: Centralized Logging With Loki Without Wrecking It</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The alert fires at eleven at night. You open Grafana, pick the right time range, type a query, and watch the spinner. Forty seconds later it times out. You narrow the range to five minutes and it comes back, eventually. The one tool you need during an incident is the one that has become unusable.</p>



<p class="wp-block-paragraph">Nothing about the Loki config changed. What changed is that three weeks ago somebody added <code>request_id</code> as a label because it seemed helpful, and Loki has been quietly building millions of separate streams ever since. The index that used to fit comfortably in memory does not. Every query now has to open thousands of tiny chunks instead of a handful of large ones.</p>



<p class="wp-block-paragraph">This is the failure mode that defines <strong>centralized logging with Loki</strong>, and it is almost entirely self-inflicted. Loki is cheap and fast when you use it the way it was designed and miserable when you bring habits from Elasticsearch. Nothing warns you at the moment you make the mistake. The bill comes weeks later, during an incident.</p>



<p class="wp-block-paragraph">This covers the model you have to internalise, how to choose labels, what replaced Promtail (it is gone, and every tutorial older than this year is wrong about it), the LogQL patterns worth knowing, and how to keep retention and storage costs under control.</p>



<h2 class="wp-block-heading">Loki is not Elasticsearch, and that is the entire design</h2>



<p class="wp-block-paragraph">Elasticsearch builds a full inverted index over every token in every log line. Powerful, and the index often ends up as large as the logs themselves. That is where the cost comes from.</p>



<p class="wp-block-paragraph">Loki indexes only labels. The log content is compressed into chunks and pushed to object storage, untouched and unindexed. A query does two things: it uses the index to select which streams to read, then it brute-force scans the contents of those chunks in parallel.</p>



<p class="wp-block-paragraph">The consequence is a rule worth writing on a wall: <strong>labels select, filters search.</strong> Labels exist to narrow down which chunks get opened. Everything else is a filter applied at query time. Scanning is genuinely fast because it is embarrassingly parallel, so filtering on content is not the expensive operation people assume.</p>



<p class="wp-block-paragraph">What is expensive is streams. A stream is one unique combination of label values, and each gets its own chunks. Do the arithmetic before you add a label:</p>



<ul class="wp-block-list">
<li>8 jobs, 3 environments, 12 hosts gives you 288 streams. Comfortable.</li>
<li>Add <code>request_id</code>, and you multiply that by however many requests you serve. The index explodes, chunks get flushed while nearly empty, and object storage fills with millions of tiny files that queries must open individually.</li>
</ul>



<p class="wp-block-paragraph">Ingesters also hold every active stream in memory. Cardinality does not just make queries slow; it is how Loki gets OOM-killed.</p>



<h2 class="wp-block-heading">Choosing labels</h2>



<p class="wp-block-paragraph">A label earns its place if it satisfies all three: the set of values is bounded and small, you know the values in advance, and you would actually use it to narrow a search.</p>



<ul class="wp-block-list">
<li><strong>Good:</strong> <code>job</code>, <code>service</code>, <code>env</code>, <code>host</code>, <code>namespace</code>, <code>level</code>.</li>
<li><strong>Bad:</strong> <code>request_id</code>, <code>trace_id</code>, <code>user_id</code>, <code>session_id</code>, full URL paths, IP addresses, timestamps, durations, anything derived from the log content itself.</li>
</ul>



<p class="wp-block-paragraph">Two subtler traps. <code>instance</code> including an ephemeral port is unbounded. And in container environments, pod names churn constantly, so labelling by pod name gives you a new stream every deployment forever.</p>



<p class="wp-block-paragraph">Aim to keep the total under a few thousand active streams. Check rather than guess, using <code>logcli</code>, which reports cardinality per label:</p>



<pre class="wp-block-code"><code># Which of your labels is quietly out of control?
logcli series '{}' --analyze-labels</code></pre>



<p class="wp-block-paragraph">Run that before you have a problem. It is the single most useful diagnostic here and almost nobody knows about it.</p>



<h3 class="wp-block-heading">The escape hatch: structured metadata</h3>



<p class="wp-block-paragraph">&#8220;Never label a trace ID&#8221; is correct but unsatisfying, because sometimes you genuinely need to find every line for one trace. Loki 3 answers this with structured metadata: key-value pairs attached to a log line, stored alongside it, <em>not</em> in the index and not creating streams.</p>



<pre class="wp-block-code"><code>limits_config:
  # Off by default on older configs. Turn it on before your
  # agent starts sending structured metadata, or the writes
  # get rejected and you spend an hour puzzling over it.
  allow_structured_metadata: true</code></pre>



<p class="wp-block-paragraph">Query it with the same label-filter syntax, and it costs you nothing in cardinality:</p>



<pre class="wp-block-code"><code>{job="api", env="production"} | trace_id="9f2c1b4e"</code></pre>



<p class="wp-block-paragraph">So the decision becomes three-way rather than two-way: bounded and used for selection becomes a label, high-cardinality but frequently searched becomes structured metadata, and everything else stays in the line and gets parsed at query time.</p>



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



<h2 class="wp-block-heading">Promtail is gone. Use Alloy.</h2>



<p class="wp-block-paragraph">This one has a date and it matters, because it invalidates most of the Loki material on the internet.</p>



<p class="wp-block-paragraph">Promtail, the agent every Loki tutorial tells you to install, was deprecated in February 2025 and reached end of life on 2 March 2026. No security patches, no bug fixes, no updates. Grafana Alloy, their distribution of the OpenTelemetry Collector, is the replacement, and it handles logs, metrics, traces and profiles in one binary instead of running a separate agent per signal.</p>



<p class="wp-block-paragraph">If you already run Promtail, there is a conversion tool rather than a rewrite:</p>



<pre class="wp-block-code"><code># Converts an existing Promtail config to Alloy syntax.
# Read the output before deploying it; the converter flags
# anything it could not translate cleanly.
alloy convert --source-format=promtail --output=config.alloy promtail.yaml</code></pre>



<p class="wp-block-paragraph">Alloy configuration is a component graph rather than a YAML pipeline. Components reference each other&#8217;s outputs, which reads oddly at first and then makes more sense than nested YAML stages:</p>



<pre class="wp-block-code"><code>// Find the files, and attach the labels that will select them later.
// Note what is NOT here: nothing derived from log content.
local.file_match "system" {
  path_targets = [{
    __path__ = "/var/log/syslog",
    job      = "syslog",
    host     = constants.hostname,
    env      = "production",
  }]
}

loki.source.file "system" {
  targets    = local.file_match.system.targets
  forward_to = [loki.write.default.receiver]
}

loki.write "default" {
  endpoint {
    url = "http://loki:3100/loki/api/v1/push"
  }
}</code></pre>



<p class="wp-block-paragraph">Between source and write you can insert a <code>loki.process</code> component to parse lines, drop noise, and promote fields into structured metadata. Do the dropping here rather than at the Loki end: logs you never ship cost nothing to store, index or query, and debug-level chatter from a busy service is usually the single largest slice of ingest.</p>



<p class="wp-block-paragraph">For Docker hosts, Alloy has components that discover containers and read their logs directly, which is tidier than tailing the JSON files under <code>/var/lib/docker</code> and surviving a logging-driver change.</p>



<h2 class="wp-block-heading">Deployment shape and storage</h2>



<p class="wp-block-paragraph">Loki ships as one binary that runs in different modes depending on the <code>-target</code> flag.</p>



<ul class="wp-block-list">
<li><strong>Monolithic.</strong> Everything in one process. Right for a single VPS, a homelab, or anything up to a modest daily volume. Do not feel bad about this; a lot of people run microservices mode for a workload one process would handle.</li>
<li><strong>Simple scalable.</strong> Splits into read, write and backend targets behind a load balancer. The sensible next step, and where most self-hosted setups should stop.</li>
<li><strong>Microservices.</strong> Every component separately. Genuinely necessary at large scale and a substantial operational burden below it.</li>
</ul>



<p class="wp-block-paragraph">For storage, use TSDB with schema v13. Older guides show <code>boltdb-shipper</code>; that is the previous generation. Chunks belong in object storage in anything you care about, because filesystem storage means one disk, one node, and no redundancy.</p>



<pre class="wp-block-code"><code>schema_config:
  configs:
    # Schema changes are additive. Add a new entry with a future
    # 'from' date rather than editing the existing one, or Loki
    # cannot read the data it already wrote.
    - from: 2024-01-01
      store: tsdb
      object_store: s3
      schema: v13
      index:
        prefix: index_
        period: 24h</code></pre>



<p class="wp-block-paragraph">That &#8220;additive&#8221; note is worth taking seriously. Editing an existing schema entry in place is one of the few ways to make historical logs unreadable.</p>



<p class="wp-block-paragraph">Any S3-compatible store works, which is where the cost advantage lives. Chunks are compressed and write-once, so cheaper tiers suit them well: S3 itself, or MinIO if you want it self-hosted, or one of the cheaper compatible providers like Backblaze B2 or Cloudflare R2. Watch egress pricing rather than storage pricing, since queries read chunks back.</p>



<h2 class="wp-block-heading">LogQL worth actually knowing</h2>



<p class="wp-block-paragraph">Every query starts with a stream selector in braces. That part is mandatory and it is what decides how much data gets read.</p>



<pre class="wp-block-code"><code># Order matters for performance:
#   1. select streams (index lookup)
#   2. filter lines (cheap string match, runs on every line)
#   3. parse (expensive, so run it on the smallest set possible)
{job="api", env="production"} |= "timeout" | json | status="500"</code></pre>



<p class="wp-block-paragraph">People routinely write <code>| json</code> before the line filter and then wonder why the query is slow. Parsing every line in the range to discard most of them is exactly the wrong order.</p>



<p class="wp-block-paragraph">Loki also turns logs into metrics, which is where it earns its place next to Prometheus:</p>



<pre class="wp-block-code"><code># Error rate per service, graphable and alertable.
sum by (job) (rate({env="production"} |= "level=error" [5m]))

# Which streams are eating your ingest budget?
topk(10, sum by (job, host) (count_over_time({env="production"}[1h])))</code></pre>



<p class="wp-block-paragraph">That second one is worth saving as a dashboard panel. Ingest problems announce themselves there long before they show up as a slow query.</p>



<p class="wp-block-paragraph">Loki&#8217;s ruler evaluates the same expressions as alerting rules and sends them to Alertmanager, so &#8220;alert when this service logs more than N errors in five minutes&#8221; needs no extra component if you already run Prometheus.</p>



<h2 class="wp-block-heading">Retention, and why it silently does nothing</h2>



<p class="wp-block-paragraph">Setting <code>retention_period</code> is not enough. Retention is enforced by the compactor, and the compactor does not do it unless you tell it to. Plenty of people set a retention period, watch their bucket keep growing, and never connect the two.</p>



<pre class="wp-block-code"><code>compactor:
  working_directory: /loki/compactor
  # Without this, the compactor compacts the index and
  # deletes nothing. This is the line people miss.
  retention_enabled: true
  delete_request_store: s3

limits_config:
  retention_period: 720h        # 30 days, global default</code></pre>



<p class="wp-block-paragraph">Per-stream retention lets you keep what matters and discard what does not: audit logs for a year, application debug logs for three days. That difference tends to matter more to the bill than any storage tier choice.</p>



<p class="wp-block-paragraph">Deletion is asynchronous. Chunks disappear from object storage some time after the index entries go, so do not panic if the bucket does not shrink immediately.</p>



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



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



<h3 class="wp-block-heading">Queries are slow or time out</h3>



<p class="wp-block-paragraph">Cardinality first. Run <code>logcli series '{}' --analyze-labels</code> and look for a label with a suspiciously large value count. Then check query shape: a selector that matches everything, a parser before a line filter, or a time range far wider than needed. Adding query frontend caching helps repeated queries but will not rescue a bad label set.</p>



<h3 class="wp-block-heading">Stream limit exceeded</h3>



<p class="wp-block-paragraph">Loki is telling you the truth. Raising <code>max_global_streams_per_user</code> buys time and makes the underlying problem worse. Find the label that is generating streams and move it to structured metadata or into the line.</p>



<h3 class="wp-block-heading">Rate limit or ingestion errors</h3>



<p class="wp-block-paragraph">Two different limits produce similar-looking rejections: a global ingestion rate, and a per-stream rate. Per-stream limits usually mean one very chatty stream rather than too much traffic overall, and the fix is to split it by a label or drop the noise at the agent.</p>



<h3 class="wp-block-heading">Logs are not appearing at all</h3>



<p class="wp-block-paragraph">Work forward from the agent. Check the agent&#8217;s own logs first, then whether it can reach the push endpoint, then whether the file is actually readable by the agent&#8217;s user. Container log permissions and SELinux catch people constantly. In Grafana, remember the label browser only shows labels seen recently, so an empty dropdown often means nothing has arrived, not that the query is wrong.</p>



<h3 class="wp-block-heading">Logs stop after an agent restart</h3>



<p class="wp-block-paragraph">The position file that tracks how far it read is not persisting. In a container, that means it is not on a mounted volume, so every restart re-reads or skips depending on configuration.</p>



<h3 class="wp-block-heading">Storage keeps growing despite retention</h3>



<p class="wp-block-paragraph">Confirm <code>retention_enabled: true</code> is actually set, that exactly one compactor is running, and that it has write access to the object store. A compactor that cannot delete usually says so in its logs and nowhere else.</p>



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



<ul class="wp-block-list">
<li>Treating labels like Elasticsearch fields and labelling everything.</li>
<li>Promoting a parsed value into a label without checking how many distinct values it has.</li>
<li>Following a tutorial that installs Promtail, which is now end of life.</li>
<li>Writing <code>| json</code> before the line filter.</li>
<li>Setting <code>retention_period</code> without <code>retention_enabled: true</code>.</li>
<li>Running more than one compactor.</li>
<li>Editing an existing <code>schema_config</code> entry instead of adding a new one.</li>
<li>Filesystem storage on a single node for anything you would miss.</li>
<li>Shipping every debug line and then paying to store, index and scan it.</li>
<li>Raising stream limits instead of fixing cardinality.</li>
<li>Running microservices mode for a workload the single binary handles comfortably.</li>
<li>No dashboard showing ingest volume per stream, so growth is invisible until queries break.</li>
</ul>



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



<ul class="wp-block-list">
<li>Decide labels deliberately: bounded, known in advance, used for selection.</li>
<li>Put high-cardinality but searchable values in structured metadata, not labels.</li>
<li>Run <code>--analyze-labels</code> periodically, not just when something breaks.</li>
<li>Drop noise at the agent, before it costs you anything downstream.</li>
<li>Emit structured logs from your applications so parsers are cheap and reliable.</li>
<li>Start monolithic and move to simple scalable only when you have a reason.</li>
<li>TSDB, schema v13, chunks in object storage.</li>
<li>Turn retention on properly and use per-stream retention to keep costs sane.</li>
<li>Order queries: select, filter, parse.</li>
<li>Alert on log-derived metrics through the ruler rather than eyeballing dashboards.</li>
<li>Graph ingest volume per job so cardinality growth shows up as a trend.</li>
</ul>



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



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



<h3 class="wp-block-heading">Is Loki a drop-in replacement for the ELK stack?</h3>



<p class="wp-block-paragraph">No, and expecting it to be is where most disappointment comes from. Loki is dramatically cheaper to run and much better suited to &#8220;show me the logs for this service around this time&#8221;. Elasticsearch is better at ad-hoc full-text search across everything with no idea where to start. Pick based on which of those you do more often.</p>



<h3 class="wp-block-heading">Why is my Loki so slow?</h3>



<p class="wp-block-paragraph">Almost always label cardinality. Run <code>logcli series '{}' --analyze-labels</code> and look for the outlier. The second most common cause is a query that parses before it filters. Hardware is a distant third.</p>



<h3 class="wp-block-heading">Should I still use Promtail?</h3>



<p class="wp-block-paragraph">No. It reached end of life in March 2026 and receives no security patches. Use Grafana Alloy, and run <code>alloy convert</code> against your existing config rather than rewriting it from scratch.</p>



<h3 class="wp-block-heading">How many labels is too many?</h3>



<p class="wp-block-paragraph">The count matters less than the product of their distinct values. Five labels with a handful of values each is fine. Three labels where one has fifty thousand values is not. Multiply before you add.</p>



<h3 class="wp-block-heading">Can I run Loki on a single VPS?</h3>



<p class="wp-block-paragraph">Yes, and for a handful of services it is a perfectly good answer. Monolithic mode alongside Grafana and Prometheus fits on a modest box from any provider. Point chunk storage at an object store rather than local disk so a dead server does not take your history with it.</p>



<h3 class="wp-block-heading">How do I search for a specific request across services?</h3>



<p class="wp-block-paragraph">Attach the trace or request ID as structured metadata at the agent, then filter on it. You get the high-cardinality lookup you wanted without creating a stream per request.</p>



<h3 class="wp-block-heading">Self-host or use a managed service?</h3>



<p class="wp-block-paragraph">Self-hosting a monolithic Loki is genuinely low effort. Self-hosting a highly available multi-tenant Loki is a real ongoing job, and at that point Grafana Cloud or a hosted alternative deserves an honest cost comparison including your time. The break-even is further out than vendors suggest and closer than enthusiasts admit.</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">Labels select, filters search. Every serious problem with centralized logging with Loki traces back to someone treating a label as a search field. Loki will accept it without complaint, and the cost arrives weeks later as a query that will not finish.</p>



<p class="wp-block-paragraph">So before adding any label, multiply out how many streams it creates. If the answer is more than a few thousand, it belongs in structured metadata or in the log line. And check your cardinality on a schedule, because this degrades gradually and you will not notice the day it crosses the line.</p>



<h2 class="wp-block-heading">Need a logging setup that stays fast?</h2>



<p class="wp-block-paragraph">Most Loki installations I get asked to look at were fine on day one and got slower every week. Work I take on:</p>



<ul class="wp-block-list">
<li>Diagnosing a slow Loki: cardinality analysis, query shape review, and a concrete list of labels to move.</li>
<li>Migrating Promtail configurations to Grafana Alloy, including Docker and Kubernetes log collection.</li>
<li>Building a centralized logging stack from scratch: Loki, Alloy, Grafana, object storage, retention and dashboards.</li>
<li>Restructuring labels and introducing structured metadata without losing queryability.</li>
<li>Retention and storage cost work: per-stream policies, agent-side dropping, object storage tiering.</li>
<li>Log-based alerting through the ruler, wired into an existing Prometheus and Alertmanager setup.</li>
</ul>



<p class="wp-block-paragraph">Send me the output of <code>logcli series '{}' --analyze-labels</code> and a query that feels slow, and I will tell you where the problem is.</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/devops/centralized-logging-loki/">One Label Too Many: Centralized Logging With Loki Without Wrecking It</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>
