<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Ansible on laslopaul</title><link>https://laslopaul.dev/tags/ansible/</link><description>Recent content in Ansible on laslopaul</description><generator>Hugo -- gohugo.io</generator><language>en-us</language><lastBuildDate>Sat, 09 May 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://laslopaul.dev/tags/ansible/index.xml" rel="self" type="application/rss+xml"/><item><title>Managing self-signed TLS for Docker Compose with step-ca</title><link>https://laslopaul.dev/managing-self-signed-tls-for-docker-compose-with-step-ca/</link><pubDate>Sat, 09 May 2026 00:00:00 +0000</pubDate><guid>https://laslopaul.dev/managing-self-signed-tls-for-docker-compose-with-step-ca/</guid><description>&lt;p&gt;Almost a year ago, I set up a tiny homelab on an Intel NUC running Arch Linux. The original goal was fairly modest: run a few self-hosted services for personal use — Plex, Nextcloud, qBittorrent and Bitwarden.&lt;/p&gt;
&lt;p&gt;I deliberately avoided Kubernetes. Even though k3s is perfectly capable of running on low-end hardware, I did not want to introduce another layer of complexity into a setup that was supposed to remain small and maintainable. Docker Compose felt more than enough for a single-node environment.&lt;/p&gt;</description><content>&lt;p&gt;Almost a year ago, I set up a tiny homelab on an Intel NUC running Arch Linux. The original goal was fairly modest: run a few self-hosted services for personal use — Plex, Nextcloud, qBittorrent and Bitwarden.&lt;/p&gt;
&lt;p&gt;I deliberately avoided Kubernetes. Even though k3s is perfectly capable of running on low-end hardware, I did not want to introduce another layer of complexity into a setup that was supposed to remain small and maintainable. Docker Compose felt more than enough for a single-node environment.&lt;/p&gt;
&lt;p&gt;The stack eventually evolved into a collection of Compose services connected through a shared Traefik network. For remote access, I used ZeroTier and exposed services under a &lt;code&gt;.lan&lt;/code&gt; domain with self-signed TLS.&lt;/p&gt;
&lt;p&gt;That worked reasonably well until certificate management became annoying.&lt;/p&gt;
&lt;h2 id="the-problem"&gt;The problem&lt;/h2&gt;
&lt;p&gt;In Kubernetes, this problem is mostly solved already. You deploy cert-manager, configure an issuer and certificates are rotated automatically.&lt;/p&gt;
&lt;p&gt;Docker Compose does not really have an equivalent ecosystem around certificate automation. Traefik can integrate with ACME providers, but that is mainly useful for public domains. For private &lt;code&gt;.lan&lt;/code&gt; domains and internal-only services, you still need your own CA.&lt;/p&gt;
&lt;p&gt;At first, I tried generating certificates with Ansible using the &lt;code&gt;community.crypto&lt;/code&gt; collection:&lt;/p&gt;
&lt;p&gt;The approach itself was fine, and the Ansible &lt;a href="https://docs.ansible.com/projects/ansible/latest/collections/community/crypto/docsite/guide_selfsigned.html"&gt;documentation&lt;/a&gt; even includes a guide for self-signed PKI setups.&lt;/p&gt;
&lt;p&gt;The problem was lifecycle management.&lt;/p&gt;
&lt;p&gt;Generating a CA and leaf certificates is easy. Renewing them automatically later is a different story.&lt;/p&gt;
&lt;p&gt;I wanted something closer to an actual internal PKI:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;a dedicated certificate authority&lt;/li&gt;
&lt;li&gt;short-lived certificates&lt;/li&gt;
&lt;li&gt;automatic renewal&lt;/li&gt;
&lt;li&gt;compatibility with Traefik&lt;/li&gt;
&lt;li&gt;no Kubernetes dependency&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That eventually led me to &lt;a href="https://smallstep.com/docs/step-ca/"&gt;step-ca&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id="why-step-ca"&gt;Why step-ca&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;step-ca&lt;/code&gt; is a lightweight certificate authority designed for internal infrastructure. It supports:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;ACME&lt;/li&gt;
&lt;li&gt;automated certificate renewal&lt;/li&gt;
&lt;li&gt;internal PKI management&lt;/li&gt;
&lt;li&gt;proper certificate lifecycles&lt;/li&gt;
&lt;li&gt;lightweight deployment&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Most importantly, it works perfectly fine outside Kubernetes.&lt;/p&gt;
&lt;p&gt;Instead of reinventing certificate rotation with Ansible, I could simply let step-ca behave like a real internal CA and issue certificates dynamically.&lt;/p&gt;
&lt;p&gt;I also wanted to preserve my existing CA certificate rather than rebuilding trust from scratch across all devices in my network.&lt;/p&gt;
&lt;h2 id="architecture"&gt;Architecture&lt;/h2&gt;
&lt;p&gt;The final setup ended up looking roughly like this:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://laslopaul.dev/images/step-ca.png" alt="Homelab architecture with Traefik and step-ca"&gt;&lt;/p&gt;
&lt;p&gt;Traefik acts as the ingress proxy for all internal services. &lt;code&gt;step-ca&lt;/code&gt; issues and renews TLS certificates for Traefik automatically.&lt;/p&gt;
&lt;p&gt;All services remain attached to the same Docker network.&lt;/p&gt;
&lt;h2 id="step-ca-deployment"&gt;step-ca deployment&lt;/h2&gt;
&lt;p&gt;I deployed &lt;code&gt;step-ca&lt;/code&gt; as another Docker Compose service managed through Ansible. But before writing actual Ansible tasks, I needed to initialize &lt;code&gt;step-ca&lt;/code&gt; with the existing PKI.&lt;/p&gt;
&lt;p&gt;The official &lt;a href="https://hub.docker.com/r/smallstep/step-ca"&gt;smallstep/step-ca&lt;/a&gt; Docker image supports importing an existing root CA during the initial bootstrap process. To do this, the following files need to be mounted into the container:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;/run/secrets/root_ca.crt
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;/run/secrets/root_ca_key
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;/run/secrets/root_ca_key_password
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;If these files are present, &lt;code&gt;step-ca&lt;/code&gt; imports the existing CA automatically during its first initialization. One important detail is that these files are only used once during the first init.&lt;/p&gt;
&lt;p&gt;The initial bootstrap process itself is fairly straightforward:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;docker run -it -v step:/home/step &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -p 9000:9000 &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -e &lt;span class="s2"&gt;&amp;#34;DOCKER_STEPCA_INIT_NAME=Smallstep&amp;#34;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -e &lt;span class="s2"&gt;&amp;#34;DOCKER_STEPCA_INIT_DNS_NAMES=localhost,&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;hostname -f&lt;span class="k"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -e &lt;span class="s2"&gt;&amp;#34;DOCKER_STEPCA_INIT_REMOTE_MANAGEMENT=true&amp;#34;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; smallstep/step-ca
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;During initialization, &lt;code&gt;step-ca&lt;/code&gt; outputs several important values:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the CA fingerprint (SHA256)&lt;/li&gt;
&lt;li&gt;the remote management super admin username&lt;/li&gt;
&lt;li&gt;the remote management password&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The CA fingerprint is especially important because it is later used by clients during &lt;code&gt;step ca bootstrap&lt;/code&gt; trust establishment.&lt;/p&gt;
&lt;p&gt;Once you&amp;rsquo;ve noted the values, you can stop this container and proceed with adding the Ansible configuration.&lt;/p&gt;
&lt;p&gt;The Ansible manifest used to configure the service is available &lt;a href="https://github.com/laslopaul/nuc-arch-mediacenter/blob/master/roles/docker/tasks/step-ca.yml"&gt;on my GitHub&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id="integrating-with-traefik"&gt;Integrating with Traefik&lt;/h2&gt;
&lt;p&gt;Traefik was already acting as the reverse proxy for the homelab, so the next step was configuring it to request certificates from step-ca.&lt;/p&gt;
&lt;p&gt;The Traefik configuration is available &lt;a href="https://github.com/laslopaul/nuc-arch-mediacenter/blob/master/roles/docker/tasks/traefik.yml"&gt;on my GitHub&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Instead of using Let&amp;rsquo;s Encrypt, Traefik points to the internal ACME endpoint exposed by step-ca.&lt;/p&gt;
&lt;p&gt;This gives me:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;valid TLS inside the homelab&lt;/li&gt;
&lt;li&gt;automatic renewal&lt;/li&gt;
&lt;li&gt;no browser warnings after trusting the CA&lt;/li&gt;
&lt;li&gt;fully internal infrastructure&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Another thing I appreciated about &lt;code&gt;step-ca&lt;/code&gt; was how easy it made distributing the internal CA certificate to client devices. Instead of manually importing certificates into every system trust store, the step CLI can bootstrap trust automatically:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;step ca bootstrap &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --ca-url https://step-ca.lan:9000 &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --fingerprint &amp;lt;ca_fingerprint&amp;gt; &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --install
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;On Linux systems, this is often enough to make browsers, curl and other tooling trust the internal PKI immediately. For a homelab setup with multiple laptops and devices connected through ZeroTier, this turned out to be significantly cleaner than manually managing self-signed certificates everywhere.&lt;/p&gt;
&lt;p&gt;Since everything operates over ZeroTier, services remain accessible remotely without exposing anything publicly.&lt;/p&gt;
&lt;h2 id="certificate-renewal"&gt;Certificate renewal&lt;/h2&gt;
&lt;p&gt;The part I originally struggled with when using raw Ansible-generated certificates was renewal.&lt;/p&gt;
&lt;p&gt;With &lt;code&gt;step-ca&lt;/code&gt;, this becomes significantly cleaner.&lt;/p&gt;
&lt;p&gt;I ended up adding a small &lt;a href="https://github.com/laslopaul/nuc-arch-mediacenter/blob/master/roles/docker/templates/traefik-cert-renewer.service.j2"&gt;systemd service&lt;/a&gt; responsible for renewing Traefik certificates periodically.&lt;/p&gt;
&lt;p&gt;This keeps certificates short-lived without requiring manual intervention.&lt;/p&gt;
&lt;h2 id="final-thoughts"&gt;Final thoughts&lt;/h2&gt;
&lt;p&gt;In retrospect, &lt;code&gt;step-ca&lt;/code&gt; solved exactly the problem I had:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;internal TLS&lt;/li&gt;
&lt;li&gt;automated renewals&lt;/li&gt;
&lt;li&gt;no Kubernetes&lt;/li&gt;
&lt;li&gt;minimal operational overhead&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For small homelab environments running Docker Compose, it fills a gap between “completely manual self-signed certificates” and “full Kubernetes cert-manager ecosystem”.&lt;/p&gt;
&lt;p&gt;I still think Docker Compose is the right tradeoff for tiny single-node setups. Kubernetes brings excellent tooling around PKI and ingress management, but for a single Intel NUC running a handful of services, the operational cost rarely feels justified.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;step-ca&lt;/code&gt; gave me most of the certificate management benefits without requiring the rest of the Kubernetes stack.&lt;/p&gt;</content></item></channel></rss>