<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
    <channel>
        <title>DSA Panicle Blog</title>
        <link>https://dsapanicle.com/blog</link>
        <description>DSA Panicle Blog</description>
        <lastBuildDate>Sun, 02 Aug 2026 00:00:00 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <language>en</language>
        <item>
            <title><![CDATA[Deploying a Self-Hosted Temporal Cluster with mTLS, Auto-Renewing Certs and an SSO-Protected UI]]></title>
            <link>https://dsapanicle.com/blog/deploying-temporal-with-mtls</link>
            <guid>https://dsapanicle.com/blog/deploying-temporal-with-mtls</guid>
            <pubDate>Sun, 02 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Standing up Temporal on a single EC2 box: workers authenticated over mutual TLS with self-renewing certs, an SSO-protected dashboard and exactly one inbound port open.]]></description>
            <content:encoded><![CDATA[<p>This is the story of standing up a production-ish <a href="https://temporal.io/" target="_blank" rel="noopener noreferrer" class="">Temporal</a> cluster on a single EC2 box - where the workers authenticate over mutual TLS with certificates that renew themselves and the web dashboard sits behind single sign-on, all with exactly <strong>one</strong> inbound port open to the internet.</p>
<p>It's also a story about the wrong turns, because the wrong turns are where the actual learning is. If you're setting up something similar, the dead-ends below will save you an afternoon.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-i-set-out-to-build">What I set out to build<a href="https://dsapanicle.com/blog/deploying-temporal-with-mtls#what-i-set-out-to-build" class="hash-link" aria-label="Direct link to What I set out to build" title="Direct link to What I set out to build" translate="no">​</a></h2>
<p>Three machines already sit on a Tailscale mesh for unrelated reasons. The goal was narrow:</p>
<ul>
<li class="">Run a Temporal cluster on <strong>one</strong> of those nodes.</li>
<li class="">Let workers connect from the other nodes - or from anywhere off the mesh entirely.</li>
<li class="">Keep it secure without a lot of moving parts.</li>
</ul>
<p>The workers being able to live <em>anywhere</em>, mesh or not, turned out to be the constraint that drove every subsequent decision.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="understanding-what-temporal-server-actually-is">Understanding what "Temporal server" actually is<a href="https://dsapanicle.com/blog/deploying-temporal-with-mtls#understanding-what-temporal-server-actually-is" class="hash-link" aria-label="Direct link to Understanding what &quot;Temporal server&quot; actually is" title="Direct link to Understanding what &quot;Temporal server&quot; actually is" translate="no">​</a></h2>
<p>Before any of this made sense, I had to fix a mental model. I thought "server," "frontend" and "Postgres" were three separate components. They're not.</p>
<p>The <strong>Temporal Server</strong> is a single program containing four internal roles: Frontend, History, Matching and an internal Worker. Of those, <strong>only the Frontend is reachable from outside</strong> - it's the gRPC API on port <code>7233</code>. The other three are internal plumbing.</p>
<p>And crucially: <strong>"Frontend" does not mean the web UI.</strong> That naming collision trips everyone up. In Temporal-speak, the Frontend is the API door. The dashboard you look at in a browser is a <em>separate</em> program (the <strong>Web UI</strong>, port <code>8080</code>) that is itself just another client of the Frontend.</p>
<p>So the real component list is:</p>
<ol>
<li class=""><strong>Temporal Server</strong> - one program; its only external door is the Frontend (gRPC, <code>7233</code>).</li>
<li class=""><strong>Web UI</strong> - a separate program (HTTP, <code>8080</code>) that also talks to the Frontend behind the scenes.</li>
<li class=""><strong>PostgreSQL</strong> - the database (<code>5432</code>).</li>
</ol>
<p>The payoff of understanding this: there's really only <strong>one door to secure</strong> - the Frontend on <code>7233</code>. Everything else is either internal or a browser app.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="dead-end-1-exposing-grpc-through-a-cloudflare-tunnel">Dead end #1: exposing gRPC through a Cloudflare Tunnel<a href="https://dsapanicle.com/blog/deploying-temporal-with-mtls#dead-end-1-exposing-grpc-through-a-cloudflare-tunnel" class="hash-link" aria-label="Direct link to Dead end #1: exposing gRPC through a Cloudflare Tunnel" title="Direct link to Dead end #1: exposing gRPC through a Cloudflare Tunnel" translate="no">​</a></h2>
<p>My first instinct for the "workers from anywhere" problem was a Cloudflare Tunnel with an Access login policy. It seemed clean: no open ports, SSO in front.</p>
<p>It doesn't work for the Frontend, for two independent reasons:</p>
<ol>
<li class=""><strong>"Application with login" is interactive browser SSO.</strong> Workers are headless processes - they can't complete an OAuth redirect. For non-interactive clients Cloudflare uses <em>service tokens</em>, not login.</li>
<li class=""><strong>Cloudflare doesn't proxy gRPC over a public hostname.</strong> Per their own docs, gRPC over Tunnel is only supported via private-network routing, not public-hostname ingress.</li>
</ol>
<p>The workable Cloudflare paths for gRPC (<code>cloudflared access tcp</code> with a service token on each worker, or WARP + private routing) all boil down to <em>"install a Cloudflare client on every worker."</em> Which is the same operational footprint as just putting the worker on Tailscale - except it's a second overlay to manage.</p>
<p><strong>Lesson:</strong> Cloudflare Tunnel + Access login is perfect for the <em>browser UI</em>. It's the wrong tool for a gRPC API consumed by machines.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-worker-connectivity-decision-mtls-for-everyone">The worker connectivity decision: mTLS for everyone<a href="https://dsapanicle.com/blog/deploying-temporal-with-mtls#the-worker-connectivity-decision-mtls-for-everyone" class="hash-link" aria-label="Direct link to The worker connectivity decision: mTLS for everyone" title="Direct link to The worker connectivity decision: mTLS for everyone" translate="no">​</a></h2>
<p>That left two real options for worker → Frontend:</p>
<ul>
<li class=""><strong>Tailscale:</strong> workers join the mesh, connect in plaintext, encryption and access control handled by WireGuard. Simplest - <em>for me</em>. But it costs the worker operator more: they have to install a daemon and become a node on my private network.</li>
<li class=""><strong>mTLS:</strong> hand each worker a certificate. Works from anywhere on the internet, no network entanglement. Costs <em>me</em> more: I run a small certificate authority.</li>
</ul>
<p>I chose <strong>mTLS for all workers</strong> and the reasoning is the useful part: <em>a cert is just a file; network membership is a relationship.</em> For third parties running workers, a cert is the more professional, decoupled interface. And one uniform path beats maintaining "mesh workers here, cert workers there."</p>
<p>The honest catch: mTLS doesn't remove overhead, it moves it to you. You're now responsible for issuing certs, <strong>rotating them before they expire</strong> (the 2am outage waiting to happen) and exposing the Frontend port (mTLS-gated, but reachable). The rotation problem is the one that bites - which is why the CA choice mattered.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="mtls-in-one-paragraph">mTLS in one paragraph<a href="https://dsapanicle.com/blog/deploying-temporal-with-mtls#mtls-in-one-paragraph" class="hash-link" aria-label="Direct link to mTLS in one paragraph" title="Direct link to mTLS in one paragraph" translate="no">​</a></h3>
<p>Normal TLS: the server proves its identity (the padlock). Mutual TLS: <em>both</em> sides prove identity. The worker proves it's a legit worker; the server proves it's the real server. Since Temporal's Frontend has no password, the <strong>certificate is the password</strong> - the server only admits connections presenting a cert signed by a CA it trusts. Think keycard system: the <strong>CA</strong> is the keycard printer, the server checks cards at the door, each worker carries a card.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="infisical-as-the-certificate-authority">Infisical as the certificate authority<a href="https://dsapanicle.com/blog/deploying-temporal-with-mtls#infisical-as-the-certificate-authority" class="hash-link" aria-label="Direct link to Infisical as the certificate authority" title="Direct link to Infisical as the certificate authority" translate="no">​</a></h2>
<p>Rather than hand-rolling a CA with <code>openssl</code> (fine to learn once, miserable to operate), I used <a href="https://infisical.com/" target="_blank" rel="noopener noreferrer" class="">Infisical</a>, which has a full private-PKI product: private CA hierarchies, lifecycle management and - the important bit - <strong>automated renewal with expiry alerts</strong>. It's open-source and has a free cloud tier.</p>
<p>The setup order in Infisical's Certificate Manager is layered and the UI does not make this obvious:</p>
<ol>
<li class=""><strong>Certificate Authority</strong> - the thing that signs. (I created a single Root CA; for a small deployment you don't need the textbook root + intermediate split, especially since Infisical holds the keys either way.)</li>
<li class=""><strong>Certificate Policy</strong> - the rules (I left it permissive).</li>
<li class=""><strong>Certificate Profile</strong> - bundles a CA + policy into a reusable template.</li>
<li class=""><strong>Application</strong> - a workload that issues certs through a profile.</li>
</ol>
<p>The gotcha: an "Application" is <em>not</em> a CA and it's the <em>last</em> step, not the first. I created one first (out of order) and left it empty until the end.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-one-profile-trick">The one profile trick<a href="https://dsapanicle.com/blog/deploying-temporal-with-mtls#the-one-profile-trick" class="hash-link" aria-label="Direct link to The one profile trick" title="Direct link to The one profile trick" translate="no">​</a></h3>
<p>A certificate can carry <code>serverAuth</code>, <code>clientAuth</code> or both. Instead of two profiles, I made <strong>one mTLS profile with both</strong> extended key usages. That single template issues my server cert <em>and</em> my worker certs - they differ only by the name on them.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-san-gotcha">The SAN gotcha<a href="https://dsapanicle.com/blog/deploying-temporal-with-mtls#the-san-gotcha" class="hash-link" aria-label="Direct link to The SAN gotcha" title="Direct link to The SAN gotcha" translate="no">​</a></h3>
<p>This is the single most common thing that breaks Temporal mTLS. The server certificate's Subject Alternative Names must match what clients dial. Temporal also does IP-to-IP internal comms, so you pin a logical <code>serverName</code> and put it in the cert as a SAN. My server cert ended up with four SANs:</p>
<ul>
<li class=""><code>temporal.kaplabs.dev</code> - what external workers dial</li>
<li class=""><code>temporal-frontend</code> - the internal name for the local UI/services</li>
<li class=""><code>localhost</code></li>
<li class=""><code>127.0.0.1</code></li>
</ul>
<p>Get this wrong and you get <code>x509: certificate is valid for X, not Y</code>.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="dead-end-2-avoided-native-mtls-in-the-all-in-one-image">Dead end #2 (avoided): native mTLS in the all-in-one image<a href="https://dsapanicle.com/blog/deploying-temporal-with-mtls#dead-end-2-avoided-native-mtls-in-the-all-in-one-image" class="hash-link" aria-label="Direct link to Dead end #2 (avoided): native mTLS in the all-in-one image" title="Direct link to Dead end #2 (avoided): native mTLS in the all-in-one image" translate="no">​</a></h2>
<p>Temporal's <code>auto-setup</code> Docker image <em>can</em> do mTLS via <code>TEMPORAL_TLS_*</code> environment variables. But turning on <code>requireClientAuth</code> means the image's <strong>own internal health checks and namespace setup</strong> also have to speak TLS and getting that right is genuinely fiddly.</p>
<p>So I sidestepped it entirely with a cleaner architecture:</p>
<blockquote>
<p><strong>Temporal runs plaintext, locked to the internal Docker network. A tiny nginx sits in front doing the mTLS.</strong></p>
</blockquote>
<p>nginx terminates the worker's mutual-TLS handshake using the Infisical certs, then forwards plain gRPC to Temporal over the private network. Workers get full mTLS; Temporal stays vanilla and boots cleanly; the local UI talks to it with zero cert config. The only publicly exposed thing is nginx on <code>7233</code> and it rejects anyone without a valid client cert at the handshake.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-stack">The stack<a href="https://dsapanicle.com/blog/deploying-temporal-with-mtls#the-stack" class="hash-link" aria-label="Direct link to The stack" title="Direct link to The stack" translate="no">​</a></h3>
<p><code>docker-compose.yml</code>:</p>
<div style="background-color:#0d1117;border:1px solid #30363d;border-radius:8px;overflow:hidden;margin-bottom:1rem;font-size:0.875rem;font-family:'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;line-height:1.6"><div style="position:relative"><div style="position:absolute;top:0.5rem;right:0.5rem;z-index:2"><button title="Copy to clipboard" style="background:none;border:1px solid #30363d;border-radius:4px;padding:0.2rem 0.5rem;cursor:pointer;font-size:0.7rem;color:#8b949e;font-family:system-ui, sans-serif;transition:all 0.15s;white-space:nowrap">Copy</button></div><div style="overflow-x:auto;padding:0.75rem 0"><div style="padding:0.75rem 1rem;color:#484f58;font-family:system-ui;font-size:0.8rem"><div style="padding:0 1rem;white-space:pre">services:</div><div style="padding:0 1rem;white-space:pre">  postgresql:</div><div style="padding:0 1rem;white-space:pre">    image: postgres:16-alpine</div><div style="padding:0 1rem;white-space:pre">    environment:</div><div style="padding:0 1rem;white-space:pre">      POSTGRES_USER: temporal</div><div style="padding:0 1rem;white-space:pre">      POSTGRES_PASSWORD: temporal   # change this</div><div style="padding:0 1rem;white-space:pre">      POSTGRES_DB: temporal</div><div style="padding:0 1rem;white-space:pre">    volumes:</div><div style="padding:0 1rem;white-space:pre">      - temporal-pgdata:/var/lib/postgresql/data</div><div style="padding:0 1rem;white-space:pre">    healthcheck:</div><div style="padding:0 1rem;white-space:pre">      test: ["CMD-SHELL", "pg_isready -U temporal"]</div><div style="padding:0 1rem;white-space:pre">      interval: 5s</div><div style="padding:0 1rem;white-space:pre">      timeout: 5s</div><div style="padding:0 1rem;white-space:pre">      retries: 10</div><div style="padding:0 1rem;white-space:pre">    restart: unless-stopped</div><div style="padding:0 1rem;white-space:pre">&nbsp;</div><div style="padding:0 1rem;white-space:pre">  temporal:</div><div style="padding:0 1rem;white-space:pre">    image: temporalio/auto-setup:1.27.2</div><div style="padding:0 1rem;white-space:pre">    depends_on:</div><div style="padding:0 1rem;white-space:pre">      postgresql:</div><div style="padding:0 1rem;white-space:pre">        condition: service_healthy</div><div style="padding:0 1rem;white-space:pre">    environment:</div><div style="padding:0 1rem;white-space:pre">      - DB=postgres12</div><div style="padding:0 1rem;white-space:pre">      - DB_PORT=5432</div><div style="padding:0 1rem;white-space:pre">      - POSTGRES_SEEDS=postgresql</div><div style="padding:0 1rem;white-space:pre">      - POSTGRES_USER=temporal</div><div style="padding:0 1rem;white-space:pre">      - POSTGRES_PWD=temporal</div><div style="padding:0 1rem;white-space:pre">      - NUM_HISTORY_SHARDS=512</div><div style="padding:0 1rem;white-space:pre">      - BIND_ON_IP=0.0.0.0</div><div style="padding:0 1rem;white-space:pre">      - TEMPORAL_BROADCAST_ADDRESS=127.0.0.1</div><div style="padding:0 1rem;white-space:pre">      - SKIP_ADD_CUSTOM_SEARCH_ATTRIBUTES=true</div><div style="padding:0 1rem;white-space:pre">    # No ports published. Reachable only inside the network as temporal:7233.</div><div style="padding:0 1rem;white-space:pre">    restart: unless-stopped</div><div style="padding:0 1rem;white-space:pre">&nbsp;</div><div style="padding:0 1rem;white-space:pre">  temporal-ui:</div><div style="padding:0 1rem;white-space:pre">    image: temporalio/ui:2.34.0</div><div style="padding:0 1rem;white-space:pre">    depends_on:</div><div style="padding:0 1rem;white-space:pre">      - temporal</div><div style="padding:0 1rem;white-space:pre">    environment:</div><div style="padding:0 1rem;white-space:pre">      - TEMPORAL_ADDRESS=temporal:7233</div><div style="padding:0 1rem;white-space:pre">      - TEMPORAL_UI_PORT=8080</div><div style="padding:0 1rem;white-space:pre">    ports:</div><div style="padding:0 1rem;white-space:pre">      - "127.0.0.1:8080:8080"   # localhost only; cloudflared picks this up</div><div style="padding:0 1rem;white-space:pre">    restart: unless-stopped</div><div style="padding:0 1rem;white-space:pre">&nbsp;</div><div style="padding:0 1rem;white-space:pre">  nginx:</div><div style="padding:0 1rem;white-space:pre">    image: nginx:1.27-alpine</div><div style="padding:0 1rem;white-space:pre">    depends_on:</div><div style="padding:0 1rem;white-space:pre">      - temporal</div><div style="padding:0 1rem;white-space:pre">    volumes:</div><div style="padding:0 1rem;white-space:pre">      - ./nginx.conf:/etc/nginx/nginx.conf:ro</div><div style="padding:0 1rem;white-space:pre">      - /etc/temporal/certs:/certs:ro</div><div style="padding:0 1rem;white-space:pre">    ports:</div><div style="padding:0 1rem;white-space:pre">      - "7233:7233"   # the ONLY public port; mTLS-gated</div><div style="padding:0 1rem;white-space:pre">    restart: unless-stopped</div><div style="padding:0 1rem;white-space:pre">&nbsp;</div><div style="padding:0 1rem;white-space:pre">volumes:</div><div style="padding:0 1rem;white-space:pre">  temporal-pgdata:</div></div></div></div></div>
<p><code>nginx.conf</code> - the mTLS terminator:</p>
<div style="background-color:#0d1117;border:1px solid #30363d;border-radius:8px;overflow:hidden;margin-bottom:1rem;font-size:0.875rem;font-family:'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;line-height:1.6"><div style="position:relative"><div style="position:absolute;top:0.5rem;right:0.5rem;z-index:2"><button title="Copy to clipboard" style="background:none;border:1px solid #30363d;border-radius:4px;padding:0.2rem 0.5rem;cursor:pointer;font-size:0.7rem;color:#8b949e;font-family:system-ui, sans-serif;transition:all 0.15s;white-space:nowrap">Copy</button></div><div style="overflow-x:auto;padding:0.75rem 0"><div style="padding:0.75rem 1rem;color:#484f58;font-family:system-ui;font-size:0.8rem"><div style="padding:0 1rem;white-space:pre">worker_processes auto;</div><div style="padding:0 1rem;white-space:pre">events { worker_connections 1024; }</div><div style="padding:0 1rem;white-space:pre">&nbsp;</div><div style="padding:0 1rem;white-space:pre">http {</div><div style="padding:0 1rem;white-space:pre">    server {</div><div style="padding:0 1rem;white-space:pre">        listen 7233 ssl;</div><div style="padding:0 1rem;white-space:pre">        http2 on;</div><div style="padding:0 1rem;white-space:pre">        server_name temporal.kaplabs.dev;</div><div style="padding:0 1rem;white-space:pre">&nbsp;</div><div style="padding:0 1rem;white-space:pre">        ssl_certificate     /certs/server.pem;</div><div style="padding:0 1rem;white-space:pre">        ssl_certificate_key /certs/server.key;</div><div style="padding:0 1rem;white-space:pre">&nbsp;</div><div style="padding:0 1rem;white-space:pre">        # The "m" in mTLS: require + verify client certs against our CA.</div><div style="padding:0 1rem;white-space:pre">        ssl_client_certificate /certs/ca.pem;</div><div style="padding:0 1rem;white-space:pre">        ssl_verify_client on;</div><div style="padding:0 1rem;white-space:pre">        ssl_verify_depth 2;</div><div style="padding:0 1rem;white-space:pre">&nbsp;</div><div style="padding:0 1rem;white-space:pre">        ssl_protocols TLSv1.2 TLSv1.3;</div><div style="padding:0 1rem;white-space:pre">&nbsp;</div><div style="padding:0 1rem;white-space:pre">        # Temporal workers hold long-poll gRPC streams open. Let them.</div><div style="padding:0 1rem;white-space:pre">        grpc_read_timeout 3600s;</div><div style="padding:0 1rem;white-space:pre">        grpc_send_timeout 3600s;</div><div style="padding:0 1rem;white-space:pre">&nbsp;</div><div style="padding:0 1rem;white-space:pre">        location / {</div><div style="padding:0 1rem;white-space:pre">            grpc_pass grpc://temporal:7233;</div><div style="padding:0 1rem;white-space:pre">        }</div><div style="padding:0 1rem;white-space:pre">    }</div><div style="padding:0 1rem;white-space:pre">}</div></div></div></div></div>
<p>Three files on the host under <code>/etc/temporal/certs/</code>: <code>server.pem</code>, <code>server.key</code>, <code>ca.pem</code>.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-gotchas-that-actually-cost-time">The gotchas that actually cost time<a href="https://dsapanicle.com/blog/deploying-temporal-with-mtls#the-gotchas-that-actually-cost-time" class="hash-link" aria-label="Direct link to The gotchas that actually cost time" title="Direct link to The gotchas that actually cost time" translate="no">​</a></h2>
<p>The configs above are clean in hindsight. Getting there involved a gauntlet:</p>
<p><strong>The Cloudflare orange-cloud trap.</strong> After adding the <code>temporal.kaplabs.dev</code> A record, <code>nc</code> timed out - and the resolved IP was <code>172.67.x.x</code>, a Cloudflare proxy address. The record was <strong>proxied (orange cloud)</strong>, so gRPC traffic was hitting Cloudflare's proxy, which doesn't handle it. Fix: flip it to <strong>grey cloud (DNS only)</strong> so it resolves to the real EC2 IP. (Note the contrast with the UI later, which <em>should</em> be orange.)</p>
<p><strong>The OOM freeze.</strong> First <code>docker compose up</code> on a 1 GB instance exhausted memory, thrashed and froze the entire OS - which took Tailscale SSH down with it. The box looked dead but wasn't; a Stop/Start from the EC2 console recovered it. <strong>This stack wants at least 2 GB</strong> (<code>t3.small</code> floor, <code>t3.medium</code> comfortable). A stop/start also changes the public IP unless you attach an Elastic IP - worth doing to avoid re-chasing DNS.</p>
<p><strong>The scary-but-harmless errors.</strong> During startup, the logs spat <code>context deadline exceeded</code> and <code>shard status unknown</code>. These <em>look</em> like failures but are startup churn - Temporal acquiring its 512 shards while Postgres warms up. If they taper off within a couple minutes, ignore them. If they never stop, the box is still starved.</p>
<p><strong>Docker permissions.</strong> <code>permission denied ... docker.sock</code> - the user wasn't in the <code>docker</code> group. <code>usermod -aG docker $USER</code> fixes it, but <strong>only in a new login session</strong>; the current shell keeps the old groups until you reconnect.</p>
<p><strong>Certs weren't on the box.</strong> nginx crash-looped with <code>cannot load certificate "/certs/server.pem"</code> - the files had been downloaded to a laptop, not copied to the server. Over Tailscale, <code>tailscale file cp</code> moved them across without needing the public IP or any open port. (Also: Infisical hands you the private key as a <code>.txt</code> - the extension is cosmetic; what matters is the <code>-----BEGIN PRIVATE KEY-----</code> inside.)</p>
<p><strong>cloudflared install is two steps, not one.</strong> The dashboard shows three commands. Step 1 installs the binary; step 2 registers the service with a token. I ran step 2 without step 1, so no service got created (<code>Unit cloudflared.service could not be found</code>). They're sequential, not alternatives.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="dns-and-the-firewall-one-port">DNS and the firewall: one port<a href="https://dsapanicle.com/blog/deploying-temporal-with-mtls#dns-and-the-firewall-one-port" class="hash-link" aria-label="Direct link to DNS and the firewall: one port" title="Direct link to DNS and the firewall: one port" translate="no">​</a></h2>
<p>DNS: <code>temporal.kaplabs.dev</code> → EC2 public IP, <strong>grey cloud</strong> (direct, for gRPC).</p>
<p>AWS security group <strong>inbound rules</strong>:</p>
<table><thead><tr><th>Type</th><th>Port</th><th>Source</th><th>Why</th></tr></thead><tbody><tr><td>Custom TCP</td><td>7233</td><td>0.0.0.0/0</td><td>Workers (mTLS-gated)</td></tr></tbody></table>
<p>That's the entire inbound surface. <code>0.0.0.0/0</code> is acceptable <em>because of mTLS</em> - an open port just means "anyone can attempt a handshake and fail." No SSH rule (Tailscale SSH is outbound), no 8080 rule (the UI goes out through a tunnel), no 5432 (Postgres is internal). Keep <strong>outbound</strong> allow-all, since Tailscale, cloudflared and image pulls all dial out.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="auto-renewal-the-infisical-agent">Auto-renewal: the Infisical agent<a href="https://dsapanicle.com/blog/deploying-temporal-with-mtls#auto-renewal-the-infisical-agent" class="hash-link" aria-label="Direct link to Auto-renewal: the Infisical agent" title="Direct link to Auto-renewal: the Infisical agent" translate="no">​</a></h2>
<p>The whole reason for choosing Infisical. A small daemon runs on the VM, watches the server cert and ~30 days before the 90-day cert expires, reissues a fresh one, writes it to disk and <strong>gracefully reloads nginx</strong> via a post-hook. You never touch a cert again.</p>
<p>Key design decision: the agent manages <strong>only</strong> <code>server.pem</code> and <code>server.key</code> (the rotating pieces). It leaves <code>ca.pem</code> alone, because the Root CA doesn't rotate (10-year validity) - which also sidesteps any chain-format guesswork.</p>
<p><code>agent.yaml</code> (the parts that matter):</p>
<div style="background-color:#0d1117;border:1px solid #30363d;border-radius:8px;overflow:hidden;margin-bottom:1rem;font-size:0.875rem;font-family:'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;line-height:1.6"><div style="position:relative"><div style="position:absolute;top:0.5rem;right:0.5rem;z-index:2"><button title="Copy to clipboard" style="background:none;border:1px solid #30363d;border-radius:4px;padding:0.2rem 0.5rem;cursor:pointer;font-size:0.7rem;color:#8b949e;font-family:system-ui, sans-serif;transition:all 0.15s;white-space:nowrap">Copy</button></div><div style="overflow-x:auto;padding:0.75rem 0"><div style="padding:0.75rem 1rem;color:#484f58;font-family:system-ui;font-size:0.8rem"><div style="padding:0 1rem;white-space:pre">version: v1</div><div style="padding:0 1rem;white-space:pre">infisical:</div><div style="padding:0 1rem;white-space:pre">  address: "https://app.infisical.com"</div><div style="padding:0 1rem;white-space:pre">auth:</div><div style="padding:0 1rem;white-space:pre">  type: "universal-auth"</div><div style="padding:0 1rem;white-space:pre">  config:</div><div style="padding:0 1rem;white-space:pre">    client-id: "REPLACE"</div><div style="padding:0 1rem;white-space:pre">    client-secret: "REPLACE"</div><div style="padding:0 1rem;white-space:pre">certificates:</div><div style="padding:0 1rem;white-space:pre">  - profile-name: "temporal-mtls"</div><div style="padding:0 1rem;white-space:pre">    project-slug: "REPLACE"</div><div style="padding:0 1rem;white-space:pre">    attributes:</div><div style="padding:0 1rem;white-space:pre">      common-name: "temporal.kaplabs.dev"</div><div style="padding:0 1rem;white-space:pre">      alt-names: ["temporal.kaplabs.dev", "temporal-frontend", "localhost", "127.0.0.1"]</div><div style="padding:0 1rem;white-space:pre">      key-algorithm: "RSA_2048"</div><div style="padding:0 1rem;white-space:pre">      signature-algorithm: "RSA-SHA256"</div><div style="padding:0 1rem;white-space:pre">      key-usages: ["digital_signature", "key_encipherment"]</div><div style="padding:0 1rem;white-space:pre">      extended-key-usages: ["server_auth", "client_auth"]</div><div style="padding:0 1rem;white-space:pre">      ttl: "90d"</div><div style="padding:0 1rem;white-space:pre">    lifecycle:</div><div style="padding:0 1rem;white-space:pre">      renew-before-expiry: "30d"</div><div style="padding:0 1rem;white-space:pre">      status-check-interval: "6h"</div><div style="padding:0 1rem;white-space:pre">    file-output:</div><div style="padding:0 1rem;white-space:pre">      private-key: { path: "/etc/temporal/certs/server.key", permission: "0600" }</div><div style="padding:0 1rem;white-space:pre">      certificate: { path: "/etc/temporal/certs/server.pem", permission: "0644" }</div><div style="padding:0 1rem;white-space:pre">    post-hooks:</div><div style="padding:0 1rem;white-space:pre">      on-issuance: { command: "docker exec temporal-onprem-stack-nginx-1 nginx -s reload", timeout: 30 }</div><div style="padding:0 1rem;white-space:pre">      on-renewal:  { command: "docker exec temporal-onprem-stack-nginx-1 nginx -s reload", timeout: 30 }</div></div></div></div></div>
<p>Run it as a systemd service (<code>infisical cert-manager agent --config /etc/infisical/agent.yaml</code>) so it survives reboots. On EC2 you can swap Universal Auth for <code>aws-iam</code> and hold no secret at all - the agent authenticates via the instance's IAM role.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-ui-cloudflare-tunnel--access-the-right-use">The UI: Cloudflare Tunnel + Access (the <em>right</em> use)<a href="https://dsapanicle.com/blog/deploying-temporal-with-mtls#the-ui-cloudflare-tunnel--access-the-right-use" class="hash-link" aria-label="Direct link to the-ui-cloudflare-tunnel--access-the-right-use" title="Direct link to the-ui-cloudflare-tunnel--access-the-right-use" translate="no">​</a></h2>
<p>For the dashboard, the tunnel finally earns its keep - because it's a browser/HTTP app, exactly what Access login is designed for.</p>
<p>Modern Cloudflare steers you to <strong>remotely-managed tunnels</strong>: create the tunnel at <code>one.dash.cloudflare.com → Networks → Tunnels</code> and run a single <code>cloudflared service install &lt;token&gt;</code> on the box. The connection is <strong>outbound only</strong> - which is why 8080 never needs a firewall rule.</p>
<p>Then:</p>
<ul>
<li class=""><strong>Public Hostname:</strong> <code>temporal-ui.kaplabs.dev</code> → <code>HTTP</code> → <code>localhost:8080</code>. This DNS record is <strong>proxied (orange cloud)</strong> - correct here, the opposite of the gRPC record.</li>
<li class=""><strong>Access application:</strong> self-hosted, hostname <code>temporal-ui.kaplabs.dev</code>, with an Allow policy (your email, or a domain). Built-in one-time-PIN works with zero setup. <strong>Create the Access policy immediately</strong>, or the URL is briefly open to the world.</li>
</ul>
<p>Two gotchas here mirror earlier ones: <code>Error 1033</code> means the tunnel is routed but no connector is running (<code>Active replicas: 0</code>); <code>502 Bad Gateway</code> means the connector is up but can't reach the service URL - almost always <code>https://</code> instead of <code>http://</code>, or a wrong port. And you do <strong>not</strong> open 8080 in AWS: cloudflared reaches the UI over <code>localhost</code>, which never touches the firewall.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-final-architecture">The final architecture<a href="https://dsapanicle.com/blog/deploying-temporal-with-mtls#the-final-architecture" class="hash-link" aria-label="Direct link to The final architecture" title="Direct link to The final architecture" translate="no">​</a></h2>
<div style="background-color:#0d1117;border:1px solid #30363d;border-radius:8px;overflow:hidden;margin-bottom:1rem;font-size:0.875rem;font-family:'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;line-height:1.6"><div style="position:relative"><div style="position:absolute;top:0.5rem;right:0.5rem;z-index:2"><button title="Copy to clipboard" style="background:none;border:1px solid #30363d;border-radius:4px;padding:0.2rem 0.5rem;cursor:pointer;font-size:0.7rem;color:#8b949e;font-family:system-ui, sans-serif;transition:all 0.15s;white-space:nowrap">Copy</button></div><div style="overflow-x:auto;padding:0.75rem 0"><div style="padding:0.75rem 1rem;color:#484f58;font-family:system-ui;font-size:0.8rem"><div style="padding:0 1rem;white-space:pre">Workers (anywhere)                Browser</div><div style="padding:0 1rem;white-space:pre">        |                             |</div><div style="padding:0 1rem;white-space:pre">        | mTLS / gRPC                 | SSO login</div><div style="padding:0 1rem;white-space:pre">        v                             v</div><div style="padding:0 1rem;white-space:pre"> ┌───────────────────────┐     Cloudflare Access</div><div style="padding:0 1rem;white-space:pre"> │ EC2 instance          │            |</div><div style="padding:0 1rem;white-space:pre"> │                       │            : tunnel (outbound)</div><div style="padding:0 1rem;white-space:pre"> │  nginx :7233  &lt;───────┼────────────┘ ... cloudflared</div><div style="padding:0 1rem;white-space:pre"> │   verifies client cert│                     |</div><div style="padding:0 1rem;white-space:pre"> │        |              │                     | localhost</div><div style="padding:0 1rem;white-space:pre"> │        | plain gRPC   │                     v</div><div style="padding:0 1rem;white-space:pre"> │        v              │              temporal-ui :8080</div><div style="padding:0 1rem;white-space:pre"> │  temporal server  &lt;───┼─────────────────────┘ plain gRPC</div><div style="padding:0 1rem;white-space:pre"> │   plaintext, internal │</div><div style="padding:0 1rem;white-space:pre"> │        |              │</div><div style="padding:0 1rem;white-space:pre"> │        v              │</div><div style="padding:0 1rem;white-space:pre"> │     postgres          │</div><div style="padding:0 1rem;white-space:pre"> │                       │</div><div style="padding:0 1rem;white-space:pre"> │  Infisical agent ──── renews cert, reloads ──&gt; nginx</div><div style="padding:0 1rem;white-space:pre"> └───────────────────────┘</div></div></div></div></div>
<p>The result:</p>
<ul>
<li class="">A Temporal cluster on a single EC2 box.</li>
<li class="">Workers authenticating over <strong>real mutual TLS</strong> from anywhere, no mesh membership required.</li>
<li class="">Certificates that <strong>rotate themselves</strong> and reload nginx without intervention.</li>
<li class="">A dashboard behind <strong>SSO</strong>, exposed with no inbound port.</li>
<li class="">Exactly <strong>one</strong> inbound port open to the internet, gated by client certs. Everything else is internal or outbound-only.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="takeaways">Takeaways<a href="https://dsapanicle.com/blog/deploying-temporal-with-mtls#takeaways" class="hash-link" aria-label="Direct link to Takeaways" title="Direct link to Takeaways" translate="no">​</a></h2>
<ul>
<li class=""><strong>Learn what "Frontend" means in Temporal</strong> before anything else. There's only one door to secure.</li>
<li class=""><strong>Cloudflare Tunnel + Access is for browser apps, not gRPC APIs.</strong> Don't fight the protocol.</li>
<li class=""><strong>A cert is a file; network membership is a relationship.</strong> For external workers, mTLS is the cleaner interface - but the cost is running a CA and the cost of a CA is rotation. Automate it from day one.</li>
<li class=""><strong>Terminate mTLS in nginx</strong>, keep Temporal vanilla. The all-in-one image + <code>requireClientAuth</code> is a headache you can skip.</li>
<li class=""><strong>Grey-cloud gRPC records, orange-cloud browser records.</strong> Same domain, opposite settings.</li>
<li class=""><strong>Give it 2 GB.</strong> 1 GB freezes the whole box on first boot.</li>
<li class="">Most of the "scary" errors were startup churn or a misplaced file - not real failures. Read the logs before assuming the worst.</li>
</ul>]]></content:encoded>
            <category>temporal</category>
            <category>mtls</category>
            <category>infrastructure</category>
            <category>security</category>
            <category>devops</category>
        </item>
        <item>
            <title><![CDATA[Dispatch 001: the hub is open]]></title>
            <link>https://dsapanicle.com/blog/welcome</link>
            <guid>https://dsapanicle.com/blog/welcome</guid>
            <pubDate>Sat, 01 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[DSA Panicle is moving from a single DSA site to a structured knowledge hub: the]]></description>
            <content:encoded><![CDATA[<p>DSA Panicle is moving from a single DSA site to a structured knowledge hub: the
DSA content now lives under <code>/dsa</code>, and the root domain is a landing page for
every subject we're building - practice, fundamentals and certifications, filed
onto one shared shelf layout.</p>
<p>This is the first post on the new <code>/blog</code>, where we'll write up what we build
and why - starting with the redesign itself and the traced-playground engine
underneath it.</p>]]></content:encoded>
        </item>
    </channel>
</rss>