<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Peon Sh</title>
    <description>The latest articles on DEV Community by Peon Sh (@peon_sh).</description>
    <link>https://dev.to/peon_sh</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4056497%2Fc1ef38be-aa4d-42a7-a49a-3a2afadc332e.png</url>
      <title>DEV Community: Peon Sh</title>
      <link>https://dev.to/peon_sh</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/peon_sh"/>
    <language>en</language>
    <item>
      <title>Docker Container Keeps Restarting? Here’s How to Debug It</title>
      <dc:creator>Peon Sh</dc:creator>
      <pubDate>Thu, 27 Aug 2026 05:33:02 +0000</pubDate>
      <link>https://dev.to/peon_sh/docker-container-keeps-restarting-heres-how-to-debug-it-1c86</link>
      <guid>https://dev.to/peon_sh/docker-container-keeps-restarting-heres-how-to-debug-it-1c86</guid>
      <description>&lt;p&gt;A container stuck in a restart loop usually fails in the first second. Find the real error with logs, exit codes and these five common causes.&lt;/p&gt;

&lt;p&gt;What a restart loop actually is&lt;br&gt;
A container "stuck restarting" is not mysterious: the main process exits (usually within the first second or two), and the restart policy dutifully relaunches it, forever. The container is doing exactly what it was told; your job is to find out why the process dies. The answer is almost always sitting in the logs of the failed run, and the debugging discipline is to read the evidence before changing anything.&lt;/p&gt;

&lt;p&gt;docker ps -a                       # see the status and restart count&lt;br&gt;
    docker logs --tail 100   # the error from the last run&lt;br&gt;
    docker inspect  \&lt;br&gt;
    --format '{{.State.ExitCode}} {{.State.OOMKilled}} {{.State.Error}}'&lt;/p&gt;

&lt;p&gt;Decode the exit code&lt;br&gt;
The exit code narrows the cause before you read a single stack trace:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;1 (or app-specific nonzero): the application errored, read the stack trace; usually config or a missing dependency&lt;/li&gt;
&lt;li&gt;137: the process was SIGKILLed, either the kernel OOM killer (check OOMKilled in inspect) or a stop timeout; if OOMKilled is true, raise the memory limit or fix the leak&lt;/li&gt;
&lt;li&gt;126: the entrypoint exists but is not executable, typically a missing chmod +x or a Windows line-ending problem in a shell script&lt;/li&gt;
&lt;li&gt;127: command not found, a CMD typo, or the binary does not exist in your slim base image (bash on alpine is the classic)&lt;/li&gt;
&lt;li&gt;139: segmentation fault, very often a native module compiled for the wrong architecture (x86 module in an ARM container or vice versa)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The five usual suspects&lt;br&gt;
Across thousands of restart loops, the same five causes dominate:&lt;/p&gt;

&lt;p&gt;Missing environment variable: the app’s config validation throws on boot; compare &lt;code&gt;docker exec env&lt;/code&gt; expectations against what the service actually defines&lt;br&gt;
Database not ready: the app connects once at startup, Postgres is still initializing, the connection fails and the process exits, add retry-with-backoff in the app or a health-gated depends_on&lt;br&gt;
Wrong bind address: the app listens on 127.0.0.1 inside the container, so nothing can reach it and a healthcheck kills it; always bind 0.0.0.0 in containers&lt;br&gt;
Memory limit below reality: a Node app that needs 600 MB in a 512 MB container will OOM on schedule; watch docker stats during startup&lt;br&gt;
Bad healthcheck: the check curls the wrong port or path, marks a healthy app unhealthy, and the platform restarts it, verify the check command by running it manually with docker exec&lt;br&gt;
Reproduce it interactively&lt;br&gt;
When the logs are too thin (some apps crash before configuring their logger), bypass the loop entirely: start a shell in the same image with the same environment, then launch the process by hand and watch it fail in slow motion:&lt;/p&gt;

&lt;p&gt;docker run -it --rm --entrypoint sh \&lt;br&gt;
    --env-file &amp;lt;(docker inspect  --format \&lt;br&gt;
    '{{range .Config.Env}}{{println .}}{{end}}') \&lt;br&gt;
    &lt;a href="" class="article-body-image-wrapper"&gt;&lt;img&gt;&lt;/a&gt;&lt;br&gt;
    # inside: run the original CMD manually&lt;/p&gt;

&lt;p&gt;Prevent the next one&lt;br&gt;
Three habits eliminate most restart loops before they ship: validate configuration at boot and fail with a clear message naming the missing variable; add startup dependency retries so ordering never matters; and test the image locally with docker run using production-shaped environment variables before deploying. Platforms help too, Peon streams the failing container’s logs in the dashboard, so the stack trace is one click away rather than an SSH session.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Fix “Port Is Already in Use” Errors on Linux and Docker</title>
      <dc:creator>Peon Sh</dc:creator>
      <pubDate>Thu, 27 Aug 2026 05:31:29 +0000</pubDate>
      <link>https://dev.to/peon_sh/fix-port-is-already-in-use-errors-on-linux-and-docker-5e7f</link>
      <guid>https://dev.to/peon_sh/fix-port-is-already-in-use-errors-on-linux-and-docker-5e7f</guid>
      <description>&lt;p&gt;EADDRINUSE and Docker port binding failures: find what holds the port, free it safely, and design so it never happens again.&lt;/p&gt;

&lt;p&gt;The error and what it means&lt;br&gt;
Whether it appears as EADDRINUSE in Node, "address already in use" from Docker, or "bind: address already in use" from nginx, the meaning is identical: exactly one process may listen on a given IP:port pair, and something already holds the one you want. The fix is never to reboot and hope; it is to identify the holder, decide whether it should be there, and act accordingly.&lt;/p&gt;

