<?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: Denis Smoliakov</title>
    <description>The latest articles on DEV Community by Denis Smoliakov (@denis0wn).</description>
    <link>https://dev.to/denis0wn</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%2F4106431%2F475d3b05-68a1-4b07-bd0e-801e9bf37aa0.png</url>
      <title>DEV Community: Denis Smoliakov</title>
      <link>https://dev.to/denis0wn</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/denis0wn"/>
    <language>en</language>
    <item>
      <title>Policy Routing on a Home Router: Put Only the Traffic You Choose Behind a VPN</title>
      <dc:creator>Denis Smoliakov</dc:creator>
      <pubDate>Wed, 02 Sep 2026 15:24:06 +0000</pubDate>
      <link>https://dev.to/denis0wn/policy-routing-on-a-home-router-put-only-the-traffic-you-choose-behind-a-vpn-4hpc</link>
      <guid>https://dev.to/denis0wn/policy-routing-on-a-home-router-put-only-the-traffic-you-choose-behind-a-vpn-4hpc</guid>
      <description>&lt;p&gt;I wanted my whole home on a VPN — and within a week I wanted most of it off again. The TV stopped authenticating. The printer went unreachable. One service that checks client IPs decided my exit node was a datacenter and locked the account pending review. The classic fix — per-device VPN clients — works until you own fifteen devices that don't have one.&lt;/p&gt;

&lt;p&gt;The answer I settled on: run the VPN on the router, but route &lt;strong&gt;by policy&lt;/strong&gt;, not by default. Devices (or destinations) you choose go through the tunnel; everything else keeps the normal ISP path. This is the setup I run on an OpenWrt-based home router, and the lessons apply to any policy-routing-capable gateway.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the problem
&lt;/h2&gt;

&lt;p&gt;A default-route VPN changes the egress for the entire LAN. That breaks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;devices that refuse to work from unfamiliar geolocations or datacenter IPs;&lt;/li&gt;
&lt;li&gt;local-only services that assume the ISP network path;&lt;/li&gt;
&lt;li&gt;anything your ISP filters differently when traffic arrives from its own CGNAT range versus a foreign exit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What we want instead: &lt;strong&gt;two routing paths and a rule that picks between them per packet&lt;/strong&gt;, based on the source device (or the destination).&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture in one paragraph
&lt;/h2&gt;

&lt;p&gt;Bring the VPN tunnel up as its own interface on the router. Create a second routing table whose default route points into the tunnel. Then mark the packets you want tunneled (by source address, MAC-derived address, or destination) and add an &lt;code&gt;ip rule&lt;/code&gt; that sends marked packets to the tunnel table. Unmarked packets follow the normal table. DNS needs the same split treatment or you'll leak.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1 — The tunnel as its own interface
&lt;/h2&gt;

&lt;p&gt;Whether it's WireGuard or a userspace client exposing a TUN device, the requirement is the same: the tunnel must be a first-class interface with its own default route in a &lt;strong&gt;separate table&lt;/strong&gt; (say, table 100). Never make it the global default.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 — Mark the traffic you want tunneled
&lt;/h2&gt;

&lt;p&gt;Firewall marks are the cleanest selector. Example: tunnel two specific devices by their LAN addresses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# mark packets from the devices we choose&lt;/span&gt;
iptables &lt;span class="nt"&gt;-t&lt;/span&gt; mangle &lt;span class="nt"&gt;-A&lt;/span&gt; PREROUTING &lt;span class="nt"&gt;-s&lt;/span&gt; 192.168.1.30 &lt;span class="nt"&gt;-j&lt;/span&gt; MARK &lt;span class="nt"&gt;--set-mark&lt;/span&gt; 0x1
iptables &lt;span class="nt"&gt;-t&lt;/span&gt; mangle &lt;span class="nt"&gt;-A&lt;/span&gt; PREROUTING &lt;span class="nt"&gt;-s&lt;/span&gt; 192.168.1.31 &lt;span class="nt"&gt;-j&lt;/span&gt; MARK &lt;span class="nt"&gt;--set-mark&lt;/span&gt; 0x1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Prefer marking in &lt;code&gt;PREROUTING&lt;/code&gt; by source so the mark is attached before the routing decision. If you need per-destination policy instead (e.g., "tunnel only traffic to these networks"), mark on destination — the mechanics are identical.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3 — Policy route the marked packets
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ip rule add fwmark 0x1 table 100
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That single line is the entire trick: marked packets consult table 100 (tunnel default route); everything else stays on the ISP path. Add a matching rule for connection tracking so reply packets stay consistent, and you're routing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4 — Split DNS or you leak
&lt;/h2&gt;

