<?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>Loki | John Nessime</title>
	<atom:link href="https://john-nessime.com/blog/tag/loki/feed/" rel="self" type="application/rss+xml" />
	<link>https://john-nessime.com/blog/tag/loki/</link>
	<description>Cloud, DevOps, Data &#38; AI — Built, Tested, Explained</description>
	<lastBuildDate>Thu, 06 Aug 2026 12:10:06 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.0.3</generator>

<image>
	<url>https://john-nessime.com/blog/wp-content/uploads/2026/07/cropped-jn-32x32.png</url>
	<title>Loki | John Nessime</title>
	<link>https://john-nessime.com/blog/tag/loki/</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>
					<comments>https://john-nessime.com/blog/devops/docker-log-rotation/#respond</comments>
		
		<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>
					
					<wfw:commentRss>https://john-nessime.com/blog/devops/docker-log-rotation/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</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>
					<comments>https://john-nessime.com/blog/devops/centralized-logging-loki/#respond</comments>
		
		<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>
					
					<wfw:commentRss>https://john-nessime.com/blog/devops/centralized-logging-loki/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
	</channel>
</rss>