&lt;p&gt;Find the holder&lt;br&gt;
Modern Linux gives you the owning process in one command:&lt;/p&gt;

&lt;p&gt;sudo ss -tlnp | grep :3000&lt;br&gt;
    # LISTEN 0 511 *:3000  users:(("node",pid=1234,fd=20))&lt;br&gt;
    # or the older equivalent&lt;br&gt;
    sudo lsof -i :3000&lt;br&gt;
    # if it's a container publishing the port&lt;br&gt;
    docker ps --format '{{.Names}}\t{{.Ports}}' | grep 3000&lt;/p&gt;

&lt;p&gt;Common culprits, in order of frequency&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A previous instance of your own app: a dev server you forgot, or an orphaned process after a crashed deploy, kill the specific PID, not everything matching a name&lt;/li&gt;
&lt;li&gt;Another container publishing the same host port: two services both trying to own 8080:..., only one can win&lt;/li&gt;
&lt;li&gt;System services on well-known ports: a distro-installed Apache or nginx squatting on 80/443, blocking your reverse proxy container (disable with systemctl disable --now)&lt;/li&gt;
&lt;li&gt;systemd-resolved on port 53, relevant when running Pi-hole or other DNS containers&lt;/li&gt;
&lt;li&gt;TIME_WAIT ghosts: right after a restart the port looks busy for up to a minute; SO_REUSEADDR in the app makes rebinding immediate, and ss shows no LISTEN holder in this case&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The structural fix: stop publishing ports&lt;br&gt;
On a server with a reverse proxy, host port conflicts are a symptom of an anti-pattern: app containers should not publish host ports at all. Each app listens on its internal port on the Docker network; the proxy is the only process binding 80 and 443, and it routes by hostname. Under this design, two apps can both use "port 3000" internally forever without conflict, because no one is competing for host ports.&lt;/p&gt;

&lt;p&gt;This is how Peon deploys services by default: no published ports on app containers, proxy-only ingress. If you are hand-writing compose files, deleting the ports: section from app services (keeping it only on the proxy) is the single change that retires this whole error class.&lt;/p&gt;

&lt;p&gt;Quick decision table&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Holder is your old process: kill , then fix whatever leaves orphans (usually a missing SIGTERM handler)&lt;/li&gt;
&lt;li&gt;Holder is another container: change one side’s published port, or better, unpublish both and route via the proxy&lt;/li&gt;
&lt;li&gt;Holder is a system service you need: move your service to another port&lt;/li&gt;
&lt;li&gt;Holder is a system service you do not need: disable it permanently&lt;/li&gt;
&lt;li&gt;No holder visible: TIME_WAIT, wait 60 seconds or fix the app’s socket options&lt;/li&gt;
&lt;/ul&gt;

</description>
    </item>
    <item>
      <title>Docker Ate Your Disk? Reclaim Space Safely</title>
      <dc:creator>Peon Sh</dc:creator>
      <pubDate>Thu, 27 Aug 2026 05:29:55 +0000</pubDate>
      <link>https://dev.to/peon_sh/docker-ate-your-disk-reclaim-space-safely-1jml</link>
      <guid>https://dev.to/peon_sh/docker-ate-your-disk-reclaim-space-safely-1jml</guid>
      <description>&lt;p&gt;“No space left on device” on a Docker host: find what’s consuming the disk (images, logs, volumes, build cache) and clean each safely.&lt;/p&gt;

&lt;p&gt;Why Docker hosts fill up&lt;br&gt;
Full disks are the most common cause of outages on single-server Docker hosts, more common than crashes or traffic spikes. The mechanics are mundane: every deploy leaves image layers behind, every build adds cache, and every log line a container prints is appended to an unbounded JSON file. None of it cleans itself up, and "no space left on device" takes down everything at once: new deploys fail, databases cannot write, and even the commands to fix it can fail.&lt;/p&gt;

&lt;p&gt;Diagnose before deleting anything&lt;br&gt;
Two commands show exactly where the space went, always run them first:&lt;/p&gt;

