{"id":32,"date":"2026-10-03T11:01:44","date_gmt":"2026-10-03T11:01:44","guid":{"rendered":"https:\/\/hoho.live\/luda\/index.php\/2026\/10\/03\/how-promtail-ships-container-logs-to-loki-across-an-ipsec-split-tunnel\/"},"modified":"2026-10-04T09:48:23","modified_gmt":"2026-10-04T01:48:23","slug":"how-promtail-ships-container-logs-to-loki-across-an-ipsec-split-tunnel","status":"publish","type":"post","link":"https:\/\/hoho.live\/luda\/index.php\/2026\/10\/03\/how-promtail-ships-container-logs-to-loki-across-an-ipsec-split-tunnel\/","title":{"rendered":"How Promtail ships container logs to Loki across an IPsec split tunnel"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\"><strong>Short answer:<\/strong> Do not open a remote Docker socket to read container stdout. <strong>Promtail<\/strong> on the remote host tails the local socket and <strong>pushes<\/strong> to <strong>Loki<\/strong>. If that host&#8217;s IPsec policy can reach only one virtual address, and Loki is bound to a different virtual address on the same machine, push to the address the tunnel allows and <strong>DNAT<\/strong> port 3100 onto Loki. Loki uses the <strong>host network<\/strong> so it can bind that address. The viewer then reads Loki. That is the design, not a temporary bypass.<\/p>\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n<h4 class=\"wp-block-heading\"><strong>What Promtail sends<\/strong><\/h4>\n\n<p class=\"wp-block-paragraph\">Promtail sends stdout and stderr. Docker stores those lines on the container host. Promtail reads new lines through the local Docker API and pushes them to Loki. Other machines read that copy.<\/p>\n\n\n<p class=\"wp-block-paragraph\"><code>\/var\/run\/docker.sock<\/code> is opened only on that host. It is the engine&#8217;s control API: the same socket that reads logs can start, stop, and create containers, and run commands inside them. Opening that socket on another host, or publishing it as a TCP port across the VPN, is a security risk. The far side then controls the engine. Promtail opens the socket locally, read-only, and sends only the log lines.<\/p>\n\n\n<p class=\"wp-block-paragraph\">A viewer already on the Docker host reads the socket itself. Every other host runs Promtail.<\/p>\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n<h4 class=\"wp-block-heading\"><strong>What a split tunnel will forward<\/strong><\/h4>\n\n<p class=\"wp-block-paragraph\">Picture the Loki host with interface <code>br0<\/code>, and two virtual addresses on it. The numbers are examples.<\/p>\n\n\n<ul class=\"wp-block-list\">\n<li><code>10.11.0.1\/32<\/code> is tunnel A. The policy subnet is <code>10.11.0.0\/24<\/code>. Loki binds <code>10.11.0.1:3100<\/code>.<\/li>\n<li><code>10.22.0.1\/32<\/code> is tunnel B. The policy subnet is <code>10.22.0.0\/24<\/code>.<\/li>\n<\/ul>\n\n\n<p class=\"wp-block-paragraph\">A packet enters a tunnel only when its destination is inside that tunnel&#8217;s subnet. A peer of tunnel B may send to <code>10.22.0.0\/24<\/code>. A connection to <code>10.11.0.1:3100<\/code> does not match that policy, so it times out. Both addresses are on <code>br0<\/code>. Only one of them is inside this peer&#8217;s policy.<\/p>\n\n\n<p class=\"wp-block-paragraph\">Promtail with <code>network_mode: host<\/code> uses that routing table. It can open <code>10.22.0.1:3100<\/code>. It cannot open <code>10.11.0.1:3100<\/code>.<\/p>\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n<h4 class=\"wp-block-heading\"><strong>The working path<\/strong><\/h4>\n\n<ol class=\"wp-block-list\">\n<li>Promtail, host network, Docker socket read-only, <code>docker_sd_configs<\/code> on <code>unix:\/\/\/var\/run\/docker.sock<\/code>. Relabel the container name and set a <code>host<\/code> label.<\/li>\n<li>Push URL <code>http:\/\/10.22.0.1:3100\/loki\/api\/v1\/push<\/code> \u2014 the virtual address the peer policy already allows.<\/li>\n<li>On the Loki host, nat PREROUTING DNATs TCP <code>10.22.0.1:3100<\/code> to <code>10.11.0.1:3100<\/code>, the address in Loki&#8217;s <code>http_listen_address<\/code>.<\/li>\n<li>Loki uses <code>network_mode: host<\/code> and binds <code>10.11.0.1<\/code>, so the rewritten packet is delivered locally.<\/li>\n<li>Conntrack restores the reply source to <code>10.22.0.1<\/code>, which is what the IPsec policy matches on the way back.<\/li>\n<li>On the Loki host, the firewall accepts TCP 3100 from the Promtail peer.<\/li>\n<li>The viewer calls Loki <code>query_range<\/code> with <code>{host=\"\u2026\",container=\"\u2026\"}<\/code>, built by pasting the requested container name into the query. Accept only a plain container name (letters, digits, <code>.<\/code>, <code>_<\/code>, <code>-<\/code>) before that paste. A quote or a brace in the name would change which streams Loki returns.<\/li>\n<\/ol>\n\n\n<p class=\"wp-block-paragraph\">Stamp a Promtail position for each running container id at &#8220;now&#8221; before the first start, and leave an existing cursor alone. The docker target&#8217;s key is <code>cursor-&lt;container id&gt;<\/code>. A missing cursor reads the json log from the start (<code>Since=0<\/code>). Stop Promtail before writing the positions file, or its flush replaces the keys. Afterward a container appears once it writes a new line.<\/p>\n\n\n<p class=\"wp-block-paragraph\">Daemons that log only to files are a different pipeline. This path is stdout and stderr.<\/p>\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n<h4 class=\"wp-block-heading\"><strong>Approaches that look simpler and fail<\/strong><\/h4>\n\n<figure class=\"wp-block-table\">\n<table>\n<thead>\n<tr>\n<th>Approach<\/th>\n<th>Why it fails<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>Publish the Docker socket, or Docker&#8217;s TCP API, across the VPN<\/td>\n<td>The socket is the engine control API. The far side can start, stop, and create containers.<\/td>\n<\/tr>\n<tr>\n<td>Bind Loki to 0.0.0.0 while authentication is disabled<\/td>\n<td>Loki then answers on every address of the host, including the LAN address. A client that can reach port 3100 can query and push.<\/td>\n<\/tr>\n<tr>\n<td>Add <code>10.11.0.0\/24<\/code> to the Promtail peer&#8217;s IPsec policy<\/td>\n<td>The peer can then reach that whole subnet, not only the one port forwarded to Loki.<\/td>\n<\/tr>\n<tr>\n<td>Point Promtail at <code>10.11.0.1:3100<\/code><\/td>\n<td>That address is outside <code>10.22.0.0\/24<\/code>. The packet never enters the tunnel and times out.<\/td>\n<\/tr>\n<tr>\n<td>Start Promtail with an empty positions file<\/td>\n<td>With no cursor, the docker target reads each container&#8217;s json log from the start and ships the backlog.<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<\/figure>\n\n\n<p class=\"wp-block-paragraph\">A login proxy in front of Loki protects browsers that query it. It does not give Promtail a route to <code>10.11.0.1<\/code>.<\/p>\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n<h4 class=\"wp-block-heading\"><strong>When you can drop the DNAT<\/strong><\/h4>\n\n<p class=\"wp-block-paragraph\">Drop the rewrite when one of these is true:<\/p>\n\n\n<ul class=\"wp-block-list\">\n<li>The peer policy already includes the subnet Loki binds.<\/li>\n<li>You move <code>http_listen_address<\/code> onto the VIP the peer can already reach, and that address is an acceptable ingest endpoint.<\/li>\n<li>You stop shipping remote stdout and only read the host that holds the socket.<\/li>\n<\/ul>\n\n\n<p class=\"wp-block-paragraph\">Those are policy or bind-address changes, not a hidden Promtail switch. Keep the DNAT while Loki has to stay on the other virtual address.<\/p>\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n<h4 class=\"wp-block-heading\"><strong>Why this stays published<\/strong><\/h4>\n\n<p class=\"wp-block-paragraph\">We hit this on a homelab where one Loki serves several IPsec peers and each peer policy is a single virtual subnet. <strong>Promtail pushes to the VIP the tunnel allows; DNAT on the Loki host hands that port to the address Loki binds, and the host network is what makes the bind possible.<\/strong><\/p>\n\n\n<p class=\"wp-block-paragraph\">\u672c\u6587\u7531 HoHo \u8207 AI \u5354\u4f5c\u6574\u7406\uff0c\u6700\u5f8c\u66f4\u65b0\u65bc 2026 \u5e74 10 \u6708 3 \u65e5\u3002<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Short answer: Do not open a remote Docker socket to read container stdout. Promtail on the remote host tails the local socket and pushes to Loki. If that host&#8217;s IPsec policy can reach only one virtual address, and Loki is bound to a different virtual address on the same&hellip;<\/p>\n","protected":false},"author":2,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[7,8,17],"tags":[15,16,14,13],"class_list":["post-32","post","type-post","status-publish","format-standard","hentry","category-homelab","category-ipsec","category-logging","tag-docker","tag-ipsec","tag-loki","tag-promtail"],"_links":{"self":[{"href":"https:\/\/hoho.live\/luda\/index.php\/wp-json\/wp\/v2\/posts\/32","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/hoho.live\/luda\/index.php\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/hoho.live\/luda\/index.php\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/hoho.live\/luda\/index.php\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/hoho.live\/luda\/index.php\/wp-json\/wp\/v2\/comments?post=32"}],"version-history":[{"count":7,"href":"https:\/\/hoho.live\/luda\/index.php\/wp-json\/wp\/v2\/posts\/32\/revisions"}],"predecessor-version":[{"id":50,"href":"https:\/\/hoho.live\/luda\/index.php\/wp-json\/wp\/v2\/posts\/32\/revisions\/50"}],"wp:attachment":[{"href":"https:\/\/hoho.live\/luda\/index.php\/wp-json\/wp\/v2\/media?parent=32"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/hoho.live\/luda\/index.php\/wp-json\/wp\/v2\/categories?post=32"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/hoho.live\/luda\/index.php\/wp-json\/wp\/v2\/tags?post=32"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}