&lt;p&gt;Routing is only half of it. If every DNS query goes to the ISP resolver from the router's ISP address, your "private" devices are still identifiable, and split behavior becomes guesswork. Two sane options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;send tunneled devices' DNS to a resolver over the tunnel (DoT/DoH to a provider you trust), and keep the rest on the ISP resolver;&lt;/li&gt;
&lt;li&gt;or run a local recursive resolver and steer it per client.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Either way, make the leak explicit and tested: resolve from a tunneled device and check the resolver's observed source.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5 — Fail closed (or knowingly fail open)
&lt;/h2&gt;

&lt;p&gt;Decide what happens when the tunnel drops:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fail closed&lt;/strong&gt; for the tunneled set: drop marked traffic when the tunnel is down. Safer for privacy-sensitive devices.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fail open&lt;/strong&gt; with a loud log for everything else, so the household doesn't lose the internet because a VPN provider had a bad minute.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Implement the decision once, deliberately. The worst failure mode is the silent one where a "tunneled" device quietly egresses via the ISP and nobody notices.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas I hit in the field
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hardware NAT bypasses your rules.&lt;/strong&gt; Many home routers offload forwarding to a hardware engine that never sees your marks. If your policy routing "doesn't work", check whether HW NAT/fast path is enabled — disabling it costs some throughput and buys correctness.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MTU.&lt;/strong&gt; Tunnel overhead plus a 1500 LAN MTU produces silent breakage on some sites. Clamp MSS on the tunnel path and test with large responses, not just pings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CGNAT at the ISP.&lt;/strong&gt; If your WAN address is in a shared range, inbound is dead anyway — which simplifies your threat model (nothing is exposed) but also means you can't easily verify from outside; test from the inside.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Firmware phones home.&lt;/strong&gt; Consumer firmware with TR-069, auto-update, and cloud management will route around your intentions. Lock those down before you trust the box with policy decisions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test the boring path too.&lt;/strong&gt; The most common regression is not VPN leakage — it's the non-tunneled half breaking because a rule was too broad. Verify that a device &lt;em&gt;not&lt;/em&gt; in your marked set still resolves and browses normally.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Verification checklist
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Tunneled device's public IP matches the VPN exit.&lt;/li&gt;
&lt;li&gt;Untunneled device's public IP matches the ISP.&lt;/li&gt;
&lt;li&gt;DNS for each group resolves from the expected source.&lt;/li&gt;
&lt;li&gt;Kill the tunnel: tunneled devices either drop (fail closed) or visibly fail over — never silently leak.&lt;/li&gt;
&lt;li&gt;Reboot the router: everything comes back in the right state without you touching it.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The payoff
&lt;/h2&gt;

&lt;p&gt;You end up with a house where the default answer is "normal internet", and the VPN is a deliberate lane you assign to a device in one line of firewall config. Adding or removing a device from the tunnel is a one-liner, not a Saturday project.&lt;/p&gt;

&lt;p&gt;If you've fought a router VPN setup before, I'm curious which part bit you — hardware NAT and DNS leaks are my top candidates.&lt;/p&gt;

</description>
      <category>linux</category>
    </item>
    <item>
      <title>Before You docker build: 7 Supply-Chain Checks for Third-Party Repos</title>
      <dc:creator>Denis Smoliakov</dc:creator>
      <pubDate>Wed, 02 Sep 2026 15:20:49 +0000</pubDate>
      <link>https://dev.to/denis0wn/before-you-docker-build-7-supply-chain-checks-for-third-party-repos-413h</link>
      <guid>https://dev.to/denis0wn/before-you-docker-build-7-supply-chain-checks-for-third-party-repos-413h</guid>
      <description>&lt;p&gt;You cloned a repo, read the README, and your fingers are already typing &lt;code&gt;docker compose up&lt;/code&gt;. Stop. A third-party repository is untrusted input — and the build/run pipeline executes it. Most "it's just a demo repo" incidents I've audited started with a skipped two-minute review.&lt;/p&gt;