&lt;p&gt;docker system df -v      # images, containers, volumes, build cache&lt;br&gt;
    df -h /                   # overall disk picture&lt;br&gt;
    du -sh /var/lib/docker/containers/*/ 2&amp;gt;/dev/null | sort -h | tail&lt;br&gt;
    # ^ per-container log sizes: the silent killer&lt;/p&gt;

&lt;p&gt;Clean each consumer, in safety order&lt;br&gt;
From completely safe to requires-thought:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Dangling and unused images: docker image prune -af, safe, worst case the next deploy pulls layers again&lt;/li&gt;
&lt;li&gt;Build cache: docker builder prune -af, safe, the next build is slower, nothing is lost&lt;/li&gt;
&lt;li&gt;Stopped containers: docker container prune -f, safe if you do not intentionally keep stopped containers around&lt;/li&gt;
&lt;li&gt;Container logs: truncate oversized ones (truncate -s 0 /var/lib/docker/containers//-json.log), then fix rotation permanently (next section)&lt;/li&gt;
&lt;li&gt;Volumes: docker volume prune is DANGEROUS, "unused" only means no running container references it right now; a stopped database’s data volume qualifies. List them, identify each one, and remove only what you can name&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cap log growth permanently&lt;br&gt;
The default json-file log driver has no size limit; a single chatty container can write gigabytes a week. Set global limits in the daemon config and this problem never returns:&lt;/p&gt;

&lt;h1&gt;
  
  
  /etc/docker/daemon.json
&lt;/h1&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
"log-driver": "json-file",
"log-opts": { "max-size": "20m", "max-file": "3" }
}
# then: systemctl restart docker
# note: applies to newly created containers; recreate old ones to adopt it
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Automate the hygiene&lt;br&gt;
One-off cleanups buy weeks; automation buys forever. Schedule a weekly prune of images, stopped containers and build cache, and alert on disk crossing 80% so you act before 100%. Peon exposes exactly this as a server cleanup action (on demand or scheduled) and shows per-server disk meters in the dashboard, deploy-heavy hosts stay healthy without anyone remembering to SSH in and prune.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>SSH Connection Refused or Timing Out: A Debugging Checklist</title>
      <dc:creator>Peon Sh</dc:creator>
      <pubDate>Thu, 27 Aug 2026 05:28:25 +0000</pubDate>
      <link>https://dev.to/peon_sh/ssh-connection-refused-or-timing-out-a-debugging-checklist-2fln</link>
      <guid>https://dev.to/peon_sh/ssh-connection-refused-or-timing-out-a-debugging-checklist-2fln</guid>
      <description>&lt;p&gt;Locked out of your VPS? Work through connection refused vs timeout, firewall rules, sshd state and key problems methodically.&lt;/p&gt;

&lt;p&gt;The error message is the map&lt;br&gt;
SSH failures announce their category if you read them precisely. "Connection refused" means a machine answered and actively rejected you: the network path works, but nothing (or the wrong thing) listens on that port. "Connection timed out" means packets vanished: a firewall silently drops them or you are aiming at the wrong address. "Permission denied (publickey)" means SSH itself works fine and authentication is the problem. Each category has a completely different checklist, so classifying first halves the work.&lt;/p&gt;

&lt;p&gt;Timeouts: walk the firewalls, outside in&lt;br&gt;
Verify the IP: ping it, and check the provider dashboard, VPS IPs change after rebuilds, and stale SSH configs point at ghosts&lt;br&gt;
Provider/cloud firewall: does a rule allow TCP 22 from your current IP? Office and home IPs change; allowlists silently go stale&lt;br&gt;
Host firewall: ufw or iptables on the server itself, easy to lock yourself out by enabling ufw without &lt;code&gt;ufw allow ssh&lt;/code&gt; first&lt;br&gt;
Your side: corporate and hotel networks sometimes block outbound 22, test via phone hotspot to rule it out in one minute&lt;br&gt;
fail2ban or provider intrusion protection may have banned your IP after repeated failed attempts, check from a different IP&lt;br&gt;
Refused: get on the box out-of-band&lt;br&gt;
Every serious VPS provider offers a web console (VNC/serial) that works even when SSH does not, this is your lifeline. Log in through it and inspect the daemon:&lt;/p&gt;

&lt;p&gt;Common causes: a bad sshd_config edit (always run sshd -t before restarting), the daemon disabled after an update, or sshd moved to a non-standard port you forgot&lt;br&gt;
Disk 100% full can also prevent sshd from accepting sessions, check df -h while you are there&lt;br&gt;
systemctl status sshd          # running? crashed? failed config?&lt;br&gt;
    journalctl -u sshd -n 50        # recent errors, bad config lines&lt;br&gt;
    ss -tlnp | grep sshd            # which port is it actually on?&lt;br&gt;
    sshd -t                         # validate config syntax before restarting&lt;br&gt;
Permission denied (publickey)&lt;br&gt;
Authentication failures are almost always one of four things:&lt;/p&gt;

&lt;p&gt;Wrong key offered: ssh -v shows which keys the client tries; specify explicitly with -i ~/.ssh/the_right_key&lt;br&gt;
Wrong user: images differ, ubuntu on Ubuntu cloud images, root on many VPS defaults, debian, admin... check the provider docs&lt;br&gt;
Server-side permissions: ~/.ssh must be 700 and authorized_keys 600, owned by the user; sshd silently ignores world-readable key files (visible in journalctl -u sshd)&lt;br&gt;
PasswordAuthentication no with your key missing from authorized_keys entirely, fix via the web console&lt;br&gt;
Lock-out-proofing for the future&lt;br&gt;
Three cheap habits make lockouts a non-event: keep a second SSH key from a different machine in authorized_keys; when changing sshd config, keep your current session open and test a new connection before closing it; and know where your provider’s web console lives before you need it. Deployment platforms reduce day-to-day exposure too, with Peon managing servers over its own configured SSH access, your personal SSH sessions become rare, so there is less config churn to get wrong.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>DNS Propagation: Why Your Domain Change Takes Time (and Why It Doesn’t)</title>
      <dc:creator>Peon Sh</dc:creator>
      <pubDate>Thu, 27 Aug 2026 05:26:20 +0000</pubDate>
      <link>https://dev.to/peon_sh/dns-propagation-why-your-domain-change-takes-time-and-why-it-doesnt-1ond</link>
      <guid>https://dev.to/peon_sh/dns-propagation-why-your-domain-change-takes-time-and-why-it-doesnt-1ond</guid>
      <description>&lt;p&gt;What actually happens when you change an A record, why “48 hours” is a myth, and how to verify DNS changes in real time.&lt;/p&gt;

&lt;p&gt;There is no “propagation”, only caches expiring&lt;br&gt;
DNS changes do not push out to the world; nothing is propagating anywhere. When you update an A record, the authoritative nameserver answers with the new value immediately. Everyone else, ISP resolvers, public resolvers like 1.1.1.1, your OS, your browser, keeps serving their cached copy until its TTL (time to live) expires, then re-asks and gets the new answer.&lt;/p&gt;

&lt;p&gt;"Propagation delay" is simply the world’s caches expiring at different moments. With a 300-second TTL, effectively everyone converges within five minutes. The mythical "24 to 48 hours" dates from an era of default day-long TTLs and survives because it makes a safe thing to tell customers.&lt;/p&gt;

&lt;p&gt;Verify at the source, skip the guesswork&lt;br&gt;
The definitive check queries your zone’s authoritative nameserver directly, bypassing every cache on Earth:&lt;/p&gt;

&lt;p&gt;Correct at the authoritative server: your change is live; the world converges within one TTL, done&lt;br&gt;
Wrong there: the change did not save, or you edited the wrong zone, if nameservers point at Cloudflare, records at your registrar are decorative&lt;br&gt;
dig +short NS example.com                 # find the authoritative servers&lt;br&gt;
    dig +short app.example.com @ns1.dns-host.com   # ask one directly&lt;br&gt;
Why YOUR machine still shows the old value&lt;br&gt;
The most common "DNS is broken" report is local caching. Your OS resolver, systemd-resolved, and your browser each cache independently, sometimes beyond the TTL. Test against a public resolver to see what the world sees, and flush local caches only if you personally need the new value right now:&lt;/p&gt;

&lt;p&gt;dig +short app.example.com @1.1.1.1     # Cloudflare's resolver&lt;br&gt;
    dig +short app.example.com @8.8.8.8     # Google's&lt;br&gt;
    # flush local (macOS)&lt;br&gt;
    sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder&lt;br&gt;
    # flush local (Linux with systemd-resolved)&lt;br&gt;
    sudo resolvectl flush-caches&lt;br&gt;
Negative caching: the sneaky one&lt;br&gt;
If you query a name before creating its record, resolvers cache the "does not exist" answer (NXDOMAIN) for the zone’s negative TTL, often longer than your record TTL. Practical rule: create the record first, test second. If you tested too early, the fix is patience or querying a resolver you have not poisoned yet.&lt;/p&gt;

&lt;p&gt;Practical playbook for changes&lt;br&gt;
Before a planned migration: lower the TTL to 300 a day in advance (the old TTL governs how long the lowering itself takes to be seen)&lt;br&gt;
Make the change, verify against the authoritative server, then against 1.1.1.1&lt;br&gt;
Keep the old server running for at least the old TTL window to catch stragglers&lt;br&gt;
After stabilizing, raise TTL back to 3600 or more for resilience and fewer resolver queries&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Let’s Encrypt Certificate Not Issuing? Diagnose It in Order</title>
      <dc:creator>Peon Sh</dc:creator>
      <pubDate>Thu, 27 Aug 2026 05:22:16 +0000</pubDate>
      <link>https://dev.to/peon_sh/lets-encrypt-certificate-not-issuing-diagnose-it-in-order-l4n</link>
      <guid>https://dev.to/peon_sh/lets-encrypt-certificate-not-issuing-diagnose-it-in-order-l4n</guid>
      <description>&lt;p&gt;ACME challenge failed, connection refused, or rate limited, a systematic checklist for when automatic HTTPS doesn’t come up.&lt;/p&gt;

&lt;p&gt;How issuance fails, structurally&lt;br&gt;
Automatic HTTPS has three prerequisites: the domain resolves to your server, the ACME challenge can reach it (port 80 for HTTP-01), and you are not rate limited from earlier failed attempts. Every "certificate not issued" case is one of those three, and the fastest path is to check them in that order rather than re-deploying and hoping. Crucially: your proxy logs contain the exact ACME error naming the failing check, read them first.&lt;/p&gt;

&lt;p&gt;docker logs  2&amp;gt;&amp;amp;1 | grep -i -E "acme|certificate|challenge" | tail -20&lt;/p&gt;

&lt;p&gt;Check 1: DNS (it’s DNS 80% of the time)&lt;br&gt;
The domain must resolve to this server’s public IP before issuance can succeed:&lt;/p&gt;

&lt;p&gt;No answer: the record does not exist, or you edited a zone that is not authoritative (registrar DNS vs Cloudflare is the classic mix-up)&lt;br&gt;
Wrong IP: an old record, or the record points at a load balancer/other box&lt;br&gt;
Also check AAAA: if an IPv6 record exists but the server does not actually serve on that address, validation can fail even though IPv4 looks perfect&lt;br&gt;
dig +short app.example.com     # what the world sees&lt;br&gt;
    curl -4 -s ifconfig.me          # this server's public IPv4&lt;br&gt;
    # these two must match&lt;/p&gt;

&lt;p&gt;Check 2: reachability on port 80&lt;br&gt;
HTTP-01 validation arrives as a plain HTTP request on port 80. Both the cloud firewall (security group) and any host firewall (ufw, iptables) must allow 80 and 443, and the proxy container must actually be running and bound to them. A stray host-level nginx or Apache holding port 80 silently absorbs every challenge, check with ss -tlnp | grep -E ":80|:443" that the listener is your proxy.&lt;/p&gt;

&lt;p&gt;Check 3: the Cloudflare orange cloud&lt;br&gt;
If the domain is proxied through Cloudflare (orange cloud), challenge requests hit Cloudflare’s edge, not your origin, and HTTP-01 can fail confusingly. Two clean resolutions: set the Cloudflare SSL mode to Full (strict) and let Cloudflare terminate for visitors while your origin still gets its own certificate; or temporarily grey-cloud the DNS record, let issuance complete, then re-enable the proxy. Never run Flexible mode; it causes redirect loops with origin HTTPS.&lt;/p&gt;

&lt;p&gt;Check 4: rate limits, and how not to hit them&lt;br&gt;
Let’s Encrypt limits failed validations to 5 per account, per hostname, per hour, and duplicate certificates to 5 per week. Retrying in a loop while DNS is broken burns through both. The discipline: diagnose with the checks above, fix the root cause, retry once. For experiments, point the proxy at the staging endpoint, generous limits and untrusted certificates, perfect for verifying plumbing before touching production limits.&lt;/p&gt;

&lt;p&gt;When everything is fixed, issuance is fast: certificates typically arrive within seconds of the first valid request, and platforms like Peon retry automatically, so a previously failing domain heals on its own once DNS and firewall are right.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Docker Logging Best Practices for Production Apps</title>
      <dc:creator>Peon Sh</dc:creator>
      <pubDate>Thu, 27 Aug 2026 05:21:03 +0000</pubDate>
      <link>https://dev.to/peon_sh/docker-logging-best-practices-for-production-apps-52k6</link>
      <guid>https://dev.to/peon_sh/docker-logging-best-practices-for-production-apps-52k6</guid>
      <description>&lt;p&gt;Log to stdout, structure as JSON, cap file sizes and know your retention: pragmatic logging for containerized apps without an ELK stack.&lt;/p&gt;

&lt;p&gt;The one rule: stdout, unbuffered&lt;br&gt;
The twelve-factor principle remains the foundation of container logging: applications write events to stdout/stderr and treat log routing as the runtime’s job. Never write log files inside a container, they die with it, they hide from docker logs, and inside volumes they grow until the disk fills. Every serious runtime, platform and collector builds on the stdout convention; fighting it buys you nothing.&lt;/p&gt;

&lt;p&gt;Unbuffered matters too: Python needs PYTHONUNBUFFERED=1, and any language buffering stdout will show logs minutes late or lose the crucial lines before a crash.&lt;/p&gt;

&lt;p&gt;Structure beats prose&lt;br&gt;
The difference between grep-able text and queryable JSON shows up the first time you debug a real incident. JSON lines with a level, timestamp, message and request context turn "search the haystack" into "filter where user_id=X and status=500". Every mainstream logger does this well: pino (Node), zerolog/slog (Go), structlog (Python), Serilog (.NET).&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Include a request ID on every line of a request’s lifecycle, correlation is the whole game&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Log at boundaries (request in/out, job start/end, external calls) rather than narrating every function&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Never log secrets, tokens or full card numbers; add a redaction layer where user data flows&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;{"level":"error","time":"2026-04-07T10:31:04Z","req_id":"abc123",&lt;br&gt;
    "user_id":8841,"route":"/api/checkout","status":500,&lt;br&gt;
    "err":"payment provider timeout after 3000ms","duration_ms":3012}&lt;/p&gt;

&lt;p&gt;Cap and rotate at the daemon&lt;br&gt;
Docker’s default json-file driver has no size cap, making unbounded logs the number-one cause of mysteriously full Docker hosts. Fix it once, globally:&lt;/p&gt;

&lt;h1&gt;
  
  
  /etc/docker/daemon.json
&lt;/h1&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{ "log-opts": { "max-size": "20m", "max-file": "3" } }
# 60 MB ceiling per container; restart docker, recreate containers to adopt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Levels and volume discipline&lt;br&gt;
Run production at info level: debug in production drowns signals and inflates costs everywhere downstream. A useful volume heuristic: a healthy request logs 1 to 3 lines, not 30. If a single user action produces a screen of logs, you are narrating rather than reporting, and the noise will hide the one line that matters during an incident.&lt;/p&gt;

&lt;p&gt;Do you actually need a log stack?&lt;br&gt;
For single-host and few-host deployments, platform log access, Peon streams live and recent container logs per service in the dashboard, plus daemon-level rotation covers the daily debugging loop: see the error, correlate by request ID, fix. Graduate to Loki or an ELK stack when a concrete need arrives: searching across many servers at once, retention measured in months for compliance, or alerting on log patterns. Adopting that infrastructure before the need is a classic complexity trap; the migration later is easy precisely because everything already logs structured JSON to stdout.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Postgres “Connection Refused” in Docker: The Complete Fix List</title>
      <dc:creator>Peon Sh</dc:creator>
      <pubDate>Thu, 27 Aug 2026 05:06:34 +0000</pubDate>
      <link>https://dev.to/peon_sh/postgres-connection-refused-in-docker-the-complete-fix-list-53m2</link>
      <guid>https://dev.to/peon_sh/postgres-connection-refused-in-docker-the-complete-fix-list-53m2</guid>
      <description>&lt;p&gt;App can’t reach Postgres in Docker? Hostname resolution, networks, startup ordering and auth, the four failure classes and their fixes.&lt;/p&gt;

&lt;p&gt;Four failure classes, four different fixes&lt;br&gt;
Every "app cannot reach Postgres in Docker" report is one of four distinct problems: wrong hostname, separate networks, startup ordering, or authentication. The error text tells you which: ECONNREFUSED or "connection refused" is network-level (classes 1 to 3); "password authentication failed" or "no pg_hba.conf entry" means the network is fine and auth is wrong (class 4). Diagnose top-down and you fix it in minutes.&lt;/p&gt;

&lt;p&gt;Class 1: localhost is not your database&lt;br&gt;
Inside a container, localhost means that same container, not the machine, not the database next door. An app configured with postgres://user:pass@localhost:5432/db will get ECONNREFUSED forever, because nothing listens on 5432 inside the app’s own container. Use the database’s service or container name as the hostname; Docker’s embedded DNS resolves it on shared user-defined networks:&lt;/p&gt;

&lt;h1&gt;
  
  
  wrong (inside a container)
&lt;/h1&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DATABASE_URL=postgres://app:secret@localhost:5432/appdb
# right
DATABASE_URL=postgres://app:secret@postgres:5432/appdb
#                                    ^ the service/container name
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Class 2: different networks&lt;br&gt;
Containers resolve each other only when they share a user-defined Docker network. Two compose stacks, or a hand-run container and a platform-managed one, land on different networks by default and are mutually invisible. Verify and fix:&lt;/p&gt;

&lt;p&gt;Platforms avoid this by attaching all services to a shared network, in Peon, services on the same server reach each other by name out of the box&lt;br&gt;
docker inspect app --format '{{json .NetworkSettings.Networks}}' | jq keys&lt;br&gt;
    docker inspect postgres --format '{{json .NetworkSettings.Networks}}' | jq keys&lt;br&gt;
    # no common network? connect one:&lt;br&gt;
    docker network connect  app&lt;/p&gt;

&lt;p&gt;Class 3: the startup race&lt;br&gt;
Postgres takes several seconds to initialize, longer on first boot with a fresh volume. An app that connects exactly once at startup loses the race, gets ECONNREFUSED, and crashes into a restart loop that eventually stabilizes (masking the real issue). Fix it properly in both places:&lt;/p&gt;

&lt;p&gt;And in the app: retry initial connections with backoff for 30 to 60 seconds, ordering then never matters anywhere (CI, restarts, reboots)&lt;/p&gt;

&lt;h1&gt;
  
  
  compose: gate on real readiness, not just "started"
&lt;/h1&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;depends_on:
postgres:
condition: service_healthy
# postgres service:
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 5s
retries: 10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Class 4: auth and the first-boot trap&lt;br&gt;
The official image’s POSTGRES_USER/POSTGRES_PASSWORD/POSTGRES_DB variables apply only when initializing an empty data volume. Change them later and nothing happens, the credentials in the existing volume win, a trap that produces "password authentication failed" after an innocent-looking config edit. Fix credentials in the running database (ALTER USER app WITH PASSWORD ‘...’;) or, for throwaway dev data, remove the volume and re-initialize. "No pg_hba.conf entry" appearing with network connections usually means a custom config restricted host access, the stock image already allows network connections from the Docker network with password auth.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Next.js Standalone Mode: Small Docker Images That Boot Fast</title>
      <dc:creator>Peon Sh</dc:creator>
      <pubDate>Thu, 27 Aug 2026 05:02:13 +0000</pubDate>
      <link>https://dev.to/peon_sh/nextjs-standalone-mode-small-docker-images-that-boot-fast-1f1f</link>
      <guid>https://dev.to/peon_sh/nextjs-standalone-mode-small-docker-images-that-boot-fast-1f1f</guid>
      <description>&lt;p&gt;output: "standalone" cuts Next.js images from 1 GB+ to ~150 MB. How it works, the static-files gotcha, and a copy-paste Dockerfile.&lt;/p&gt;

&lt;p&gt;The problem standalone solves&lt;br&gt;
A naive Next.js Dockerfile copies the entire project, node_modules included, into the final image: 1 GB or more, most of it build tooling and dev dependencies the production server never touches. Every deploy moves that gigabyte, every host stores copies of it, and cold starts pay for loading it.&lt;/p&gt;

&lt;p&gt;With output: "standalone" in next.config, next build performs file tracing: it walks the actual require/import graph of the production server and emits .next/standalone, a self-contained folder with server.js and only the node_modules files genuinely reached at runtime. Typical result: 120 to 180 MB final images, an 85 to 90% reduction.&lt;/p&gt;

&lt;p&gt;// next.config.js&lt;br&gt;
    module.exports = { output: 'standalone' };&lt;/p&gt;

&lt;p&gt;The gotcha everyone hits once&lt;br&gt;
Standalone output deliberately excludes two directories: .next/static (hashed JS/CSS assets) and public/ (your static files). The assumption is you might serve them from a CDN. Self-hosting them means copying both into the image yourself, forget this, and the app boots fine but every page loads without styles or scripts, assets 404ing:&lt;/p&gt;

&lt;p&gt;FROM node:22-alpine AS build&lt;br&gt;
    WORKDIR /app&lt;br&gt;
    COPY package*.json ./&lt;br&gt;
    RUN npm ci&lt;br&gt;
    COPY . .&lt;br&gt;
    RUN npm run build&lt;br&gt;
    FROM node:22-alpine&lt;br&gt;
    WORKDIR /app&lt;br&gt;
    ENV NODE_ENV=production&lt;br&gt;
    COPY --from=build /app/.next/standalone ./&lt;br&gt;
    COPY --from=build /app/.next/static ./.next/static   # &amp;lt;- required&lt;br&gt;
    COPY --from=build /app/public ./public               # &amp;lt;- required&lt;br&gt;
    EXPOSE 3000&lt;br&gt;
    CMD ["node", "server.js"]&lt;/p&gt;

&lt;p&gt;Environment variable timing&lt;br&gt;
The other classic standalone-mode bug is env timing. NEXT_PUBLIC_* variables are inlined into the client JavaScript at build time; setting them at runtime does nothing, they must exist during npm run build (build args or platform build-time variables). Server-only secrets are the reverse: read at runtime from process.env, so they belong in runtime environment variables and never need rebuilds. The symptom of mixing these up is always "works locally, undefined in production".&lt;/p&gt;

&lt;p&gt;What still works (everything)&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;ISR: revalidation runs in the server process; cache lives on the container filesystem (fine for one instance; use a custom cache handler when scaling out)&lt;/li&gt;
&lt;li&gt;next/image: on-demand optimization works out of the box, sharp is bundled by tracing&lt;/li&gt;
&lt;li&gt;Middleware, API routes, server actions: all present, this is the full Next.js server, not an adaptation&lt;/li&gt;
&lt;li&gt;The only external assumption gone: no CDN implied; add Cloudflare in front if you want edge asset caching&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Payoff in production&lt;br&gt;
Concrete numbers from typical apps: image 1.1 GB to 150 MB, build-and-deploy cycle minutes to under one, container start under a second, and far less disk churn on deploy-heavy hosts (relevant when your platform builds on the server, as Peon does, layer cache stays warm and rebuilds move only your app layer). Standalone mode is the single highest-leverage line of configuration in self-hosted Next.js.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Docker Networking Explained: Bridges, DNS and Why localhost Breaks</title>
      <dc:creator>Peon Sh</dc:creator>
      <pubDate>Thu, 27 Aug 2026 05:00:21 +0000</pubDate>
      <link>https://dev.to/peon_sh/docker-networking-explained-bridges-dns-and-why-localhost-breaks-2d1i</link>
      <guid>https://dev.to/peon_sh/docker-networking-explained-bridges-dns-and-why-localhost-breaks-2d1i</guid>
      <description>&lt;p&gt;A mental model for Docker networks: how containers find each other, when to publish ports, and the difference between expose and ports.&lt;/p&gt;

&lt;p&gt;The mental model: networks are virtual switches&lt;br&gt;
Almost every Docker networking confusion dissolves with one picture: a user-defined bridge network is a virtual switch. Containers attached to it get a private IP and, crucially, a DNS name equal to their container or service name, resolved by Docker’s embedded DNS server. Containers on the same switch reach each other by name on any port; containers on different switches cannot see each other at all; and the host only reaches containers through explicitly published ports.&lt;/p&gt;

&lt;p&gt;The "user-defined" qualifier matters: the legacy default bridge (what you get with a bare docker run) does not provide DNS between containers. Always create and use named networks, compose does this automatically per project.&lt;/p&gt;

&lt;p&gt;ports vs expose, settled&lt;br&gt;
ports: "8080:3000" binds host port 8080 to the container’s 3000, this is the doorway from the outside world (and the internet, if the firewall allows). Each host port can be bound once&lt;br&gt;
expose: 3000 is documentation only; it changes no behaviour. Containers on a shared network can already reach any port the other container listens on&lt;br&gt;
The production rule: only the reverse proxy publishes ports (80/443); every app and database stays network-internal, this eliminates port conflicts and accidental public databases in one stroke&lt;/p&gt;

&lt;p&gt;Why localhost breaks, and what to use instead&lt;br&gt;
Inside a container, localhost is that container’s own loopback interface, not the host, not sibling containers. The two fixes cover 95% of cases: to reach a sibling service, use its network name (postgres, redis, api); to reach something on the host machine, use host.docker.internal (add the extra_hosts mapping on Linux).&lt;/p&gt;

&lt;p&gt;The mirror-image bug: an app inside a container binding to 127.0.0.1 is unreachable even with published ports, because the publish forwards to the container’s external interface. Containerized servers must listen on 0.0.0.0.&lt;/p&gt;

&lt;h1&gt;
  
  
  Linux: make host.docker.internal work
&lt;/h1&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;extra_hosts:
- "host.docker.internal:host-gateway"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;A platform-shaped example&lt;br&gt;
A typical Peon-managed server runs one shared network (peon) where the proxy, your apps and your databases all live. The proxy publishes 80/443 and routes by hostname; your app reaches its database at postgres-abc:5432 by name; nothing else touches host ports. Two different apps can each listen on "port 3000" internally without any conflict, because host ports are simply not part of the design.&lt;/p&gt;

&lt;p&gt;Inspection toolkit&lt;br&gt;
When connectivity confuses you, these four commands answer it empirically: &lt;br&gt;
docker network ls                                  # what switches exist&lt;br&gt;
    docker network inspect peon | jq '.[0].Containers'  # who is attached&lt;br&gt;
    docker exec app getent hosts postgres               # does DNS resolve?&lt;br&gt;
    docker exec app wget -qO- &lt;a href="http://api:3000/health" rel="noopener noreferrer"&gt;http://api:3000/health&lt;/a&gt;    # can I actually reach it?&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>containers</category>
      <category>devops</category>
      <category>docker</category>
    </item>
    <item>
      <title>Environment Variables in Docker Compose: env_file, environment and Interpolation</title>
      <dc:creator>Peon Sh</dc:creator>
      <pubDate>Thu, 27 Aug 2026 04:57:44 +0000</pubDate>
      <link>https://dev.to/peon_sh/environment-variables-in-docker-compose-envfile-environment-and-interpolation-ock</link>
      <guid>https://dev.to/peon_sh/environment-variables-in-docker-compose-envfile-environment-and-interpolation-ock</guid>
      <description>&lt;p&gt;The three ways Compose handles env vars, which one wins on conflicts, and how to keep secrets out of Git while staying reproducible.&lt;/p&gt;

&lt;p&gt;Three mechanisms that look alike and are not&lt;br&gt;
Compose gives you three distinct ways to get values into containers, and most env-related confusion comes from blurring them:&lt;/p&gt;

&lt;p&gt;environment: entries in the compose file, set directly on the container; highest precedence; visible to anyone reading the file&lt;br&gt;
env_file: loads KEY=value lines from a named file into the container at start&lt;br&gt;
${VAR} interpolation: substitutes values into the compose file itself at parse time, from your shell or from a .env file sitting next to docker-compose.yml&lt;/p&gt;

&lt;p&gt;The classic confusion: .env does not enter containers&lt;br&gt;
The .env file next to your compose file feeds interpolation of the YAML, it is not automatically injected into any container. DB_PASSWORD=secret in .env does nothing for your app unless the compose file passes it through explicitly. The symptom is maddening: the variable exists on the host, echo shows it, and the container sees nothing.&lt;/p&gt;

&lt;h1&gt;
  
  
  .env (next to docker-compose.yml)
&lt;/h1&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DB_PASSWORD=s3cret
# docker-compose.yml: must reference it to pass it through
services:
app:
environment:
DB_PASSWORD: ${DB_PASSWORD}   # now it reaches the container
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Precedence, definitively&lt;br&gt;
When the same key appears in multiple places, the order is: values from your shell override the .env file (for interpolation); and on the container, environment: entries override env_file: entries. One subtle trap: an interpolation with no value becomes an empty string silently, use ${VAR:?err} syntax to make missing required values fail the deploy loudly instead.&lt;/p&gt;

&lt;p&gt;environment:&lt;br&gt;
    DATABASE_URL: ${DATABASE_URL:?DATABASE_URL must be set}   # fail fast&lt;br&gt;
    LOG_LEVEL: ${LOG_LEVEL:-info}                             # default value&lt;/p&gt;

&lt;p&gt;eeping secrets out of Git&lt;br&gt;
The pattern that scales: commit the compose file with ${PLACEHOLDERS} and defaults for non-secrets; never commit real values; inject them at deploy time from a secrets store. A deployment platform formalizes this, Peon stores variables encrypted at rest, renders them when deploying the stack, and offers workspace-level shared variables so one API key serves ten services without ten copies. Rotating a credential becomes: change it in one place, redeploy consumers.&lt;/p&gt;

&lt;p&gt;Debugging what a container actually received&lt;br&gt;
Stop guessing; look:&lt;/p&gt;

&lt;p&gt;Remember the lifecycle: env changes apply on container recreation, restart alone does not re-read env_file or compose changes&lt;br&gt;
And the classic Next.js/CRA trap: build-time variables (NEXT_PUBLIC_*) must exist during the image build, not just in the runtime environment&lt;/p&gt;

&lt;p&gt;docker exec  env | sort          # runtime truth&lt;br&gt;
    docker compose config                        # fully interpolated YAML&lt;br&gt;
    docker inspect  --format '{{json .Config.Env}}' | jq&lt;/p&gt;

</description>
    </item>
    <item>
      <title>How to Reduce Docker Image Size: From Gigabytes to Megabytes</title>
      <dc:creator>Peon Sh</dc:creator>
      <pubDate>Thu, 27 Aug 2026 04:49:34 +0000</pubDate>
      <link>https://dev.to/peon_sh/how-to-reduce-docker-image-size-from-gigabytes-to-megabytes-4cjp</link>
      <guid>https://dev.to/peon_sh/how-to-reduce-docker-image-size-from-gigabytes-to-megabytes-4cjp</guid>
      <description>&lt;p&gt;Multi-stage builds, slim bases, layer ordering and .dockerignore, the techniques that cut image size by 90% and speed up every deploy.&lt;/p&gt;

&lt;p&gt;Why size matters more than it seems&lt;br&gt;
Image size is not aesthetic. Every gigabyte is pulled on deploy, stored per host, kept per release for rollbacks, and pruned eventually by someone at 2 a.m. when the disk fills. Big images slow every deploy, stretch rollback windows, and inflate the attack surface (more packages, more CVEs in every scan). The good news: 90% reductions are routine with four techniques, none of which change your application code.&lt;/p&gt;

&lt;p&gt;Technique 1: multi-stage builds&lt;br&gt;
The heavy hitter. Build with the full toolchain image; copy only the artifacts into a minimal runtime stage. Compilers, dev dependencies and source never reach production:&lt;/p&gt;

&lt;p&gt;FROM node:22 AS build           # fat: toolchain, dev deps&lt;br&gt;
    WORKDIR /app&lt;br&gt;
    COPY package*.json ./&lt;br&gt;
    RUN npm ci&lt;br&gt;
    COPY . .&lt;br&gt;
    RUN npm run build&lt;br&gt;
    FROM node:22-slim               # thin: runtime only&lt;br&gt;
    WORKDIR /app&lt;br&gt;
    ENV NODE_ENV=production&lt;br&gt;
    COPY package*.json ./&lt;br&gt;
    RUN npm ci --omit=dev&lt;br&gt;
    COPY --from=build /app/dist ./dist&lt;br&gt;
    CMD ["node", "dist/index.js"]&lt;/p&gt;

&lt;p&gt;Technique 2: pick the right base&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;node:22 (full Debian): ~1 GB, never for production&lt;/li&gt;
&lt;li&gt;node:22-slim: ~200 MB, the safe default; glibc, so native modules just work&lt;/li&gt;
&lt;li&gt;node:22-alpine: ~130 MB, smallest mainstream option; musl libc occasionally breaks native modules (sharp, canvas), test before committing&lt;/li&gt;
&lt;li&gt;distroless: ~20 MB base with no shell or package manager, excellent security posture, harder to debug in&lt;/li&gt;
&lt;li&gt;scratch/distroless-static: for compiled languages, Go and Rust binaries yield single-digit-MB images&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Technique 3: layer ordering and .dockerignore&lt;br&gt;
Docker caches layers top-down and invalidates everything below the first change. Order instructions least-to-most volatile: dependency manifests and installs before source code, so editing code never re-downloads dependencies. And always ship a .dockerignore, it shrinks the build context (faster uploads), keeps junk out of layers, and prevents secrets from leaking into image history:&lt;/p&gt;

&lt;h1&gt;
  
  
  .dockerignore
&lt;/h1&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;node_modules
.git
.next
dist
*.log
.env*
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Technique 4: measure, don’t guess&lt;br&gt;
docker history &lt;a href="" class="article-body-image-wrapper"&gt;&lt;img&gt;&lt;/a&gt; shows the size each instruction added, the offender is usually obvious. The dive tool goes deeper, showing files per layer and wasted space from files added then deleted in later layers (which does not reclaim size unless done in the same RUN). Combine cleanup with installation in one instruction: apt-get install with rm -rf /var/lib/apt/lists/* in the same RUN, or use --no-cache flags on alpine’s apk.&lt;/p&gt;

&lt;p&gt;Expected results&lt;br&gt;
Node/Next.js app: 1.1 GB naive to 130-180 MB (multi-stage + slim + standalone output)&lt;br&gt;
Python/Django: 950 MB to ~180 MB (multi-stage + python:slim)&lt;br&gt;
Go service: any size to 8-15 MB (static binary + distroless)&lt;br&gt;
Operationally: deploys move seconds of data instead of minutes, rollbacks are instant, disks stop filling, and platform build caches (like Peon’s on-server layer cache) stay effective because only your app layer changes per push&lt;/p&gt;

</description>
      <category>devops</category>
      <category>docker</category>
      <category>performance</category>
    </item>
  </channel>
</rss>