&lt;p&gt;Here are seven checks, in the order that catches the most problems first. None takes more than a few minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Read the Dockerfile line by line — especially RUN
&lt;/h2&gt;

&lt;p&gt;The Dockerfile &lt;em&gt;is&lt;/em&gt; the attack surface. Anything in a &lt;code&gt;RUN&lt;/code&gt; instruction executes on your builder (and often inside your runtime):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;RUN &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://example.com/setup.sh | bash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;curl | bash&lt;/code&gt; in a build is running an unaudited remote script with the builder's privileges. Watch for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;remote scripts piped to a shell (&lt;code&gt;curl | sh&lt;/code&gt;, &lt;code&gt;wget -qO- | bash&lt;/code&gt;);&lt;/li&gt;
&lt;li&gt;downloads without checksum verification;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;COPY --from=&lt;/code&gt; stages pulling from images you didn't expect;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ARG&lt;/code&gt;/&lt;code&gt;ENV&lt;/code&gt; values that look like credentials or endpoints you didn't anticipate.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the repo &lt;em&gt;generates&lt;/em&gt; its Dockerfile from templates or config (some frameworks do), audit the generator too — injection into generated Dockerfiles is a real vulnerability class, not a theoretical one.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Check what the base image actually is
&lt;/h2&gt;



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

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;latest&lt;/code&gt; means "whatever the registry serves today" — a moving target. Prefer pinned tags, better a digest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; python:3.12-slim@sha256:...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Quick sanity: does the base image match the project's claimed stack? A "minimal Redis wrapper" building from a full Node monolith image deserves a second look.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Audit the compose ports and volumes
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5432:5432"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/var/run/docker.sock:/var/run/docker.sock&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two classic mistakes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Publishing ports to &lt;code&gt;0.0.0.0&lt;/code&gt;&lt;/strong&gt; that the README says are "internal". Bind to localhost (&lt;code&gt;127.0.0.1:5432:5432&lt;/code&gt;) unless you truly need exposure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mounting the Docker socket&lt;/strong&gt; into a container. That hands the container root-equivalent control of your host. If a demo needs it, run it on a throwaway VM — not your workstation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Also scan volume mounts for sensitive host paths (&lt;code&gt;~/.ssh&lt;/code&gt;, &lt;code&gt;~/.aws&lt;/code&gt;, &lt;code&gt;/etc&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Look at the CI workflows before you trust the badges
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;.github/workflows/*.yml&lt;/code&gt; run &lt;em&gt;in the project's CI&lt;/em&gt;, but a malicious or sloppy workflow can also be a clue about the project's security culture:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;actions pinned by hash (&lt;code&gt;actions/checkout@&amp;lt;sha&amp;gt;&lt;/code&gt;) vs floating tags (&lt;code&gt;@v4&lt;/code&gt;);&lt;/li&gt;
&lt;li&gt;workflows that post secrets to external endpoints;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pull_request_target&lt;/code&gt; workflows running untrusted code with write tokens.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And remember: green badges prove tests pass, not that the code is safe.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Grep the repo for secrets and surprises
&lt;/h2&gt;

&lt;p&gt;Sixty seconds that pay for themselves:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# obvious secret patterns&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rnE&lt;/span&gt; &lt;span class="s2"&gt;"(api[_-]?key|secret|token|password)&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="s2"&gt;*[:=]&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="s2"&gt;*['&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;][A-Za-z0-9_/+=-]{12,}"&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'*'&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nb"&gt;test&lt;/span&gt;

&lt;span class="c"&gt;# remote-exec patterns&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rnE&lt;/span&gt; &lt;span class="s2"&gt;"curl[^|]*&lt;/span&gt;&lt;span class="se"&gt;\|\s&lt;/span&gt;&lt;span class="s2"&gt;*(bash|sh)|wget[^|]*&lt;/span&gt;&lt;span class="se"&gt;\|\s&lt;/span&gt;&lt;span class="s2"&gt;*(bash|sh)"&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;

&lt;span class="c"&gt;# docker socket references&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"docker.sock"&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A committed production key is a red flag about the maintainers; a postinstall script that phones home is a red flag about the software.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Pin and review dependencies you'll actually run
&lt;/h2&gt;

&lt;p&gt;For images you'll run locally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose config        &lt;span class="c"&gt;# what will actually run, with variables resolved&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For package manifests, glance at the install hooks — &lt;code&gt;postinstall&lt;/code&gt;/&lt;code&gt;prepare&lt;/code&gt; scripts execute on install:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-A3&lt;/span&gt; &lt;span class="s1"&gt;'"scripts"'&lt;/span&gt; package.json | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'postinstall|prepare|preinstall'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You don't need a full dependency audit for a weekend experiment. You need to know whether installing it runs code you haven't seen.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Run it like it's hostile
&lt;/h2&gt;

&lt;p&gt;Last line of defense — isolation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;run unfamiliar stacks on a VM or a dedicated machine, not your daily driver;&lt;/li&gt;
&lt;li&gt;use a separate browser profile/network for any UI it exposes;&lt;/li&gt;
&lt;li&gt;don't paste real credentials into a demo you haven't reviewed;&lt;/li&gt;
&lt;li&gt;keep the container runtime updated; consider &lt;code&gt;--read-only&lt;/code&gt; filesystems and dropped capabilities for anything long-lived.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The two-minute habit
&lt;/h2&gt;

&lt;p&gt;You won't do all seven every time. But checks 1, 3 and 5 catch the majority of real problems, and together they take about two minutes. Treat every third-party repo the way you treat a binary download from 2005: it doesn't get to run on your machine until you've decided it's allowed to.&lt;/p&gt;

&lt;p&gt;What's the first thing you check in an unfamiliar repo? I'm collecting war stories — share yours in the comments.&lt;/p&gt;

</description>
      <category>tutorial</category>
    </item>
    <item>
      <title>Automatic HTTPS for Your Dockerized App with Caddy</title>
      <dc:creator>Denis Smoliakov</dc:creator>
      <pubDate>Wed, 02 Sep 2026 15:18:11 +0000</pubDate>
      <link>https://dev.to/denis0wn/automatic-https-for-your-dockerized-app-with-caddy-442h</link>
      <guid>https://dev.to/denis0wn/automatic-https-for-your-dockerized-app-with-caddy-442h</guid>
      <description>&lt;p&gt;Most "serve your app over HTTPS" tutorials still reach for Nginx plus certbot: hand-written reverse-proxy configs, a cron job for certificate renewal, and a debugging session for every &lt;code&gt;acme-challenge&lt;/code&gt; failure. Caddy removes all of that. It obtains and renews TLS certificates automatically, and the entire setup fits in two lines of config.&lt;/p&gt;

&lt;p&gt;In this tutorial you'll put a Dockerized application behind Caddy so that it's served over HTTPS with zero certificate management. No certbot, no renewal timers, no Nginx.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A Linux server with Docker and Docker Compose installed (tested on Ubuntu 22.04+).&lt;/li&gt;
&lt;li&gt;A domain name you control, with DNS access.&lt;/li&gt;
&lt;li&gt;Ports &lt;code&gt;80&lt;/code&gt; and &lt;code&gt;443&lt;/code&gt; reachable from the internet (needed for the ACME HTTP-01 challenge).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1 — Run your app on an internal port
&lt;/h2&gt;

&lt;p&gt;You can use any containerized app. For this tutorial, a minimal example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# docker-compose.yml&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx:alpine&lt;/span&gt;   &lt;span class="c1"&gt;# stand-in for your real application&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part: your app must be reachable &lt;strong&gt;from the Caddy container&lt;/strong&gt;, not from the internet. Either don't publish its ports at all, or bind them to localhost only. Caddy will be the only service exposed publicly.&lt;/p&gt;

&lt;p&gt;If your real app listens on port &lt;code&gt;3000&lt;/code&gt;, leave it unexposed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;your-app:latest&lt;/span&gt;
    &lt;span class="c1"&gt;# no `ports:` here — reachable only inside the compose network&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 2 — Add Caddy to the stack
&lt;/h2&gt;

&lt;p&gt;Add a &lt;code&gt;caddy&lt;/code&gt; service to the same compose file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;your-app:latest&lt;/span&gt;

  &lt;span class="na"&gt;caddy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;caddy:2-alpine&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;80:80"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;443:443"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;443:443/udp"&lt;/span&gt;   &lt;span class="c1"&gt;# HTTP/3 (QUIC)&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./Caddyfile:/etc/caddy/Caddyfile:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;caddy_data:/data&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;caddy_config:/config&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Two things matter here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;caddy_data&lt;/code&gt; persists TLS certificates across container rebuilds. Losing it forces re-issuance and counts against Let's Encrypt rate limits.&lt;/li&gt;
&lt;li&gt;Port &lt;code&gt;443/udp&lt;/code&gt; enables HTTP/3; it's optional but costs nothing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now the Caddyfile. Create &lt;code&gt;Caddyfile&lt;/code&gt; next to your compose file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;app.example.com&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;reverse_proxy&lt;/span&gt; &lt;span class="nf"&gt;app&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;
&lt;span class="err"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replace &lt;code&gt;app.example.com&lt;/code&gt; with your domain and &lt;code&gt;app:3000&lt;/code&gt; with your app's service name and port. That's the entire configuration. Caddy reads the site address, sees it's a real domain (not &lt;code&gt;localhost&lt;/code&gt;), and automatically switches on TLS: it will solve the ACME HTTP-01 challenge, obtain the certificate from Let's Encrypt, store it in &lt;code&gt;/data&lt;/code&gt;, and renew it in the background whenever it nears expiry.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3 — Point DNS and open the firewall
&lt;/h2&gt;

&lt;p&gt;Create an A record for your domain pointing at the server's public IP. If you run &lt;code&gt;ufw&lt;/code&gt;, allow the web ports:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow 80/tcp
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow 443/tcp
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow 443/udp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 4 — Start the stack and verify
&lt;/h2&gt;



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

&lt;/div&gt;



&lt;p&gt;Watch Caddy obtain the certificate on first run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose logs &lt;span class="nt"&gt;-f&lt;/span&gt; caddy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see lines like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;certificate obtained successfully
certificate stored in storage
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then verify from any machine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-I&lt;/span&gt; https://app.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expect &lt;code&gt;HTTP/2 200&lt;/code&gt; (or &lt;code&gt;HTTP/3&lt;/code&gt; from a browser). Your app is now served with a valid, automatically-renewed certificate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Useful variations
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Parameterize the domain.&lt;/strong&gt; Pass it through the environment instead of editing the Caddyfile:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="err"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$DOMAIN&lt;/span&gt;&lt;span class="err"&gt;}&lt;/span&gt; &lt;span class="err"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;reverse_proxy&lt;/span&gt; &lt;span class="nf"&gt;app&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;
&lt;span class="err"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;  &lt;span class="na"&gt;caddy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;DOMAIN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app.example.com&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Test against staging.&lt;/strong&gt; If you're iterating and don't want to burn Let's Encrypt rate limits, point Caddy at the staging CA while you experiment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="err"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;acme_ca&lt;/span&gt; &lt;span class="s"&gt;https://acme-staging-v02.api.letsencrypt.org/directory&lt;/span&gt;
&lt;span class="err"&gt;}&lt;/span&gt;

&lt;span class="s"&gt;app.example.com&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;reverse_proxy&lt;/span&gt; &lt;span class="nf"&gt;app&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;
&lt;span class="err"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Remove the &lt;code&gt;acme_ca&lt;/code&gt; line for production.&lt;/p&gt;

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

&lt;p&gt;Caddy turns "HTTPS for my container" from an ops project into two lines of config: one site block, one &lt;code&gt;reverse_proxy&lt;/code&gt;. Certificates are issued on first start and renewed silently forever after, and the persistent &lt;code&gt;/data&lt;/code&gt; volume keeps them safe across rebuilds. If you're starting a new Docker deployment, there's little reason to manage certificates by hand anymore.&lt;/p&gt;

</description>
      <category>security</category>
    </item>
  </channel>
</rss>
