<?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>Forem: Danylo Mikula</title>
    <description>The latest articles on Forem by Danylo Mikula (@mikula).</description>
    <link>https://forem.com/mikula</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3294336%2F07bb6c0a-7ba7-4729-89ff-974bd83859e3.jpg</url>
      <title>Forem: Danylo Mikula</title>
      <link>https://forem.com/mikula</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/mikula"/>
    <language>en</language>
    <item>
      <title>Building My Personal Website: From Idea to Automated Deployment (Part 2)</title>
      <dc:creator>Danylo Mikula</dc:creator>
      <pubDate>Thu, 04 Dec 2025 15:50:39 +0000</pubDate>
      <link>https://forem.com/mikula/building-my-personal-website-from-idea-to-automated-deployment-part-2-21g4</link>
      <guid>https://forem.com/mikula/building-my-personal-website-from-idea-to-automated-deployment-part-2-21g4</guid>
      <description>&lt;p&gt;In the &lt;a href="https://dev.to/mikula/building-my-personal-website-from-idea-to-automated-deployment-part-1-427p"&gt;first part&lt;/a&gt; of this series, I covered the high-level architecture and the tools I chose for building my personal website. Now let's dive deeper into the technical implementation, starting with the Terraform modules.&lt;/p&gt;

&lt;h2&gt;
  
  
  Infrastructure Overview
&lt;/h2&gt;

&lt;p&gt;To deploy the minimal infrastructure with everything needed on Hetzner Cloud, we need to configure the following components:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Network&lt;/strong&gt; — private network for internal communication&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Firewall&lt;/strong&gt; — security rules to restrict traffic&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SSH Key&lt;/strong&gt; — authentication for server access&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server&lt;/strong&gt; — the actual compute instance&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I created Terraform modules for each of these components. Let's go through them one by one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Network Module
&lt;/h2&gt;

&lt;p&gt;The first piece of infrastructure we need is a private network. For this, I created the &lt;a href="https://github.com/danylomikula/terraform-hcloud-network" rel="noopener noreferrer"&gt;terraform-hcloud-network&lt;/a&gt; module.&lt;/p&gt;

&lt;p&gt;This module provides comprehensive network management for Hetzner Cloud:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Optional creation of a new network or reuse of an existing one&lt;/li&gt;
&lt;li&gt;Support for multiple subnets across different network zones and types (&lt;code&gt;server&lt;/code&gt;, &lt;code&gt;cloud&lt;/code&gt;, or &lt;code&gt;vswitch&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Optional custom routes for advanced scenarios like VPN gateways&lt;/li&gt;
&lt;li&gt;Consistent outputs for easy integration with other modules&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's my network configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;module&lt;/span&gt; &lt;span class="s2"&gt;"network"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"danylomikula/network/hcloud"&lt;/span&gt;
  &lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"1.0.0"&lt;/span&gt;

  &lt;span class="nx"&gt;create_network&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;project_slug&lt;/span&gt;
  &lt;span class="nx"&gt;ip_range&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"10.100.0.0/16"&lt;/span&gt;

  &lt;span class="nx"&gt;labels&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;common_labels&lt;/span&gt;

  &lt;span class="nx"&gt;subnets&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;web&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;type&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"cloud"&lt;/span&gt;
      &lt;span class="nx"&gt;network_zone&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"eu-central"&lt;/span&gt;
      &lt;span class="nx"&gt;ip_range&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"10.100.1.0/24"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I chose the &lt;code&gt;eu-central&lt;/code&gt; network zone because it offers the best pricing. This configuration creates a network with a &lt;code&gt;/16&lt;/code&gt; CIDR block (&lt;code&gt;10.100.0.0/16&lt;/code&gt;) and a single subnet with a &lt;code&gt;/24&lt;/code&gt; block (&lt;code&gt;10.100.1.0/24&lt;/code&gt;). For a single server, this is more than enough address space.&lt;/p&gt;

&lt;h2&gt;
  
  
  Firewall Module
&lt;/h2&gt;

&lt;p&gt;Next, we need to set up a firewall to restrict external traffic. As I mentioned in the first part, I only allow HTTP/HTTPS traffic from Cloudflare IP addresses and SSH access from my home IP.&lt;/p&gt;

&lt;p&gt;For this, I created the &lt;a href="https://github.com/danylomikula/terraform-hcloud-firewall" rel="noopener noreferrer"&gt;terraform-hcloud-firewall&lt;/a&gt; module. It supports:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Creating multiple firewalls with custom rules&lt;/li&gt;
&lt;li&gt;Both inbound and outbound rules&lt;/li&gt;
&lt;li&gt;Flexible port and IP restrictions&lt;/li&gt;
&lt;li&gt;Common labels across all firewalls&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's my firewall configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;module&lt;/span&gt; &lt;span class="s2"&gt;"firewall"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"danylomikula/firewall/hcloud"&lt;/span&gt;
  &lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"1.0.0"&lt;/span&gt;

  &lt;span class="nx"&gt;firewalls&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="s2"&gt;"${local.resource_names.website}"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;rules&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;direction&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"in"&lt;/span&gt;
          &lt;span class="nx"&gt;protocol&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"tcp"&lt;/span&gt;
          &lt;span class="nx"&gt;port&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"22"&lt;/span&gt;
          &lt;span class="nx"&gt;source_ips&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;my_homelab_ip&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
          &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"allow ssh"&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;direction&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"in"&lt;/span&gt;
          &lt;span class="nx"&gt;protocol&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"tcp"&lt;/span&gt;
          &lt;span class="nx"&gt;port&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"80"&lt;/span&gt;
          &lt;span class="nx"&gt;source_ips&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cloudflare_all_ips&lt;/span&gt;
          &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"allow http from cloudflare"&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;direction&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"in"&lt;/span&gt;
          &lt;span class="nx"&gt;protocol&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"tcp"&lt;/span&gt;
          &lt;span class="nx"&gt;port&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"443"&lt;/span&gt;
          &lt;span class="nx"&gt;source_ips&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cloudflare_all_ips&lt;/span&gt;
          &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"allow https from cloudflare"&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;direction&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"in"&lt;/span&gt;
          &lt;span class="nx"&gt;protocol&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"icmp"&lt;/span&gt;
          &lt;span class="nx"&gt;source_ips&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"0.0.0.0/0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"::/0"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
          &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"allow ping"&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;]&lt;/span&gt;
      &lt;span class="nx"&gt;labels&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;service&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"firewall"&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;common_labels&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;common_labels&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Dynamic Cloudflare IP Fetching
&lt;/h3&gt;

&lt;p&gt;Cloudflare publishes their IP ranges publicly, so I fetch them dynamically using Terraform's &lt;code&gt;http&lt;/code&gt; data source:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="s2"&gt;"http"&lt;/span&gt; &lt;span class="s2"&gt;"cloudflare_ips_v4"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://www.cloudflare.com/ips-v4"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="s2"&gt;"http"&lt;/span&gt; &lt;span class="s2"&gt;"cloudflare_ips_v6"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://www.cloudflare.com/ips-v6"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;locals&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;cloudflare_ipv4_cidrs&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;n"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;trimspace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cloudflare_ips_v4&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;response_body&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="nx"&gt;cloudflare_ipv6_cidrs&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;n"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;trimspace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cloudflare_ips_v6&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;response_body&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="nx"&gt;cloudflare_all_ips&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;concat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cloudflare_ipv4_cidrs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cloudflare_ipv6_cidrs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This approach ensures that whenever Cloudflare updates their IP ranges, a simple &lt;code&gt;terraform apply&lt;/code&gt; will update the firewall rules automatically.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Important:&lt;/strong&gt; For this setup to work, you need to enable the Proxy toggle on your A and AAAA records in Cloudflare DNS settings.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  SSH Key Module
&lt;/h2&gt;

&lt;p&gt;Before creating the server, we need an SSH key for authentication. I created the &lt;a href="https://github.com/danylomikula/terraform-hcloud-ssh-key" rel="noopener noreferrer"&gt;terraform-hcloud-ssh-key&lt;/a&gt; module for this purpose.&lt;/p&gt;

&lt;p&gt;This module is quite flexible and supports:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Automated key generation (ED25519, RSA, or ECDSA)&lt;/li&gt;
&lt;li&gt;Automatic local save of generated keys&lt;/li&gt;
&lt;li&gt;Uploading existing public keys&lt;/li&gt;
&lt;li&gt;Referencing keys already in Hetzner Cloud by ID or name&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's my configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;module&lt;/span&gt; &lt;span class="s2"&gt;"ssh_key"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"danylomikula/ssh-key/hcloud"&lt;/span&gt;
  &lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"1.0.0"&lt;/span&gt;

  &lt;span class="nx"&gt;create_key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;project_slug&lt;/span&gt;

  &lt;span class="nx"&gt;save_private_key_locally&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nx"&gt;local_key_directory&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;module&lt;/span&gt;

  &lt;span class="nx"&gt;labels&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;common_labels&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This generates an ED25519 key pair (the default and recommended algorithm) and saves both the private and public keys locally for easy access.&lt;/p&gt;

&lt;h2&gt;
  
  
  Server Module
&lt;/h2&gt;

&lt;p&gt;Finally, let's create the server itself using the &lt;a href="https://github.com/danylomikula/terraform-hcloud-server" rel="noopener noreferrer"&gt;terraform-hcloud-server&lt;/a&gt; module. Like the others, it's designed to be flexible and supports:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Multi-server management with a single module invocation&lt;/li&gt;
&lt;li&gt;Private network attachments with static IPs&lt;/li&gt;
&lt;li&gt;Firewall integration at creation time&lt;/li&gt;
&lt;li&gt;Placement groups for high availability&lt;/li&gt;
&lt;li&gt;All &lt;code&gt;hcloud_server&lt;/code&gt; resource attributes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's my server configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;module&lt;/span&gt; &lt;span class="s2"&gt;"servers"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"danylomikula/server/hcloud"&lt;/span&gt;
  &lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"1.0.0"&lt;/span&gt;

  &lt;span class="nx"&gt;servers&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="s2"&gt;"${local.resource_names.website}"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;server_type&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"cx23"&lt;/span&gt;
      &lt;span class="nx"&gt;location&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"hel1"&lt;/span&gt;
      &lt;span class="nx"&gt;image&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hcloud_image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rocky&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
      &lt;span class="nx"&gt;user_data&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cloud_init_config&lt;/span&gt;
      &lt;span class="nx"&gt;ssh_keys&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ssh_key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ssh_key_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
      &lt;span class="nx"&gt;firewall_ids&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;firewall&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;firewall_ids&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource_names&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;website&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;
      &lt;span class="nx"&gt;networks&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
        &lt;span class="nx"&gt;network_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;network&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;network_id&lt;/span&gt;
        &lt;span class="nx"&gt;ip&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"10.100.1.10"&lt;/span&gt;
      &lt;span class="p"&gt;}]&lt;/span&gt;
      &lt;span class="nx"&gt;labels&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;service&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"website"&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;common_labels&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;common_labels&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I chose the &lt;code&gt;cx23&lt;/code&gt; server type as it's the cheapest option available and costs me less than $5 per month in the Helsinki (&lt;code&gt;hel1&lt;/code&gt;) region. Its specifications are more than enough for a static website.&lt;/p&gt;

&lt;p&gt;Notice how I'm passing variables from previous modules dynamically — the SSH key ID, firewall ID, and network ID are all referenced from their respective module outputs. This eliminates manual configuration and reduces the chance of errors.&lt;/p&gt;

&lt;h2&gt;
  
  
  Complete Configuration
&lt;/h2&gt;

&lt;p&gt;Here's the full Terraform configuration with all the pieces together:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;locals&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;project_slug&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"mikula-dev"&lt;/span&gt;

  &lt;span class="nx"&gt;common_labels&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;environment&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"production"&lt;/span&gt;
    &lt;span class="nx"&gt;project&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;project_slug&lt;/span&gt;
    &lt;span class="nx"&gt;managed_by&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"terraform"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;resource_names&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;website&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${local.project_slug}-web"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;cloud_init_config&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;templatefile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"${path.module}/cloud-init.tpl"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;ansible_ssh_public_key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ansible_user_ssh_public_key&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;

  &lt;span class="nx"&gt;cloudflare_ipv4_cidrs&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;n"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;trimspace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cloudflare_ips_v4&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;response_body&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="nx"&gt;cloudflare_ipv6_cidrs&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;n"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;trimspace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cloudflare_ips_v6&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;response_body&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="nx"&gt;cloudflare_all_ips&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;concat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cloudflare_ipv4_cidrs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cloudflare_ipv6_cidrs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Fetch Cloudflare IP ranges for firewall rules&lt;/span&gt;
&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="s2"&gt;"http"&lt;/span&gt; &lt;span class="s2"&gt;"cloudflare_ips_v4"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://www.cloudflare.com/ips-v4"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="s2"&gt;"http"&lt;/span&gt; &lt;span class="s2"&gt;"cloudflare_ips_v6"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://www.cloudflare.com/ips-v6"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;module&lt;/span&gt; &lt;span class="s2"&gt;"network"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"danylomikula/network/hcloud"&lt;/span&gt;
  &lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"1.0.0"&lt;/span&gt;

  &lt;span class="nx"&gt;create_network&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;project_slug&lt;/span&gt;
  &lt;span class="nx"&gt;ip_range&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"10.100.0.0/16"&lt;/span&gt;

  &lt;span class="nx"&gt;labels&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;common_labels&lt;/span&gt;

  &lt;span class="nx"&gt;subnets&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;web&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;type&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"cloud"&lt;/span&gt;
      &lt;span class="nx"&gt;network_zone&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"eu-central"&lt;/span&gt;
      &lt;span class="nx"&gt;ip_range&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"10.100.1.0/24"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;module&lt;/span&gt; &lt;span class="s2"&gt;"ssh_key"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"danylomikula/ssh-key/hcloud"&lt;/span&gt;
  &lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"1.0.0"&lt;/span&gt;

  &lt;span class="nx"&gt;create_key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;project_slug&lt;/span&gt;

  &lt;span class="nx"&gt;save_private_key_locally&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nx"&gt;local_key_directory&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;module&lt;/span&gt;

  &lt;span class="nx"&gt;labels&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;common_labels&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;module&lt;/span&gt; &lt;span class="s2"&gt;"firewall"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"danylomikula/firewall/hcloud"&lt;/span&gt;
  &lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"1.0.0"&lt;/span&gt;

  &lt;span class="nx"&gt;firewalls&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="s2"&gt;"${local.resource_names.website}"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;rules&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;direction&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"in"&lt;/span&gt;
          &lt;span class="nx"&gt;protocol&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"tcp"&lt;/span&gt;
          &lt;span class="nx"&gt;port&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"22"&lt;/span&gt;
          &lt;span class="nx"&gt;source_ips&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;my_homelab_ip&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
          &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"allow ssh"&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;direction&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"in"&lt;/span&gt;
          &lt;span class="nx"&gt;protocol&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"tcp"&lt;/span&gt;
          &lt;span class="nx"&gt;port&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"80"&lt;/span&gt;
          &lt;span class="nx"&gt;source_ips&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cloudflare_all_ips&lt;/span&gt;
          &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"allow http from cloudflare"&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;direction&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"in"&lt;/span&gt;
          &lt;span class="nx"&gt;protocol&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"tcp"&lt;/span&gt;
          &lt;span class="nx"&gt;port&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"443"&lt;/span&gt;
          &lt;span class="nx"&gt;source_ips&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cloudflare_all_ips&lt;/span&gt;
          &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"allow https from cloudflare"&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;direction&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"in"&lt;/span&gt;
          &lt;span class="nx"&gt;protocol&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"icmp"&lt;/span&gt;
          &lt;span class="nx"&gt;source_ips&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"0.0.0.0/0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"::/0"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
          &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"allow ping"&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;]&lt;/span&gt;
      &lt;span class="nx"&gt;labels&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;service&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"firewall"&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;common_labels&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;common_labels&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;module&lt;/span&gt; &lt;span class="s2"&gt;"servers"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"danylomikula/server/hcloud"&lt;/span&gt;
  &lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"1.0.0"&lt;/span&gt;

  &lt;span class="nx"&gt;servers&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="s2"&gt;"${local.resource_names.website}"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;server_type&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"cx23"&lt;/span&gt;
      &lt;span class="nx"&gt;location&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"hel1"&lt;/span&gt;
      &lt;span class="nx"&gt;image&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hcloud_image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rocky&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
      &lt;span class="nx"&gt;user_data&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cloud_init_config&lt;/span&gt;
      &lt;span class="nx"&gt;ssh_keys&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ssh_key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ssh_key_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
      &lt;span class="nx"&gt;firewall_ids&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;firewall&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;firewall_ids&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource_names&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;website&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;
      &lt;span class="nx"&gt;networks&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
        &lt;span class="nx"&gt;network_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;network&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;network_id&lt;/span&gt;
        &lt;span class="nx"&gt;ip&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"10.100.1.10"&lt;/span&gt;
      &lt;span class="p"&gt;}]&lt;/span&gt;
      &lt;span class="nx"&gt;labels&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;service&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"website"&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;common_labels&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;common_labels&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With this configuration, running &lt;code&gt;terraform apply&lt;/code&gt; provisions the complete infrastructure in just a few minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Server Bootstrapping with Ansible
&lt;/h2&gt;

&lt;p&gt;Now let's look at bootstrapping the actual website. For this, I'm using an Ansible collection that I also created and published publicly: &lt;a href="https://github.com/danylomikula/ansible-hugo-deploy" rel="noopener noreferrer"&gt;ansible-hugo-deploy&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;For the operating system, I chose Rocky Linux 10. For the web server — Caddy.&lt;/p&gt;

&lt;p&gt;The Ansible collection handles the complete deployment pipeline:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hugo Static Site Deployment&lt;/strong&gt; — automated cloning and building of Hugo websites&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom Caddy Build&lt;/strong&gt; — compiles Caddy with custom plugins from source&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SSL/TLS Automation&lt;/strong&gt; — automatic HTTPS certificates via Let's Encrypt with Cloudflare DNS challenge&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Built-in Rate Limiting&lt;/strong&gt; — protection against bots and abuse&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare Integration&lt;/strong&gt; — DNS-01 ACME challenge support&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub Deploy Key Generation&lt;/strong&gt; — automatic SSH key generation for secure repository access&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automated Updates&lt;/strong&gt; — systemd timer for periodic Git pulls and site rebuilds&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Firewall Configuration&lt;/strong&gt; — automated firewalld setup with sensible defaults&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Version Pinning&lt;/strong&gt; — full control over Hugo, Caddy, and Go versions&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  My Ansible Configuration
&lt;/h3&gt;

&lt;p&gt;Here's my complete configuration:&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="nn"&gt;---&lt;/span&gt;
&lt;span class="c1"&gt;# Domain configuration.&lt;/span&gt;
&lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mikula.dev"&lt;/span&gt;
&lt;span class="na"&gt;admin_email&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;admin@{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;domain&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;

&lt;span class="c1"&gt;# Git repository for website source.&lt;/span&gt;
&lt;span class="na"&gt;website_repo_url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;git@github.com:danylomikula/mikula.dev.git"&lt;/span&gt;
&lt;span class="na"&gt;website_repo_branch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;master"&lt;/span&gt;

&lt;span class="c1"&gt;# Web content paths.&lt;/span&gt;
&lt;span class="na"&gt;website_root&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/var/www/{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;domain&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
&lt;span class="na"&gt;caddy_log_path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/var/log/caddy"&lt;/span&gt;
&lt;span class="na"&gt;website_public_dir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;website_root&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}/public"&lt;/span&gt;

&lt;span class="c1"&gt;# Deploy SSH key configuration.&lt;/span&gt;
&lt;span class="na"&gt;deploy_ssh_key_user&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;caddy"&lt;/span&gt;
&lt;span class="na"&gt;deploy_ssh_key_group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;deploy_ssh_key_user&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
&lt;span class="na"&gt;deploy_ssh_key_dir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/var/lib/{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;deploy_ssh_key_user&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}/.ssh"&lt;/span&gt;
&lt;span class="na"&gt;deploy_ssh_key_path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;deploy_ssh_key_dir&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}/deploy_key"&lt;/span&gt;
&lt;span class="na"&gt;deploy_ssh_key_type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ed25519"&lt;/span&gt;
&lt;span class="na"&gt;deploy_ssh_key_comment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;domain&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}-deploy-key"&lt;/span&gt;

&lt;span class="c1"&gt;# Website rebuild configuration.&lt;/span&gt;
&lt;span class="na"&gt;webrebuild_schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*-*-*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;04:00:00"&lt;/span&gt;
&lt;span class="na"&gt;webrebuild_boot_delay&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;180"&lt;/span&gt;
&lt;span class="na"&gt;webrebuild_service_user&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;caddy"&lt;/span&gt;
&lt;span class="na"&gt;webrebuild_service_group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;caddy"&lt;/span&gt;
&lt;span class="na"&gt;webrebuild_commands&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;git&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;pull&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;origin&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;website_repo_branch&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&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;hugo&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;--gc&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;--minify"&lt;/span&gt;

&lt;span class="na"&gt;hugo_version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.152.2"&lt;/span&gt;

&lt;span class="c1"&gt;# Caddy configuration.&lt;/span&gt;
&lt;span class="na"&gt;caddy_version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2.10.2"&lt;/span&gt;
&lt;span class="na"&gt;caddy_go_version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1.25.4"&lt;/span&gt;
&lt;span class="na"&gt;caddy_modules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;github.com/mholt/caddy-ratelimit&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;github.com/caddy-dns/cloudflare&lt;/span&gt;

&lt;span class="na"&gt;caddy_rate_limit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;events&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;60&lt;/span&gt;
  &lt;span class="na"&gt;window&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1m"&lt;/span&gt;

&lt;span class="na"&gt;caddy_compression_formats&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;gzip&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;zstd&lt;/span&gt;

&lt;span class="c1"&gt;# DNS / ACME configuration.&lt;/span&gt;
&lt;span class="na"&gt;cloudflare_api_token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;vault_cloudflare_api_token&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
&lt;span class="na"&gt;caddy_acme_ca&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://acme-v02.api.letsencrypt.org/directory"&lt;/span&gt;

&lt;span class="c1"&gt;# Firewall configuration.&lt;/span&gt;
&lt;span class="na"&gt;firewall_zone&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;public"&lt;/span&gt;
&lt;span class="na"&gt;firewall_allowed_services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ssh&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;http&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;https&lt;/span&gt;
&lt;span class="na"&gt;firewall_allowed_ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[]&lt;/span&gt;
&lt;span class="na"&gt;firewall_allowed_icmp&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="na"&gt;firewall_allowed_icmp_types&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;echo-request&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Custom Caddy Build with Plugins
&lt;/h3&gt;

&lt;p&gt;Since I'm using Cloudflare with proxy enabled, the standard Caddy build isn't enough for automatic certificate provisioning. I need the &lt;a href="https://github.com/caddy-dns/cloudflare" rel="noopener noreferrer"&gt;caddy-dns/cloudflare&lt;/a&gt; module to pass the DNS-01 ACME challenge for certificate verification.&lt;/p&gt;

&lt;p&gt;Since I'm already building a custom Caddy binary, I decided to add another useful module — &lt;a href="https://github.com/mholt/caddy-ratelimit" rel="noopener noreferrer"&gt;caddy-ratelimit&lt;/a&gt; for rate limiting protection against bots and scanners.&lt;/p&gt;

&lt;p&gt;The configuration for these modules is available in my Ansible playbook. If you don't want to use one of them or want to add additional modules, you can easily customize the &lt;code&gt;caddy_modules&lt;/code&gt; list.&lt;/p&gt;

&lt;h3&gt;
  
  
  Automated Content Updates
&lt;/h3&gt;

&lt;p&gt;We can now deploy the website, but one problem remains: how do we update the content automatically without manually logging into the server? I want to simply push to Git and have the website update itself after some time.&lt;/p&gt;

&lt;p&gt;To solve this, I'm using GitHub deploy keys. These keys are read-only, meaning all they can do is read the content of the Git repository — nothing more.&lt;/p&gt;

&lt;p&gt;The Ansible playbook generates this key automatically, outputs the public part to the console, and waits for your confirmation while you configure your GitHub repository. After confirmation, it clones the content, builds it, and starts the Hugo server.&lt;/p&gt;

&lt;h3&gt;
  
  
  systemd Timer for Periodic Updates
&lt;/h3&gt;

&lt;p&gt;For periodic content updates, I use a simple systemd timer that runs every morning and updates the website with new content.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;webrebuild.service:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Rebuild website from Git repository&lt;/span&gt;
&lt;span class="py"&gt;After&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network-online.target&lt;/span&gt;
&lt;span class="py"&gt;Wants&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network-online.target&lt;/span&gt;

&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;oneshot&lt;/span&gt;
&lt;span class="py"&gt;User&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;{{ webrebuild_service_user }}&lt;/span&gt;
&lt;span class="py"&gt;Group&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;{{ webrebuild_service_group }}&lt;/span&gt;
&lt;span class="py"&gt;WorkingDirectory&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;{{ website_root }}&lt;/span&gt;
&lt;span class="py"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;PATH={{ caddy_webserver_rebuild_path }}&lt;/span&gt;
&lt;span class="err"&gt;{%&lt;/span&gt; &lt;span class="err"&gt;for&lt;/span&gt; &lt;span class="err"&gt;command&lt;/span&gt; &lt;span class="err"&gt;in&lt;/span&gt; &lt;span class="err"&gt;webrebuild_commands&lt;/span&gt; &lt;span class="err"&gt;%}&lt;/span&gt;
&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/bin/env bash -c "{{ command }}"&lt;/span&gt;
&lt;span class="err"&gt;{%&lt;/span&gt; &lt;span class="err"&gt;endfor&lt;/span&gt; &lt;span class="err"&gt;%}&lt;/span&gt;
&lt;span class="py"&gt;StandardOutput&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;journal&lt;/span&gt;
&lt;span class="py"&gt;StandardError&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;journal&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;webrebuild.timer:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Rebuild website daily&lt;/span&gt;
&lt;span class="py"&gt;RefuseManualStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;no&lt;/span&gt;
&lt;span class="py"&gt;RefuseManualStop&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;no&lt;/span&gt;

&lt;span class="nn"&gt;[Timer]&lt;/span&gt;
&lt;span class="c"&gt;# Run {{ webrebuild_boot_delay }} seconds after boot for the first time.
&lt;/span&gt;&lt;span class="py"&gt;OnBootSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;{{ webrebuild_boot_delay }}&lt;/span&gt;
&lt;span class="c"&gt;# Run daily at scheduled time.
&lt;/span&gt;&lt;span class="py"&gt;OnCalendar&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;{{ webrebuild_schedule }}&lt;/span&gt;
&lt;span class="py"&gt;Unit&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;webrebuild.service&lt;/span&gt;

&lt;span class="nn"&gt;[Install]&lt;/span&gt;
&lt;span class="py"&gt;WantedBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;timers.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With this setup, every morning at 4:00 AM the timer triggers, pulls the latest changes from the repository, and rebuilds the site with Hugo. If I need an immediate update, I can always trigger it manually with &lt;code&gt;sudo systemctl start webrebuild.service&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Caddyfile Configuration
&lt;/h3&gt;

&lt;p&gt;The Caddy server is configured using a config file called &lt;code&gt;Caddyfile&lt;/code&gt;. Here's the complete template:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Caddyfile for {{ domain }}
# Managed by Ansible - do not edit manually.

{% if (cloudflare_api_token | length &amp;gt; 0) or (caddy_acme_ca | length &amp;gt; 0) %}
{
{% if caddy_acme_ca | length &amp;gt; 0 %}
    acme_ca {{ caddy_acme_ca }}
{% endif %}
{% if cloudflare_api_token | length &amp;gt; 0 %}
    acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
{% endif %}
}
{% endif %}

www.{{ domain }} {
    # Redirect www to non-www domain.
    redir https://{{ domain }}{uri} permanent
}

{{ domain }} {
    # Root directory for static files.
    root * {{ website_public_dir }}

    # Enable static file server.
    file_server

{% if caddy_rate_limit.enabled | default(false) %}
    # Basic rate limiting per client IP to slow down bots/scanners.
    rate_limit {
        zone per_client {
            key {remote_ip}
            events {{ caddy_rate_limit.events }}
            window {{ caddy_rate_limit.window }}
        }
    }
{% endif %}

    # Enable compression.
    encode {% for format in caddy_compression_formats %}{{ format }} {% endfor %}

    # TLS configuration with admin email.
    tls {{ admin_email }}

    # Access logging.
    log {
        output file {{ caddy_log_path }}/access.log {
            roll_size 100MiB
            roll_local_time
            roll_keep_for 15d
        }
    }

    # Security headers.
    header {
        Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
        X-Content-Type-Options "nosniff"
        Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://static.cloudflareinsights.com; font-src 'self' data:; frame-ancestors 'none'; object-src 'none'; base-uri 'self'; form-action 'self'; connect-src 'self' https://www.google-analytics.com https://www.googletagmanager.com https://static.cloudflareinsights.com https://cloudflareinsights.com"
        Referrer-Policy "strict-origin-when-cross-origin"
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This configuration includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;www to non-www redirect&lt;/strong&gt; — all traffic to &lt;code&gt;www.mikula.dev&lt;/code&gt; is permanently redirected to &lt;code&gt;mikula.dev&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Static file serving&lt;/strong&gt; — serves files from the Hugo build output directory&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limiting&lt;/strong&gt; — limits requests per client IP to protect against abuse&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compression&lt;/strong&gt; — gzip and zstd compression for better performance&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automatic TLS&lt;/strong&gt; — certificates via Let's Encrypt with Cloudflare DNS challenge&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Access logging&lt;/strong&gt; — with automatic log rotation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security headers&lt;/strong&gt; — HSTS, CSP, and other security-related headers&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;That's it! With this setup, I can deploy a fully functional, secure, and automated website infrastructure in about 15 minutes. The entire workflow is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Run &lt;code&gt;terraform apply&lt;/code&gt; to provision the infrastructure&lt;/li&gt;
&lt;li&gt;Push content to the repository&lt;/li&gt;
&lt;li&gt;Run the Ansible playbook to configure the server&lt;/li&gt;
&lt;li&gt;Add the deploy key to GitHub&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;From that point on, the website updates itself automatically every day.&lt;/p&gt;

&lt;p&gt;I hope this guide helps you set up your own website even faster than I did. Feel free to use my ready-made configurations as a starting point.&lt;/p&gt;

&lt;p&gt;All the code is open source:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Terraform Modules:&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/danylomikula/terraform-hcloud-network" rel="noopener noreferrer"&gt;terraform-hcloud-network&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/danylomikula/terraform-hcloud-firewall" rel="noopener noreferrer"&gt;terraform-hcloud-firewall&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/danylomikula/terraform-hcloud-ssh-key" rel="noopener noreferrer"&gt;terraform-hcloud-ssh-key&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/danylomikula/terraform-hcloud-server" rel="noopener noreferrer"&gt;terraform-hcloud-server&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;

&lt;strong&gt;Ansible Collection:&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/danylomikula/ansible-hugo-deploy" rel="noopener noreferrer"&gt;ansible-hugo-deploy&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Have questions or suggestions? Feel free to reach out or open an issue on GitHub.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>cloud</category>
      <category>automation</category>
      <category>terraform</category>
    </item>
    <item>
      <title>Building My Personal Website: From Idea to Automated Deployment (Part 1)</title>
      <dc:creator>Danylo Mikula</dc:creator>
      <pubDate>Fri, 28 Nov 2025 21:58:57 +0000</pubDate>
      <link>https://forem.com/mikula/building-my-personal-website-from-idea-to-automated-deployment-part-1-427p</link>
      <guid>https://forem.com/mikula/building-my-personal-website-from-idea-to-automated-deployment-part-1-427p</guid>
      <description>&lt;p&gt;The idea of creating my own personal website—a place where I could share projects I'm working on and document my technical journey—has been on my mind for a long time. But as with many personal projects, it kept getting pushed aside. Finally, I found the time, and here it is: &lt;a href="https://mikula.dev" rel="noopener noreferrer"&gt;mikula.dev&lt;/a&gt;. In this post, I want to share how I built it, what tools I chose, and why.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing the Right Static Site Generator
&lt;/h2&gt;

&lt;p&gt;When looking for a site generator, I had a few requirements in mind: it needed to be simple yet flexible, fast, and shouldn't require hours of configuration just to get started. After evaluating several options, I settled on &lt;a href="https://gohugo.io/" rel="noopener noreferrer"&gt;Hugo&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Hugo is one of the fastest static site generators out there. Written in Go, it can build thousands of pages in seconds. But speed isn't the only advantage—it generates pure static HTML files, which makes hosting incredibly straightforward. No databases, no server-side processing, no complex runtime dependencies. Just files that can be served by any web server.&lt;/p&gt;

&lt;p&gt;The fact that Hugo outputs static files also brings security benefits—there's simply no dynamic attack surface. Combined with its extensive templating capabilities and active community, it was an easy choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding the Perfect Theme
&lt;/h2&gt;

&lt;p&gt;I didn't want to spend weeks developing my own theme from scratch. Instead, I looked for something that matched my aesthetic preferences and could be customized easily. I found &lt;a href="https://github.com/panr/hugo-theme-terminal" rel="noopener noreferrer"&gt;Terminal&lt;/a&gt; by Radek Kozieł, and it was exactly what I was looking for.&lt;/p&gt;

&lt;p&gt;The theme has a clean, retro terminal-inspired look with beautiful syntax highlighting powered by Chroma. It uses &lt;a href="https://github.com/tonsky/FiraCode" rel="noopener noreferrer"&gt;Fira Code&lt;/a&gt; as the default monospace font, is fully responsive, and supports customizable color schemes. While it covered most of my needs out of the box, I did extend it with some additional functionality—like better post organization and a dedicated resume page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to Host?
&lt;/h2&gt;

&lt;p&gt;Since Hugo generates static files, I had several hosting options to consider: GitHub Pages, AWS S3 with CloudFront, or a small cloud server. Each has its merits, but I went with a dedicated server on &lt;a href="https://www.hetzner.com/cloud" rel="noopener noreferrer"&gt;Hetzner Cloud&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Why? Flexibility. While GitHub Pages and S3 are excellent for simple static hosting, having my own server gives me complete control over the infrastructure. I can configure custom caching rules, set up rate limiting, add custom header and run additional services if needed. Plus, Hetzner offers excellent performance at very competitive prices.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caddy: The Modern Web Server
&lt;/h2&gt;

&lt;p&gt;For the web server, I evaluated a few options—nginx, Apache, and Caddy. I chose &lt;a href="https://caddyserver.com/" rel="noopener noreferrer"&gt;Caddy&lt;/a&gt; for several compelling reasons.&lt;/p&gt;

&lt;p&gt;First, automatic HTTPS. Caddy handles SSL certificate provisioning and renewal through Let's Encrypt completely automatically. No more manual certificate management, no cron jobs for renewal, no forgetting to renew and having your site go down. It just works.&lt;/p&gt;

&lt;p&gt;Second, simplicity. Caddy's configuration format (the Caddyfile) is remarkably straightforward compared to nginx or Apache configurations. A basic site configuration can be just a few lines, yet it still offers powerful customization options when you need them.&lt;/p&gt;

&lt;p&gt;I'm also using a custom Caddy build with additional plugins: &lt;a href="https://github.com/caddy-dns/cloudflare" rel="noopener noreferrer"&gt;caddy-dns/cloudflare&lt;/a&gt; for DNS-01 ACME challenges (so I can get certificates even before DNS propagation completes) and &lt;a href="https://github.com/mholt/caddy-ratelimit" rel="noopener noreferrer"&gt;caddy-ratelimit&lt;/a&gt; to protect against bots and abuse.&lt;/p&gt;

&lt;h2&gt;
  
  
  DNS and Security with Cloudflare
&lt;/h2&gt;

&lt;p&gt;For DNS management, I'm using &lt;a href="https://www.cloudflare.com/" rel="noopener noreferrer"&gt;Cloudflare&lt;/a&gt;. But it's not just about DNS—I have Cloudflare Proxy enabled, which means all traffic to my site goes through Cloudflare's network first. This provides several benefits: DDoS protection, CDN caching, and most importantly, it hides my server's real IP address from the public.&lt;/p&gt;

&lt;p&gt;To take security a step further, I configured firewall rules directly in Hetzner Cloud to only allow incoming HTTP/HTTPS traffic from Cloudflare's IP ranges. This means even if someone discovers my server's actual IP address, they can't connect to the web server directly—all requests must go through Cloudflare. This setup effectively creates an additional security layer and ensures that all traffic benefits from Cloudflare's protection.&lt;/p&gt;

&lt;p&gt;Cloudflare publishes their IP ranges publicly, so keeping the firewall rules updated is straightforward. Combined with Caddy's rate limiting, this gives me a solid defense-in-depth approach without adding complexity to the daily operations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Infrastructure as Code with Terraform
&lt;/h2&gt;

&lt;p&gt;As someone who believes in automating everything, I needed proper Infrastructure as Code for my cloud setup. I looked for existing Terraform modules for Hetzner Cloud but didn't find anything that met my standards for flexibility and maintainability. So I built my own.&lt;/p&gt;

&lt;p&gt;I created a set of reusable Terraform modules that cover the essential Hetzner Cloud resources:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/danylomikula/terraform-hcloud-server" rel="noopener noreferrer"&gt;terraform-hcloud-server&lt;/a&gt; — Multi-server management with support for private networks and firewalls.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/danylomikula/terraform-hcloud-network" rel="noopener noreferrer"&gt;terraform-hcloud-network&lt;/a&gt; — VPC and subnet management&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/danylomikula/terraform-hcloud-firewall" rel="noopener noreferrer"&gt;terraform-hcloud-firewall&lt;/a&gt; — Firewall rules configuration&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/danylomikula/terraform-hcloud-ssh-key" rel="noopener noreferrer"&gt;terraform-hcloud-ssh-key&lt;/a&gt; — SSH key management with support for key generation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These modules are designed to work together seamlessly while remaining flexible enough for various use cases. They're all open source and available on both GitHub and the Terraform Registry.&lt;/p&gt;

&lt;h2&gt;
  
  
  Configuration Management with Ansible
&lt;/h2&gt;

&lt;p&gt;With infrastructure sorted out, I needed a way to automate the actual server configuration and site deployment. For this, I created an Ansible collection: &lt;a href="https://github.com/danylomikula/ansible-hugo-deploy" rel="noopener noreferrer"&gt;ansible-hugo-deploy&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This collection handles the complete deployment pipeline:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Installing and configuring Caddy with custom builds (including the Cloudflare DNS and rate limiting plugins)&lt;/li&gt;
&lt;li&gt;Generating SSH deploy keys for secure repository access&lt;/li&gt;
&lt;li&gt;Cloning the Hugo site from GitHub&lt;/li&gt;
&lt;li&gt;Building the site with Hugo&lt;/li&gt;
&lt;li&gt;Obtaining and managing SSL certificates&lt;/li&gt;
&lt;li&gt;Configuring rate limiting and security headers&lt;/li&gt;
&lt;li&gt;Setting up automated content updates via systemd timer&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The systemd timer runs daily, pulling the latest changes from the repository and rebuilding the site. This means I can just push a new post to GitHub, and within a day (or I can trigger it manually), the site updates automatically. No SSH-ing into the server, no manual deployments.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Complete Picture
&lt;/h2&gt;

&lt;p&gt;Here's how everything fits together:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Terraform&lt;/strong&gt; provisions the infrastructure on Hetzner Cloud—server, network, firewall rules (allowing only Cloudflare IPs), and SSH keys&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ansible&lt;/strong&gt; configures the server—installs Caddy, Hugo, sets up the deployment pipeline&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hugo&lt;/strong&gt; generates the static site from Markdown content&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Caddy&lt;/strong&gt; serves the site with automatic HTTPS, compression, and rate limiting&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare&lt;/strong&gt; handles DNS, proxies all traffic, provides DDoS protection and CDN caching&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;systemd timer&lt;/strong&gt; keeps the content automatically updated&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The entire setup—from bare server to fully functional website—takes about 15 minutes. And once it's running, I never need to touch the server for content updates. Write a post in Markdown, push to GitHub, and the site updates itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next?
&lt;/h2&gt;

&lt;p&gt;In the second part of this series, I'll dive deeper into the technical details of both the Terraform modules and the Ansible collection. I'll walk through the code, explain the design decisions, and show how you can use these tools for your own projects.&lt;/p&gt;

&lt;p&gt;All the code is open source and available on my GitHub:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/danylomikula?tab=repositories&amp;amp;q=terraform-hcloud" rel="noopener noreferrer"&gt;Terraform Hetzner Cloud Modules&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/danylomikula/ansible-hugo-deploy" rel="noopener noreferrer"&gt;Ansible Hugo Deploy Collection&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Feel free to use them, contribute, or just take inspiration for your own automation journey!&lt;/p&gt;

</description>
      <category>tutorial</category>
      <category>terraform</category>
      <category>ansible</category>
      <category>automation</category>
    </item>
    <item>
      <title>Automated macOS Setup with Dotfiles</title>
      <dc:creator>Danylo Mikula</dc:creator>
      <pubDate>Sat, 15 Nov 2025 05:28:13 +0000</pubDate>
      <link>https://forem.com/mikula/automated-macos-setup-with-dotfiles-4221</link>
      <guid>https://forem.com/mikula/automated-macos-setup-with-dotfiles-4221</guid>
      <description>&lt;p&gt;A comprehensive automation solution for setting up macOS development environments. This dotfiles repository handles everything from Homebrew package installation to SSH/GPG key generation and GitHub integration – all through an interactive bootstrap script.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repository:&lt;/strong&gt; &lt;a href="https://github.com/danylomikula/dotfiles" rel="noopener noreferrer"&gt;github.com/danylomikula/dotfiles&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;Every DevOps engineer knows the pain:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;New laptop arrives – 4-8 hours of manual setup&lt;/li&gt;
&lt;li&gt;Switching between personal/work machines – different configs everywhere
&lt;/li&gt;
&lt;li&gt;Team onboarding – "just install these 50 things..."&lt;/li&gt;
&lt;li&gt;Disaster recovery – scrambling to remember every tool and setting&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Traditional approaches fall short:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Manual installation&lt;/strong&gt;: Error-prone, inconsistent, undocumented&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Basic dotfiles&lt;/strong&gt;: Only manage config files, not installation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Homebrew Brewfile&lt;/strong&gt;: Doesn't handle SSH/GPG keys or directory structures&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ansible playbooks&lt;/strong&gt;: Overkill for personal use, slow iteration&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Solution: Intelligent Bootstrap Script
&lt;/h2&gt;

&lt;p&gt;I built a &lt;a href="https://github.com/danylomikula/dotfiles" rel="noopener noreferrer"&gt;comprehensive dotfiles repository&lt;/a&gt; that handles everything through a single &lt;code&gt;bootstrap.sh&lt;/code&gt; script with interactive prompts using &lt;code&gt;gum&lt;/code&gt; for a beautiful TUI experience.&lt;/p&gt;

&lt;h3&gt;
  
  
  What It Does
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Core Installation:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Homebrew + analytics disabled&lt;/li&gt;
&lt;li&gt;Nerd Fonts (Hack, Ubuntu Mono, Fira Code, Courier Prime)&lt;/li&gt;
&lt;li&gt;Oh-My-Zsh with plugins (autosuggestions, syntax highlighting, you-should-use)&lt;/li&gt;
&lt;li&gt;Modern CLI tools: &lt;code&gt;eza&lt;/code&gt;, &lt;code&gt;zoxide&lt;/code&gt;, &lt;code&gt;fzf&lt;/code&gt;, &lt;code&gt;starship&lt;/code&gt;, &lt;code&gt;zellij&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Python via &lt;code&gt;pyenv&lt;/code&gt; with latest version auto-installed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Applications:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Productivity&lt;/strong&gt;: Zen, Obsidian, Maccy, BetterDisplay, Grammarly&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Communication&lt;/strong&gt;: Signal, Telegram, Slack, Discord&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Development&lt;/strong&gt;: VSCode, Alacritty, Fork, Lens&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DevOps/Cloud&lt;/strong&gt;: AWS CLI, kubectl, helm, terraform, vault, argocd, ansible&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security&lt;/strong&gt;: Mullvad VPN, Tailscale, GPG Suite&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Utilities&lt;/strong&gt;: AldDente, Bartender, AppCleaner, Shottr&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Git Configuration:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Global &lt;code&gt;.gitconfig&lt;/code&gt; with sane defaults&lt;/li&gt;
&lt;li&gt;Separate directories for personal/work repos with automatic context switching&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;includeIf&lt;/code&gt; directives for email/name per directory&lt;/li&gt;
&lt;li&gt;Global &lt;code&gt;.gitignore&lt;/code&gt; setup&lt;/li&gt;
&lt;li&gt;Branch sorting by commit date, auto column UI&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;SSH Key Generation:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Ed25519 keys with modern crypto&lt;/li&gt;
&lt;li&gt;Automatic GitHub CLI integration&lt;/li&gt;
&lt;li&gt;Adds key to GitHub via API&lt;/li&gt;
&lt;li&gt;Tests SSH connection to GitHub&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;GPG Key Generation:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Ed25519 + Curve25519 keys for signing/encryption&lt;/li&gt;
&lt;li&gt;Configures &lt;code&gt;pinentry-mac&lt;/code&gt; for macOS Keychain integration&lt;/li&gt;
&lt;li&gt;Automatic GitHub integration via API&lt;/li&gt;
&lt;li&gt;Sets global signing key in Git&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Dotfiles Management:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GNU Stow for symlink management&lt;/li&gt;
&lt;li&gt;Configs for: zsh, starship, alacritty, zellij, k9s, docker, git, gh&lt;/li&gt;
&lt;li&gt;Alacritty themes auto-cloned (200+ colorschemes)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;AI Agents Configuration:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Configures &lt;a href="https://github.com/danylomikula/codex-cli" rel="noopener noreferrer"&gt;Codex&lt;/a&gt; and &lt;a href="https://claude.ai/" rel="noopener noreferrer"&gt;Claude&lt;/a&gt; for development workflow&lt;/li&gt;
&lt;li&gt;Installs Context7 MCP server for automatic library documentation&lt;/li&gt;
&lt;li&gt;Sets up global Copilot instructions at &lt;code&gt;~/.config/Code/User/prompts/context7.instructions.md&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Enables AI agents to automatically fetch library docs during code generation&lt;/li&gt;
&lt;li&gt;Configured via standalone &lt;code&gt;configure-ai-agents.sh&lt;/code&gt; script&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Directory Structure
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dotfiles/
├── bootstrap.sh              # Main installation script
├── configure-git.sh          # Standalone Git config tool
├── configure-ai-agents.sh    # Standalone AI agents setup
├── generate-gpg-key.sh       # Standalone GPG key generator
├── generate-ssh-key.sh       # Standalone SSH key generator
├── alacritty/
│   └── .config/alacritty/
│       ├── alacritty.toml
│       └── themes/           # 200+ themes via git submodule
├── claude/                   # Claude Code AI agent config
│   └── .claude/
│       └── CLAUDE.md
├── codex/                    # Codex AI agent config
│   └── .codex/
│       ├── AGENTS.md
│       └── config.toml
├── docker/.docker/
│   └── config.json
├── git/
│   ├── .gitconfig
│   └── .gitignore_global
├── github-cli/.config/gh/
├── k9s/.config/k9s/
├── starship/.config/
│   └── starship.toml
├── zellij/.config/zellij/
│   └── config.kdl
└── zshrc/
    └── .zshrc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Key Design Decisions
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;1. Interactive Prompts with Gum&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Instead of environment variables or config files, I use &lt;a href="https://github.com/charmbracelet/gum" rel="noopener noreferrer"&gt;charmbracelet/gum&lt;/a&gt; for beautiful TUI prompts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gum style &lt;span class="nt"&gt;--foreground&lt;/span&gt; &lt;span class="s2"&gt;"#00FF00"&lt;/span&gt; &lt;span class="nt"&gt;--bold&lt;/span&gt; &lt;span class="s2"&gt;"Do you want to generate a GPG key?"&lt;/span&gt;
&lt;span class="nv"&gt;CHOICE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;gum choose &lt;span class="s2"&gt;"Yes"&lt;/span&gt; &lt;span class="s2"&gt;"No"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This makes the script:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Self-documenting (you see what's being configured)&lt;/li&gt;
&lt;li&gt;Flexible (skip sections you don't need)&lt;/li&gt;
&lt;li&gt;Beginner-friendly (no prior knowledge required)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;2. Conditional Directory Creation&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The script offers to create separate Git directories:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;~/git/
├── personal/    &lt;span class="c"&gt;# Personal projects with personal email&lt;/span&gt;
└── work/        &lt;span class="c"&gt;# Work projects with work email&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Git automatically switches context using &lt;code&gt;includeIf&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[includeIf "gitdir:~/git/personal/**"]&lt;/span&gt;
    &lt;span class="py"&gt;path&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;~/git/personal/.gitconfig&lt;/span&gt;

&lt;span class="nn"&gt;[includeIf "gitdir:~/git/work/**"]&lt;/span&gt;
    &lt;span class="py"&gt;path&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;~/git/work/.gitconfig&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. Modern Crypto Defaults&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SSH&lt;/strong&gt;: Ed25519 (fast, secure, small keys)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GPG&lt;/strong&gt;: Ed25519 for signing + Curve25519 for encryption&lt;/li&gt;
&lt;li&gt;No RSA 2048/4096 bloat&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;4. GitHub API Integration&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Keys aren't just generated–they're automatically added to GitHub:&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;# SSH key&lt;/span&gt;
gh ssh-key add &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SSH_KEY_PATH&lt;/span&gt;&lt;span class="s2"&gt;.pub"&lt;/span&gt; &lt;span class="nt"&gt;--title&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;hostname&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# GPG key  &lt;/span&gt;
gh gpg-key add &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;gpg &lt;span class="nt"&gt;--armor&lt;/span&gt; &lt;span class="nt"&gt;--export&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$KEY_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;5. Stow for Symlink Management&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;GNU Stow creates symlinks from &lt;code&gt;dotfiles/&lt;/code&gt; to &lt;code&gt;$HOME&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;stow zshrc starship alacritty zellij k9s docker git github-cli
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This keeps your actual configs in Git while making them available system-wide.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. AI Agents with Context7 MCP&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;configure-ai-agents.sh&lt;/code&gt; script sets up AI development assistants:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Codex CLI&lt;/strong&gt;: Command-line AI agent for shell tasks&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Claude Integration&lt;/strong&gt;: Configured for VS Code with Copilot&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Context7 MCP Server&lt;/strong&gt;: Provides automatic library documentation via Model Context Protocol&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Global Instructions&lt;/strong&gt;: Creates &lt;code&gt;~/.config/Code/User/prompts/context7.instructions.md&lt;/code&gt; with rule to always use Context7 for docs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This enables AI agents to automatically fetch up-to-date documentation when generating code, reducing hallucinations and improving accuracy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Usage
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Quick Start (Full Bootstrap)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/danylomikula/dotfiles.git ~/dotfiles
&lt;span class="nb"&gt;cd&lt;/span&gt; ~/dotfiles
&lt;span class="nb"&gt;chmod&lt;/span&gt; +x bootstrap.sh
./bootstrap.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script will:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Install Homebrew and packages (5-10 min)&lt;/li&gt;
&lt;li&gt;Configure Oh-My-Zsh and plugins&lt;/li&gt;
&lt;li&gt;Sync dotfiles via Stow&lt;/li&gt;
&lt;li&gt;Prompt for Git configuration (optional)&lt;/li&gt;
&lt;li&gt;Offer to generate SSH key (optional)&lt;/li&gt;
&lt;li&gt;Offer to generate GPG key (optional)&lt;/li&gt;
&lt;li&gt;Offer to configure AI agents (optional)&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Standalone Scripts
&lt;/h3&gt;

&lt;p&gt;Each script can run independently:&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;# Just configure Git&lt;/span&gt;
./configure-git.sh

&lt;span class="c"&gt;# Just generate SSH key&lt;/span&gt;
./generate-ssh-key.sh

&lt;span class="c"&gt;# Just generate GPG key  &lt;/span&gt;
./generate-gpg-key.sh

&lt;span class="c"&gt;# Just configure AI agents&lt;/span&gt;
./configure-ai-agents.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Customization
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Add/Remove Packages:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Edit the &lt;code&gt;brew install&lt;/code&gt; lines in &lt;code&gt;bootstrap.sh&lt;/code&gt;:&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;# Add your tools here&lt;/span&gt;
brew &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--cask&lt;/span&gt; your-app-here
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Add New Dotfiles:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create directory: &lt;code&gt;mkdir -p newtool/.config/newtool&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Add config: &lt;code&gt;newtool/.config/newtool/config.yaml&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Stow it: &lt;code&gt;stow newtool&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Change Alacritty Theme:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="c"&gt;# ~/.config/alacritty/alacritty.toml&lt;/span&gt;
&lt;span class="nn"&gt;[general]&lt;/span&gt;
&lt;span class="py"&gt;import&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s"&gt;"~/.config/alacritty/themes/themes/tokyo-night.toml"&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Customize AI Agents:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Edit &lt;code&gt;~/.config/Code/User/prompts/context7.instructions.md&lt;/code&gt;:&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="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;applyTo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;**"&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Global-Context7-Rule"&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

&lt;span class="s"&gt;Always use context7 when I need code generation, setup steps, or library docs.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can add more rules or change the &lt;code&gt;applyTo&lt;/code&gt; pattern to scope instructions to specific file types.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real-World Usage
&lt;/h2&gt;

&lt;h3&gt;
  
  
  New Machine Setup
&lt;/h3&gt;

&lt;p&gt;Time to productive workstation: &lt;strong&gt;~15 minutes&lt;/strong&gt; (mostly package downloads)&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;# On new Mac&lt;/span&gt;
git clone https://github.com/danylomikula/dotfiles.git ~/dotfiles
&lt;span class="nb"&gt;cd&lt;/span&gt; ~/dotfiles
./bootstrap.sh

&lt;span class="c"&gt;# Answer prompts:&lt;/span&gt;
&lt;span class="c"&gt;# - Configure Git? Yes&lt;/span&gt;
&lt;span class="c"&gt;# - Personal dir? ~/git/personal&lt;/span&gt;
&lt;span class="c"&gt;# - Work dir? ~/git/work  &lt;/span&gt;
&lt;span class="c"&gt;# - Generate SSH? Yes&lt;/span&gt;
&lt;span class="c"&gt;# - Generate GPG? Yes&lt;/span&gt;
&lt;span class="c"&gt;# - Add to GitHub? Yes&lt;/span&gt;
&lt;span class="c"&gt;# - Configure AI agents? Yes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result: Fully configured machine with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;All dev tools installed&lt;/li&gt;
&lt;li&gt;SSH key in GitHub&lt;/li&gt;
&lt;li&gt;GPG signing configured&lt;/li&gt;
&lt;li&gt;Git context switching working&lt;/li&gt;
&lt;li&gt;Terminal customized&lt;/li&gt;
&lt;li&gt;AI agents with Context7 MCP ready&lt;/li&gt;
&lt;li&gt;Ready to &lt;code&gt;git clone&lt;/code&gt; and start working&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Disaster Recovery
&lt;/h3&gt;

&lt;p&gt;Laptop dies or needs rebuild:&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;# On replacement machine&lt;/span&gt;
git clone https://github.com/danylomikula/dotfiles.git ~/dotfiles
&lt;span class="nb"&gt;cd&lt;/span&gt; ~/dotfiles
./bootstrap.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Back to 100% productivity in under an hour (including restoring data from backups).&lt;/p&gt;




&lt;h2&gt;
  
  
  Technical Deep Dive
&lt;/h2&gt;

&lt;h3&gt;
  
  
  GPG Key Generation with Batch Mode
&lt;/h3&gt;

&lt;p&gt;I use GPG's batch mode to avoid interactive prompts:&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="nv"&gt;GPG_BATCH_FILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;mktemp&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GPG_BATCH_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;
%echo Generating a GPG key
Key-Type: eddsa
Key-Curve: ed25519
Subkey-Type: ecdh
Subkey-Curve: cv25519
Name-Real: &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;GPG_OWNER_NAME&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;
Name-Email: &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;GPG_OWNER_EMAIL&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;
Expire-Date: 0
Passphrase: &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;GPG_PASSWORD&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;
%commit
%echo done
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;gpg &lt;span class="nt"&gt;--batch&lt;/span&gt; &lt;span class="nt"&gt;--gen-key&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GPG_BATCH_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GPG_BATCH_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This creates:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Primary key&lt;/strong&gt;: Ed25519 for signing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subkey&lt;/strong&gt;: Curve25519 for encryption&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No expiration&lt;/strong&gt;: Suitable for long-term code signing&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Detecting New GPG Key After Generation
&lt;/h3&gt;

&lt;p&gt;Since we generate the key non-interactively, we need to find the new key ID:&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;# Capture existing keys before generation&lt;/span&gt;
&lt;span class="nv"&gt;EXISTING_KEYS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;gpg &lt;span class="nt"&gt;--list-secret-keys&lt;/span&gt; &lt;span class="nt"&gt;--keyid-format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;long &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'/^sec/ {print $2}'&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="s1"&gt;'/'&lt;/span&gt; &lt;span class="nt"&gt;-f2&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# Generate key...&lt;/span&gt;

&lt;span class="c"&gt;# Find the new key&lt;/span&gt;
&lt;span class="nv"&gt;NEW_KEYS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;gpg &lt;span class="nt"&gt;--list-secret-keys&lt;/span&gt; &lt;span class="nt"&gt;--keyid-format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;long &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'/^sec/ {print $2}'&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="s1"&gt;'/'&lt;/span&gt; &lt;span class="nt"&gt;-f2&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="nv"&gt;KEY_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;comm&lt;/span&gt; &lt;span class="nt"&gt;-13&lt;/span&gt; &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$EXISTING_KEYS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
                   &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NEW_KEYS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 1&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This uses &lt;code&gt;comm&lt;/code&gt; to find the set difference–the new key ID.&lt;/p&gt;

&lt;h3&gt;
  
  
  GitHub CLI Integration
&lt;/h3&gt;

&lt;p&gt;Adding keys to GitHub via API:&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;# Authenticate (opens browser for OAuth)&lt;/span&gt;
gh auth login

&lt;span class="c"&gt;# Add SSH key&lt;/span&gt;
gh ssh-key add &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.ssh/id_ed25519.pub"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--title&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;hostname&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--type&lt;/span&gt; authentication

&lt;span class="c"&gt;# Test connection&lt;/span&gt;
ssh &lt;span class="nt"&gt;-T&lt;/span&gt; git@github.com

&lt;span class="c"&gt;# Add GPG key  &lt;/span&gt;
gh gpg-key add &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;gpg &lt;span class="nt"&gt;--armor&lt;/span&gt; &lt;span class="nt"&gt;--export&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$KEY_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# Configure Git to use it&lt;/span&gt;
git config &lt;span class="nt"&gt;--global&lt;/span&gt; user.signingkey &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$KEY_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
git config &lt;span class="nt"&gt;--global&lt;/span&gt; commit.gpgsign &lt;span class="nb"&gt;true
&lt;/span&gt;git config &lt;span class="nt"&gt;--global&lt;/span&gt; tag.gpgSign &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No manual copying of keys into GitHub UI!&lt;/p&gt;

&lt;h3&gt;
  
  
  Pinentry Configuration for macOS
&lt;/h3&gt;

&lt;p&gt;macOS Keychain integration requires &lt;code&gt;pinentry-mac&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;pinentry-mac

&lt;span class="c"&gt;# Configure GPG agent&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"pinentry-program &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;which pinentry-mac&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; ~/.gnupg/gpg-agent.conf

&lt;span class="c"&gt;# Restart agent&lt;/span&gt;
killall gpg-agent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now GPG passphrase prompts use macOS Keychain–enter once, cached securely.&lt;/p&gt;

&lt;h3&gt;
  
  
  AI Agents Configuration Deep Dive
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;configure-ai-agents.sh&lt;/code&gt; script sets up CLI and desktop AI coding assistants with Context7 MCP integration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Tools Installation:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;node codex           &lt;span class="c"&gt;# Codex CLI&lt;/span&gt;
brew &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--cask&lt;/span&gt; claude-code   &lt;span class="c"&gt;# Claude desktop app&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Codex CLI Configuration:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Creates &lt;code&gt;~/.codex/AGENTS.md&lt;/code&gt; with global instructions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# Global instructions&lt;/span&gt;

&lt;span class="gu"&gt;## context7 instructions&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Always use context7 when I need code generation, setup or configuration 
  steps, or library/API documentation. This means you should automatically 
  use the Context7 MCP tools to resolve library id and get library docs 
  without me having to explicitly ask.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Creates &lt;code&gt;~/.codex/config.toml&lt;/code&gt; with MCP server configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="py"&gt;model&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"gpt-5.1-codex"&lt;/span&gt;
&lt;span class="py"&gt;model_reasoning_effort&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"high"&lt;/span&gt;

&lt;span class="nn"&gt;[mcp_servers.context7]&lt;/span&gt;
&lt;span class="py"&gt;command&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"npx"&lt;/span&gt;
&lt;span class="py"&gt;args&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"-y"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"@upstash/context7-mcp"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. Claude Desktop Configuration:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Creates &lt;code&gt;~/.claude/CLAUDE.md&lt;/code&gt; with Context7 usage instructions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# Context7 MCP usage&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Always use context7 when I need code generation, setup or configuration 
  steps, or library/API documentation. This means you should automatically 
  use the Context7 MCP tools to resolve library id and get library docs 
  without me having to explicitly ask.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Enables MCP server for Claude:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude mcp add context7 &lt;span class="nt"&gt;--&lt;/span&gt; npx &lt;span class="nt"&gt;-y&lt;/span&gt; @upstash/context7-mcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;4. How It Works:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Both agents can now automatically fetch library documentation via Context7 MCP:&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;# Codex CLI usage&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;codex &lt;span class="s2"&gt;"Add authentication with Supabase"&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt;Codex automatically fetches Supabase docs via Context7 MCP]
&lt;span class="s2"&gt;"Installing supabase-js and configuring auth..."&lt;/span&gt;

&lt;span class="c"&gt;# Claude Desktop usage&lt;/span&gt;
User: &lt;span class="s2"&gt;"Add authentication with Supabase"&lt;/span&gt;
Claude: &lt;span class="o"&gt;[&lt;/span&gt;fetches Supabase docs via Context7]
        &lt;span class="s2"&gt;"I'll help you set up Supabase authentication..."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This eliminates manual documentation lookups and reduces hallucinations by grounding AI responses in actual library docs.&lt;/p&gt;

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

&lt;p&gt;This dotfiles setup has saved me countless hours across:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;5+ fresh installs on new machines&lt;/li&gt;
&lt;li&gt;Daily context switching between personal/work&lt;/li&gt;
&lt;li&gt;Team onboarding&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Resources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/danylomikula/dotfiles" rel="noopener noreferrer"&gt;My dotfiles repository&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/charmbracelet/gum" rel="noopener noreferrer"&gt;Gum - Glamorous shell scripts&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.gnu.org/software/stow/" rel="noopener noreferrer"&gt;GNU Stow&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/context7/mcp" rel="noopener noreferrer"&gt;Context7 MCP Server&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://modelcontextprotocol.io/" rel="noopener noreferrer"&gt;Model Context Protocol&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/alacritty/alacritty-theme" rel="noopener noreferrer"&gt;Alacritty themes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://cli.github.com/" rel="noopener noreferrer"&gt;GitHub CLI&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ohmyz.sh/" rel="noopener noreferrer"&gt;Oh My Zsh&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Have questions or improvements? Open an issue or PR on the &lt;a href="https://github.com/danylomikula/dotfiles" rel="noopener noreferrer"&gt;dotfiles repo&lt;/a&gt;!&lt;/em&gt;&lt;/p&gt;

</description>
      <category>tutorial</category>
      <category>macos</category>
      <category>dotfiles</category>
      <category>automation</category>
    </item>
    <item>
      <title>Build a Highly Available Pi-hole Cluster with Ansible (VRRP)</title>
      <dc:creator>Danylo Mikula</dc:creator>
      <pubDate>Fri, 07 Nov 2025 00:55:34 +0000</pubDate>
      <link>https://forem.com/mikula/build-a-highly-available-pi-hole-cluster-with-ansible-vrrp-gbp</link>
      <guid>https://forem.com/mikula/build-a-highly-available-pi-hole-cluster-with-ansible-vrrp-gbp</guid>
      <description>&lt;p&gt;Step-by-step guide to prepare two Linux hosts, then use Ansible to deploy a highly available Pi-hole pair with keepalived (VRRP) and a Virtual IP, plus config sync and validation - powered by my open-source playbook: &lt;a href="https://github.com/danylomikula/ansible-pihole-cluster" rel="noopener noreferrer"&gt;ansible-pihole-cluster&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Download &amp;amp; flash the OS for Raspberry Pi
&lt;/h2&gt;

&lt;p&gt;Both &lt;a href="https://github.com/danylomikula/ansible-bootstrap" rel="noopener noreferrer"&gt;ansible-bootstrap&lt;/a&gt; and &lt;a href="https://github.com/danylomikula/ansible-pihole-cluster" rel="noopener noreferrer"&gt;ansible-pihole-cluster&lt;/a&gt; support the following distributions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Debian 13 (Trixie)&lt;/li&gt;
&lt;li&gt;Ubuntu 24.04 (Noble Numbat)&lt;/li&gt;
&lt;li&gt;Rocky Linux 10&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This guide uses Rocky Linux as an example, but feel free to pick whichever you prefer.&lt;/p&gt;

&lt;h3&gt;
  
  
  1) Get the Raspberry Pi image
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Go to the official &lt;a href="https://rockylinux.org/download?arch=aarch64" rel="noopener noreferrer"&gt;Rocky Linux &lt;strong&gt;Download&lt;/strong&gt; page&lt;/a&gt;: pick &lt;strong&gt;ARM (aarch64)&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Scroll to the &lt;strong&gt;Raspberry Pi Images&lt;/strong&gt; section and download the image for your Pi.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  2) Flash the image to a microSD card
&lt;/h3&gt;

&lt;p&gt;You can use &lt;strong&gt;balenaEtcher&lt;/strong&gt; (what I use below), or &lt;strong&gt;Raspberry Pi Imager&lt;/strong&gt;—both work.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option A — balenaEtcher
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Install/open &lt;a href="https://etcher.balena.io/" rel="noopener noreferrer"&gt;&lt;strong&gt;balenaEtcher&lt;/strong&gt;&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flash from file&lt;/strong&gt; → pick the Rocky Linux RPi image.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Select target&lt;/strong&gt; → choose your microSD card.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flash!&lt;/strong&gt; → wait for completion.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmfx16zlxrmj0flgkaz8c.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmfx16zlxrmj0flgkaz8c.png" alt="balenaEtcher" width="800" height="476"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Option B — Raspberry Pi Imager
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Open &lt;a href="https://www.raspberrypi.com/software/" rel="noopener noreferrer"&gt;&lt;strong&gt;Raspberry Pi Imager&lt;/strong&gt;&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Choose OS → Use custom&lt;/strong&gt; and select the Rocky Linux RPi image.&lt;/li&gt;
&lt;li&gt;Choose your microSD card and &lt;strong&gt;Next&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;When asked &lt;em&gt;“Would you like to apply OS customisation settings?”&lt;/em&gt; click &lt;strong&gt;No&lt;/strong&gt; (we’ll configure users/SSH/hostname later).&lt;/li&gt;
&lt;li&gt;You’ll get a &lt;em&gt;Warning&lt;/em&gt; that all data on the card will be erased — click &lt;strong&gt;Yes&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Folf3tedkpggksdc91v7i.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Folf3tedkpggksdc91v7i.png" alt="Raspberry Pi Imager" width="800" height="561"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Repeat this flashing process for both microSD cards (one per Raspberry Pi), then boot each Pi and make sure it gets a DHCP address on your network.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bootstrap: admin user, SSH keys, SSH hardening, networking, filesystem expansion
&lt;/h2&gt;

&lt;p&gt;Before deploying Pi-hole, each node needs initial setup — an admin user with SSH key access, hardened SSH, static IP configuration, firewall rules, and the filesystem expanded to use the full microSD card. You can automate all of this with &lt;a href="https://github.com/danylomikula/ansible-bootstrap" rel="noopener noreferrer"&gt;ansible-bootstrap&lt;/a&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Creates admin user with passwordless sudo&lt;/li&gt;
&lt;li&gt;Generates and deploys SSH keys&lt;/li&gt;
&lt;li&gt;Hardens SSH (disables password authentication and root login)&lt;/li&gt;
&lt;li&gt;Configures static IPv4/IPv6 addresses, gateway, and DNS&lt;/li&gt;
&lt;li&gt;Sets up firewall (firewalld) with custom zones and services&lt;/li&gt;
&lt;li&gt;Expands the filesystem to use all available disk space&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  1) Install the collection
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ansible-galaxy collection &lt;span class="nb"&gt;install &lt;/span&gt;danylomikula.ansible_bootstrap
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2) Create the inventory
&lt;/h3&gt;

&lt;p&gt;Create &lt;code&gt;inventory.ini&lt;/code&gt; with your nodes. The &lt;code&gt;ansible_host&lt;/code&gt; should be the current DHCP address of each Pi, while &lt;code&gt;bootstrap_static_ip&lt;/code&gt; and &lt;code&gt;bootstrap_gateway&lt;/code&gt; define the static network configuration that will be applied:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[servers]&lt;/span&gt;
&lt;span class="err"&gt;pihole-master&lt;/span&gt; &lt;span class="py"&gt;ansible_host&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;10.20.160.251 bootstrap_static_ip=10.20.0.50/16 bootstrap_gateway=10.20.0.1&lt;/span&gt;
&lt;span class="err"&gt;pihole-backup&lt;/span&gt; &lt;span class="py"&gt;ansible_host&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;10.20.200.39 bootstrap_static_ip=10.20.0.51/16 bootstrap_gateway=10.20.0.1&lt;/span&gt;

&lt;span class="nn"&gt;[servers:vars]&lt;/span&gt;
&lt;span class="py"&gt;ansible_user&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;rocky&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; On first run, &lt;code&gt;ansible_user&lt;/code&gt; is the default OS user (e.g., &lt;code&gt;rocky&lt;/code&gt; for Rocky Linux). After bootstrap completes, the new admin user is created and the nodes are accessible via SSH keys.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  3) Create the bootstrap playbook
&lt;/h3&gt;

&lt;p&gt;Create &lt;code&gt;site.yml&lt;/code&gt;:&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="nn"&gt;---&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Bootstrap servers&lt;/span&gt;
  &lt;span class="na"&gt;hosts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;all&lt;/span&gt;
  &lt;span class="na"&gt;become&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;vars&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;bootstrap_user&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dan"&lt;/span&gt;
    &lt;span class="na"&gt;bootstrap_ssh_key_generate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;bootstrap_network_enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;bootstrap_dns4&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;1.1.1.1"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1.0.0.1"&lt;/span&gt;
    &lt;span class="na"&gt;bootstrap_firewall_enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;bootstrap_firewall_zone&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;public"&lt;/span&gt;
    &lt;span class="na"&gt;bootstrap_firewall_services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ssh&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;http&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;https&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;dns&lt;/span&gt;
    &lt;span class="na"&gt;bootstrap_firewall_custom_zones&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ftl&lt;/span&gt;
        &lt;span class="na"&gt;interface&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;lo&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="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4711&lt;/span&gt;
            &lt;span class="na"&gt;proto&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tcp&lt;/span&gt;
    &lt;span class="na"&gt;bootstrap_firewall_allow_icmp&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;bootstrap_expand_fs_enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;roles&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;danylomikula.ansible_bootstrap.bootstrap&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4) Run the bootstrap playbook
&lt;/h3&gt;

&lt;p&gt;On the first run, SSH keys are not yet deployed, so you need to provide the password interactively:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ansible-playbook &lt;span class="nt"&gt;-i&lt;/span&gt; inventory.ini site.yml &lt;span class="nt"&gt;-k&lt;/span&gt; &lt;span class="nt"&gt;-K&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Default credentials for Rocky Linux:&lt;/strong&gt; User: &lt;code&gt;rocky&lt;/code&gt;, Password: &lt;code&gt;rockylinux&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;After bootstrap completes, the nodes reboot with their new static IPs. SSH keys are generated in the &lt;code&gt;ssh_keys/&lt;/code&gt; directory — you'll reference these keys later in the Pi-hole cluster inventory.&lt;/p&gt;

&lt;p&gt;See the &lt;a href="https://github.com/danylomikula/ansible-bootstrap#readme" rel="noopener noreferrer"&gt;ansible-bootstrap README&lt;/a&gt; for the full list of configuration options.&lt;/p&gt;




&lt;h2&gt;
  
  
  Deploy Pi-hole cluster with Ansible
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Prerequisites:&lt;/strong&gt; &lt;a href="https://docs.ansible.com/ansible/latest/installation_guide/intro_installation.html" rel="noopener noreferrer"&gt;Ansible&lt;/a&gt; installed on your configuration device and both nodes bootstrapped (see previous section).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  1) Install the collection
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ansible-galaxy collection &lt;span class="nb"&gt;install &lt;/span&gt;danylomikula.ansible_pihole_cluster
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2) Create the inventory
&lt;/h3&gt;

&lt;p&gt;Create &lt;code&gt;inventory.ini&lt;/code&gt; with your nodes. Point &lt;code&gt;ansible_ssh_private_key_file&lt;/code&gt; to the SSH keys generated by &lt;code&gt;ansible-bootstrap&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[master]&lt;/span&gt;
&lt;span class="err"&gt;pihole-master&lt;/span&gt; &lt;span class="py"&gt;ansible_host&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;10.20.0.50 ansible_ssh_private_key_file=../pihole-bootstrap/ssh_keys/pihole-master_ed25519 priority=150&lt;/span&gt;

&lt;span class="nn"&gt;[backup]&lt;/span&gt;
&lt;span class="err"&gt;pihole-backup&lt;/span&gt; &lt;span class="py"&gt;ansible_host&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;10.20.0.51 ansible_ssh_private_key_file=../pihole-bootstrap/ssh_keys/pihole-backup_ed25519 priority=140&lt;/span&gt;

&lt;span class="nn"&gt;[pihole_cluster:children]&lt;/span&gt;
&lt;span class="err"&gt;master&lt;/span&gt;
&lt;span class="err"&gt;backup&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; Adjust the &lt;code&gt;ansible_ssh_private_key_file&lt;/code&gt; paths to match where your bootstrap SSH keys are stored.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  3) Create the playbook
&lt;/h3&gt;

&lt;p&gt;Create &lt;code&gt;site.yml&lt;/code&gt; with all cluster configuration in one place:&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="nn"&gt;---&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy Pi-hole HA Cluster&lt;/span&gt;
  &lt;span class="na"&gt;hosts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pihole_cluster&lt;/span&gt;
  &lt;span class="na"&gt;become&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;vars&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;ansible_user&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dan&lt;/span&gt;
    &lt;span class="na"&gt;bootstrap_timezone&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;America/New_York"&lt;/span&gt;
    &lt;span class="na"&gt;keepalived_vip_ipv4&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10.20.0.53/16"&lt;/span&gt;           &lt;span class="c1"&gt;# Virtual IP for failover&lt;/span&gt;
    &lt;span class="na"&gt;pihole_web_password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SUPER_SECURE_PASSWORD"&lt;/span&gt;    &lt;span class="c1"&gt;# Use ansible-vault!&lt;/span&gt;
    &lt;span class="na"&gt;pihole_version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;6.3"&lt;/span&gt;
    &lt;span class="na"&gt;nebula_sync_version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v0.11.1"&lt;/span&gt;
    &lt;span class="na"&gt;pihole_local_domain&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;homelab.local"&lt;/span&gt;
    &lt;span class="na"&gt;local_dns_records&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;10.20.0.88 node.homelab.local&lt;/span&gt;
      &lt;span class="s"&gt;10.20.0.96 nas.homelab.local&lt;/span&gt;
  &lt;span class="na"&gt;roles&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;danylomikula.ansible_pihole_cluster.updates&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;danylomikula.ansible_pihole_cluster.bootstrap&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;danylomikula.ansible_pihole_cluster.docker&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;danylomikula.ansible_pihole_cluster.keepalived&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;danylomikula.ansible_pihole_cluster.unbound&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;danylomikula.ansible_pihole_cluster.pihole&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;danylomikula.ansible_pihole_cluster.pihole_updatelists&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;danylomikula.ansible_pihole_cluster.nebula_sync&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;danylomikula.ansible_pihole_cluster.status&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;See the full list of available variables in the &lt;a href="https://github.com/danylomikula/ansible-pihole-cluster/blob/main/group_vars/all.yml" rel="noopener noreferrer"&gt;group_vars/all.yml&lt;/a&gt; example.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;What the playbook installs (and why)&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;keepalived&lt;/strong&gt; — Provides VRRP and the floating Virtual IP so one node is always the active DNS endpoint. If the master goes down, the backup takes over automatically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;unbound&lt;/strong&gt; — A local validating, recursive DNS resolver. Pi-hole forwards queries to Unbound on-box instead of public resolvers, improving privacy and reducing external dependency. &lt;a href="https://docs.pi-hole.net/guides/dns/unbound/" rel="noopener noreferrer"&gt;Pi-hole's official guide&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;nebula-sync&lt;/strong&gt; — A lightweight synchronizer that keeps Pi-hole config/state in sync between nodes (lists, local DNS, settings). &lt;a href="https://github.com/lovelaze/nebula-sync" rel="noopener noreferrer"&gt;Project&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;pihole-updatelists&lt;/strong&gt; — Automates fetching and applying block/allow lists from remote sources on a schedule. &lt;a href="https://github.com/jacklul/pihole-updatelists" rel="noopener noreferrer"&gt;Project&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  4) (Optional) Quick connectivity test
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ansible all &lt;span class="nt"&gt;-i&lt;/span&gt; inventory.ini &lt;span class="nt"&gt;-m&lt;/span&gt; ping
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  5) Deploy the cluster
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ansible-playbook &lt;span class="nt"&gt;-i&lt;/span&gt; inventory.ini site.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  6) Point your network to the Virtual IP
&lt;/h3&gt;

&lt;p&gt;Update your DHCP/router (or manual client settings) to use the VIP you set in &lt;code&gt;site.yml&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;IPv4 DNS: &lt;code&gt;keepalived_vip_ipv4&lt;/code&gt; (e.g., &lt;code&gt;10.20.0.53&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;IPv6 DNS: &lt;code&gt;ipv6_vip&lt;/code&gt; (if configured)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fx1nv2yaqzgj9qopqv0xk.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fx1nv2yaqzgj9qopqv0xk.webp" alt="Pi-hole Ansible Result" width="800" height="198"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  7) Verify
&lt;/h3&gt;

&lt;p&gt;On whichever node should be master (higher &lt;code&gt;priority&lt;/code&gt;), check that the VIP is present:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ip a show dev eth0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Confirm Pi-hole is answering:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig @10.20.0.53 example.com +short
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that resolves, you're done — your HA Pi-hole pair is live behind a single Virtual IP.&lt;/p&gt;

</description>
      <category>pihole</category>
      <category>raspberrypi</category>
      <category>ansible</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>YubiKey + PGP: Offline Primary, Subkeys, Backups, and Git Signing</title>
      <dc:creator>Danylo Mikula</dc:creator>
      <pubDate>Fri, 22 Aug 2025 03:25:38 +0000</pubDate>
      <link>https://forem.com/mikula/yubikey-pgp-end-to-end-offline-primary-subkeys-backups-and-a-spare-4m09</link>
      <guid>https://forem.com/mikula/yubikey-pgp-end-to-end-offline-primary-subkeys-backups-and-a-spare-4m09</guid>
      <description>&lt;h1&gt;
  
  
  What we’ll build
&lt;/h1&gt;

&lt;p&gt;In this guide we’ll set up a modern PGP workflow anchored by a YubiKey. We’ll generate an &lt;strong&gt;offline, certification-only primary key&lt;/strong&gt; and three &lt;strong&gt;subkeys&lt;/strong&gt; for &lt;strong&gt;sign&lt;/strong&gt;, &lt;strong&gt;encrypt&lt;/strong&gt;, and &lt;strong&gt;auth&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;We’ll also create secure backups&lt;/strong&gt; before moving anything to hardware, set expiration dates on the subkeys, load the subkeys onto our &lt;strong&gt;primary YubiKey&lt;/strong&gt; with touch policies, and then clone those subkeys to a &lt;strong&gt;second YubiKey&lt;/strong&gt; as a spare. Finally, we’ll integrate the setup with &lt;strong&gt;Git commit signing&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This guide is written for macOS, but the steps translate easily to Linux.&lt;/em&gt;&lt;/p&gt;




&lt;h1&gt;
  
  
  Prerequisites
&lt;/h1&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;a href="https://www.yubico.com/products/yubikey-5-overview/" rel="noopener noreferrer"&gt;&lt;strong&gt;YubiKey&lt;/strong&gt;&lt;/a&gt; with the &lt;strong&gt;OpenPGP&lt;/strong&gt; applet (e.g., YubiKey 5 series).&lt;/li&gt;
&lt;li&gt;macOS with &lt;a href="https://brew.sh/" rel="noopener noreferrer"&gt;&lt;strong&gt;Homebrew&lt;/strong&gt;&lt;/a&gt; installed.&lt;/li&gt;
&lt;li&gt;Basic terminal familiarity.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Install the tools (macOS)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;gnupg ykman pinentry-mac
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h1&gt;
  
  
  Basic GnuPG setup
&lt;/h1&gt;

&lt;p&gt;We’ll work inside a &lt;strong&gt;temporary GnuPG home&lt;/strong&gt; so we don’t touch any existing setup.&lt;/p&gt;

&lt;h3&gt;
  
  
  1) Create a temporary GNUPGHOME
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;GNUPGHOME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;mktemp&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; gpg-&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%Y.%m.%d&lt;span class="si"&gt;))&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;chmod &lt;/span&gt;700 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GNUPGHOME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2) Create &lt;code&gt;gpg.conf&lt;/code&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GNUPGHOME&lt;/span&gt;&lt;span class="s2"&gt;/gpg.conf"&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
# === Crypto preferences ===
personal-cipher-preferences AES256 AES192 AES
personal-digest-preferences SHA512 SHA384 SHA256
personal-compress-preferences ZLIB BZIP2 ZIP Uncompressed
default-preference-list SHA512 SHA384 SHA256 AES256 AES192 AES ZLIB BZIP2 ZIP Uncompressed

# === Strong digests &amp;amp; s2k ===
cert-digest-algo SHA512
s2k-digest-algo SHA512
s2k-cipher-algo AES256

# === Output / UX ===
charset utf-8
no-comments
no-emit-version
no-greeting
keyid-format 0xlong
list-options show-uid-validity
verify-options show-uid-validity
with-fingerprint
use-agent
armor

# === Security hardening ===
require-cross-certification
require-secmem
no-symkey-cache

# === New keys default ===
default-new-key-algo ed25519/cert,sign+cv25519/encr+ed25519/auth
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3) Create &lt;code&gt;gpg-agent.conf&lt;/code&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GNUPGHOME&lt;/span&gt;&lt;span class="s2"&gt;/gpg-agent.conf"&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
# === Path to pinentry on macOS/Homebrew (Apple Silicon) ===
pinentry-program /opt/homebrew/bin/pinentry-mac

# === SSH Support ===
enable-ssh-support

# === Cache TTLs ===
default-cache-ttl 86400
max-cache-ttl 86400
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4) Reload agent to pick up the config:
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gpgconf &lt;span class="nt"&gt;--kill&lt;/span&gt; gpg-agent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h1&gt;
  
  
  Prepare the YubiKey
&lt;/h1&gt;

&lt;p&gt;If you keep a &lt;strong&gt;spare YubiKey&lt;/strong&gt;, run all steps below on &lt;strong&gt;both&lt;/strong&gt; devices (one at a time).&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Insert only one YubiKey at a time to avoid confusion.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  1) Enable KDF
&lt;/h3&gt;

&lt;p&gt;KDF (Key Derivation Function) makes the YubiKey store a hash of the PIN and derive it locally, rather than accepting the PIN as plain text.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Older/legacy OpenPGP clients (especially some mobile apps) may not support KDF; those clients will fail PIN checks if KDF is enabled.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Order of operations:&lt;/strong&gt; Enable KDF &lt;strong&gt;before&lt;/strong&gt; changing PINs or moving subkeys to the card, otherwise you may hit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;gpg: error for setup KDF: Conditions of use not satisfied
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Enable KDF:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gpg &lt;span class="nt"&gt;--card-edit&lt;/span&gt;
gpg/card&amp;gt; admin
gpg/card&amp;gt; kdf-setup
&lt;span class="c"&gt;# pinentry will prompt for the Admin PIN (default is 12345678 on a fresh card)&lt;/span&gt;
gpg/card&amp;gt; quit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gpg &lt;span class="nt"&gt;--card-status&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'KDF setting'&lt;/span&gt;
&lt;span class="c"&gt;# KDF setting ......: on&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;Do this on &lt;strong&gt;each&lt;/strong&gt; YubiKey (primary and spare) before proceeding to change PINs, set touch policies, or move subkeys.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  2) Change the default PINs
&lt;/h3&gt;

&lt;p&gt;Generate random numeric PINs and store these securely:&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;export &lt;/span&gt;&lt;span class="nv"&gt;ADMIN_PIN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nv"&gt;LC_ALL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;C &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="nt"&gt;-dc&lt;/span&gt; &lt;span class="s1"&gt;'0-9'&lt;/span&gt; &amp;lt;/dev/urandom | &lt;span class="nb"&gt;fold&lt;/span&gt; &lt;span class="nt"&gt;-w8&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;USER_PIN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nv"&gt;LC_ALL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;C  &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="nt"&gt;-dc&lt;/span&gt; &lt;span class="s1"&gt;'0-9'&lt;/span&gt; &amp;lt;/dev/urandom | &lt;span class="nb"&gt;fold&lt;/span&gt; &lt;span class="nt"&gt;-w6&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;Admin PIN: %12s&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;User  PIN: %12s&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ADMIN_PIN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$USER_PIN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Change the PINs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gpg &lt;span class="nt"&gt;--card-edit&lt;/span&gt;
gpg/card&amp;gt; admin
gpg/card&amp;gt; passwd
&lt;span class="c"&gt;# 1 - Change PIN          (default 123456)     -&amp;gt; enter $USER_PIN&lt;/span&gt;
&lt;span class="c"&gt;# 3 - Change Admin PIN    (default 12345678)   -&amp;gt; enter $ADMIN_PIN&lt;/span&gt;
&lt;span class="c"&gt;# Q - quit&lt;/span&gt;
gpg/card&amp;gt; quit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;Default User PIN = 123456, default Admin PIN = 12345678&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  3) Set touch policies
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Require touch for every signature&lt;/span&gt;
ykman openpgp keys set-touch sig on

&lt;span class="c"&gt;# Cache touch after PIN entry for encryption and auth&lt;/span&gt;
ykman openpgp keys set-touch enc cached
ykman openpgp keys set-touch aut cached
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ykman openpgp info
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Touch policy modes:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;off&lt;/code&gt; — no touch required&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;on&lt;/code&gt; — touch required for &lt;strong&gt;every&lt;/strong&gt; operation&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;fixed&lt;/code&gt; — like &lt;code&gt;on&lt;/code&gt;, but cannot be changed without resetting the OpenPGP applet&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cached&lt;/code&gt; — touch once per PIN session (until PIN cache expires or card is removed)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cached-fixed&lt;/code&gt; — like &lt;code&gt;cached&lt;/code&gt;, but cannot be changed without reset&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  4) Set key attributes (Ed25519 / cv25519 / Ed25519)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gpg &lt;span class="nt"&gt;--card-edit&lt;/span&gt;
gpg/card&amp;gt; admin
gpg/card&amp;gt; key-attr
&lt;span class="c"&gt;# Signature : ECC -&amp;gt; Curve 25519 (Ed25519)&lt;/span&gt;
&lt;span class="c"&gt;# Encryption: ECC -&amp;gt; Curve 25519 (cv25519 / X25519)&lt;/span&gt;
&lt;span class="c"&gt;# Authentication: ECC -&amp;gt; Curve 25519 (Ed25519)&lt;/span&gt;
gpg/card&amp;gt; quit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fiw1tmxxd4u1jvnotjd4h.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fiw1tmxxd4u1jvnotjd4h.png" alt="Set key attributes (Ed25519 / cv25519 / Ed25519)" width="800" height="990"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  5) Cardholder info
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gpg &lt;span class="nt"&gt;--card-edit&lt;/span&gt;
gpg/card&amp;gt; admin
gpg/card&amp;gt; name   &lt;span class="c"&gt;# Lastname, Firstname&lt;/span&gt;
gpg/card&amp;gt; lang   &lt;span class="c"&gt;# en&lt;/span&gt;
gpg/card&amp;gt; login  &lt;span class="c"&gt;# your.email@example.com&lt;/span&gt;
gpg/card&amp;gt; quit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check card status:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gpg &lt;span class="nt"&gt;--card-status&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When finished, repeat the same steps on your &lt;strong&gt;spare&lt;/strong&gt; (if you have one).&lt;/p&gt;




&lt;h1&gt;
  
  
  Generate the offline Master (Certify) key (Ed25519)
&lt;/h1&gt;

&lt;p&gt;The primary (master) key is the &lt;strong&gt;Certify&lt;/strong&gt; key. It’s used only to issue subkeys for &lt;strong&gt;sign&lt;/strong&gt;, &lt;strong&gt;encrypt&lt;/strong&gt;, and &lt;strong&gt;auth&lt;/strong&gt;.&lt;br&gt;
We keep this key &lt;strong&gt;offline at all times&lt;/strong&gt; and use it only in a dedicated, secure environment to &lt;strong&gt;create&lt;/strong&gt; or &lt;strong&gt;revoke&lt;/strong&gt; subkeys.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Do &lt;strong&gt;not&lt;/strong&gt; set an expiration date on the Certify key.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;
  
  
  1) Generate a strong passphrase for the master key
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;CERTIFY_PASS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nv"&gt;LC_ALL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;C &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="nt"&gt;-dc&lt;/span&gt; &lt;span class="s2"&gt;"A-Z2-9"&lt;/span&gt; &amp;lt; /dev/urandom | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"IOUS5"&lt;/span&gt; | &lt;span class="nb"&gt;fold&lt;/span&gt; &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PASS_GROUPSIZE&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;4&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt; | &lt;span class="nb"&gt;paste&lt;/span&gt; &lt;span class="nt"&gt;-sd&lt;/span&gt; &lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PASS_DELIMITER&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="p"&gt;-&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt; - | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PASS_LENGTH&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;29&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="nv"&gt;$CERTIFY_PASS&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;blockquote&gt;
&lt;p&gt;We will use it when GnuPG prompts during key creation.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;
  
  
  2) Create the Certify-only primary key
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gpg &lt;span class="nt"&gt;--expert&lt;/span&gt; &lt;span class="nt"&gt;--full-generate-key&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;When prompted:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Key type:&lt;/strong&gt; choose &lt;strong&gt;ECC (set your own capabilities)&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Capabilities:&lt;/strong&gt; leave &lt;strong&gt;only “Certify (C)” enabled&lt;/strong&gt;. Disable Sign/Encrypt/Auth if shown.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Curve:&lt;/strong&gt; choose &lt;strong&gt;Curve 25519 (Ed25519)&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expiration:&lt;/strong&gt; choose &lt;strong&gt;no expiration&lt;/strong&gt; (enter &lt;code&gt;0&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;User ID:&lt;/strong&gt; enter your real name and email (comment optional).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Passphrase:&lt;/strong&gt; enter the passphrase you generated in step 1.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0ffa8k7q8vuko582ciby.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0ffa8k7q8vuko582ciby.png" alt="Offline Master (Certify) key" width="800" height="676"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;After creation, note the key ID:&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;export &lt;/span&gt;&lt;span class="nv"&gt;KEYID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;gpg &lt;span class="nt"&gt;--list-keys&lt;/span&gt; &lt;span class="nt"&gt;--keyid-format&lt;/span&gt; 0xlong &lt;span class="nt"&gt;--with-colons&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt;: &lt;span class="s1"&gt;'/^pub:/ {print $5; exit}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Primary KEYID: &lt;/span&gt;&lt;span class="nv"&gt;$KEYID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3) Verify the primary key is Certify-only
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gpg &lt;span class="nt"&gt;-K&lt;/span&gt; &lt;span class="nt"&gt;--keyid-format&lt;/span&gt; long
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sec   ed25519/XXXXXXXXXX YYYY-MM-DD [C]
      Key fingerprint = XXXXXXXXXXXXXXXXX
uid   [ultimate] Your Name &amp;lt;you@example.com&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only &lt;strong&gt;[C]&lt;/strong&gt; should appear on the &lt;code&gt;sec&lt;/code&gt; line (no &lt;code&gt;[S]&lt;/code&gt;, &lt;code&gt;[E]&lt;/code&gt;, or &lt;code&gt;[A]&lt;/code&gt;).&lt;/p&gt;




&lt;h1&gt;
  
  
  Create subkeys
&lt;/h1&gt;

&lt;p&gt;We’ll add three subkeys to the offline primary key: &lt;strong&gt;Sign (Ed25519)&lt;/strong&gt;, &lt;strong&gt;Encrypt (cv25519/X25519)&lt;/strong&gt;, and &lt;strong&gt;Auth (Ed25519)&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Open the key editor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gpg &lt;span class="nt"&gt;--expert&lt;/span&gt; &lt;span class="nt"&gt;--edit-key&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$KEYID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  1) Sign subkey
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;gpg&amp;gt; addkey
# Choose: ECC (set your own capabilities)   # number may be (11)
# Curve: 25519 (Ed25519)
# Capabilities: leave ONLY [S] Sign (toggle others off)
# Expiration: 5y (or your preference)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2) Encrypt subkey
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;gpg&amp;gt; addkey
# Choose: ECC (encrypt only)                # number may be (12)
# Curve: 25519
# Expiration: 5y
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3) Auth subkey
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;gpg&amp;gt; addkey
# Choose: ECC (set your own capabilities)   # number may be (11)
# Capabilities: leave ONLY [A] Authenticate
# Curve: 25519
# Expiration: 5y
gpg&amp;gt; save
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4) Verify
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gpg &lt;span class="nt"&gt;-K&lt;/span&gt; &lt;span class="nt"&gt;--keyid-format&lt;/span&gt; long
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see your primary key with &lt;strong&gt;[C]&lt;/strong&gt; only, and three &lt;code&gt;ssb&lt;/code&gt; lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sec   ed25519/XXXXXXXXXX YYYY-MM-DD [C]
uid   Your Name &amp;lt;you@example.com&amp;gt;
ssb   ed25519/XXXXXXXXXX YYYY-MM-DD [S] [expires: YYYY-MM-DD]
ssb   cv25519/XXXXXXXXXX YYYY-MM-DD [E] [expires: YYYY-MM-DD]
ssb   ed25519/XXXXXXXXXX YYYY-MM-DD [A] [expires: YYYY-MM-DD]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h1&gt;
  
  
  Export backups (master, subkeys, public)
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;Do this &lt;strong&gt;before&lt;/strong&gt; moving any keys to the YubiKey.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  1) Create a private backups folder:
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; gnupg-backups
&lt;span class="nb"&gt;chmod &lt;/span&gt;700 gnupg-backups
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2) Export the keys:
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Master (primary + subkeys): full secret material&lt;/span&gt;
gpg &lt;span class="nt"&gt;--output&lt;/span&gt; &lt;span class="s2"&gt;"gnupg-backups/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;KEYID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-certify-&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;.key"&lt;/span&gt; &lt;span class="nt"&gt;--armor&lt;/span&gt; &lt;span class="nt"&gt;--export-secret-keys&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;KEYID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# Secret subkeys only (no primary secret) — use for loading to YubiKey / cloning to spare&lt;/span&gt;
gpg &lt;span class="nt"&gt;--output&lt;/span&gt; &lt;span class="s2"&gt;"gnupg-backups/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;KEYID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-subkeys-&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;.key"&lt;/span&gt; &lt;span class="nt"&gt;--armor&lt;/span&gt; &lt;span class="nt"&gt;--export-secret-subkeys&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;KEYID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# Public key (safe to share)&lt;/span&gt;
gpg &lt;span class="nt"&gt;--output&lt;/span&gt; &lt;span class="s2"&gt;"gnupg-backups/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;KEYID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-public-&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;.asc"&lt;/span&gt; &lt;span class="nt"&gt;--armor&lt;/span&gt; &lt;span class="nt"&gt;--export&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;KEYID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;Store copies in &lt;strong&gt;two separate offline locations&lt;/strong&gt; (e.g., two encrypted USB drives kept in different places).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  3) (Optional) Encrypt the backup files
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Symmetric (passphrase-based):&lt;/strong&gt;&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;# Encrypt each file with AES256; you’ll be prompted for a passphrase&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;f &lt;span class="k"&gt;in &lt;/span&gt;gnupg-backups/&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;KEYID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;-&lt;span class="o"&gt;{&lt;/span&gt;certify,subkeys&lt;span class="o"&gt;}&lt;/span&gt;-&lt;span class="k"&gt;*&lt;/span&gt;.key gnupg-backups/&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;KEYID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="nt"&gt;-public-&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;.asc&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;gpg &lt;span class="nt"&gt;--symmetric&lt;/span&gt; &lt;span class="nt"&gt;--cipher-algo&lt;/span&gt; AES256 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  (Optional) Quick integrity check of the backups
&lt;/h4&gt;

&lt;p&gt;Spin up a throwaway keyring, import, and list:&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="nv"&gt;TMPVERIFY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;mktemp&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; gpg-verify-XXXX&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;chmod &lt;/span&gt;700 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TMPVERIFY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="nv"&gt;GNUPGHOME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TMPVERIFY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; gpg &lt;span class="nt"&gt;--import&lt;/span&gt; gnupg-backups/&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;KEYID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="nt"&gt;-public-&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;.asc
&lt;span class="nv"&gt;GNUPGHOME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TMPVERIFY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; gpg &lt;span class="nt"&gt;--import&lt;/span&gt; gnupg-backups/&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;KEYID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="nt"&gt;-subkeys-&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;.key
&lt;span class="nv"&gt;GNUPGHOME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TMPVERIFY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; gpg &lt;span class="nt"&gt;--list-secret-keys&lt;/span&gt; &lt;span class="nt"&gt;--keyid-format&lt;/span&gt; 0xlong
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see &lt;code&gt;sec#&lt;/code&gt; (stub for the primary) and &lt;code&gt;ssb&lt;/code&gt; lines for subkeys after the subkeys import.&lt;/p&gt;

&lt;h3&gt;
  
  
  4) Clean up the temporary directory
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TMPVERIFY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h1&gt;
  
  
  Transfer subkeys to the YubiKey
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Important:&lt;/strong&gt; Moving subkeys to a YubiKey turns their on-disk copies into &lt;strong&gt;stubs&lt;/strong&gt; (&lt;code&gt;ssb#&lt;/code&gt; → &lt;code&gt;ssb&amp;gt;&lt;/code&gt;), so they can’t be moved again &lt;strong&gt;unless&lt;/strong&gt; we re-import the &lt;strong&gt;secret-subkeys backup&lt;/strong&gt;. Make sure backups are done.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;We’ll need the &lt;strong&gt;Certify key passphrase&lt;/strong&gt; (for decrypting the secret material) and the YubiKey &lt;strong&gt;Admin PIN&lt;/strong&gt; (to write keys to the card).&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Keep only &lt;strong&gt;one&lt;/strong&gt; YubiKey inserted at a time.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  1) Load the subkeys onto the primary YubiKey
&lt;/h3&gt;

&lt;p&gt;Insert the primary YubiKey, then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gpg &lt;span class="nt"&gt;--status-fd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2 &lt;span class="nt"&gt;--command-fd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0 &lt;span class="nt"&gt;--expert&lt;/span&gt; &lt;span class="nt"&gt;--edit-key&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$KEYID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
key 1
keytocard
1
save
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gpg &lt;span class="nt"&gt;--status-fd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2 &lt;span class="nt"&gt;--command-fd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0 &lt;span class="nt"&gt;--expert&lt;/span&gt; &lt;span class="nt"&gt;--edit-key&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$KEYID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
key 2
keytocard
2
save
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gpg &lt;span class="nt"&gt;--status-fd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2 &lt;span class="nt"&gt;--command-fd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0 &lt;span class="nt"&gt;--expert&lt;/span&gt; &lt;span class="nt"&gt;--edit-key&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$KEYID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
key 3
keytocard
3
save
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;During these steps GnuPG will prompt for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;your &lt;strong&gt;master (Certify) passphrase&lt;/strong&gt; (to unlock the secret subkeys on disk), and&lt;/li&gt;
&lt;li&gt;the YubiKey &lt;strong&gt;Admin PIN&lt;/strong&gt; (to write into the OpenPGP applet).&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2) Verify subkeys are on the YubiKey
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gpg &lt;span class="nt"&gt;-K&lt;/span&gt; &lt;span class="nt"&gt;--keyid-format&lt;/span&gt; long
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each subkey should display as &lt;code&gt;ssb&amp;gt;&lt;/code&gt; (stored on smartcard), for example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sec   ed25519 YYYY-MM-DD [C]
uid   Your Name &amp;lt;you@example.com&amp;gt;
ssb&amp;gt;  ed25519 YYYY-MM-DD [S] [expires: …]
ssb&amp;gt;  cv25519 YYYY-MM-DD [E] [expires: …]
ssb&amp;gt;  ed25519 YYYY-MM-DD [A] [expires: …]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Additionally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gpg &lt;span class="nt"&gt;--card-status&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Should list the Signature/Encryption/Authentication slots and the touch policies you set earlier.&lt;/p&gt;

&lt;h3&gt;
  
  
  3) (If you have a spare) Re-import and load to the second YubiKey
&lt;/h3&gt;

&lt;p&gt;Because moving subkeys to the first YubiKey turned local copies into stubs, we’ll clear any existing secret entries and re-import the &lt;strong&gt;pre-move&lt;/strong&gt; secret-subkeys backup.&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;# Ensure you’re in the same GNUPGHOME you used for the guide&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GNUPGHOME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# Remove current secret entries (drops stubs)&lt;/span&gt;
gpg &lt;span class="nt"&gt;--delete-secret-keys&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$KEYID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# Re-import secret subkeys&lt;/span&gt;
gpg &lt;span class="nt"&gt;--import&lt;/span&gt; gnupg-backups/&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;KEYID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="nt"&gt;-subkeys-&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;.key

&lt;span class="c"&gt;# Sanity check — these must be plain `ssb` (not `ssb#` or `ssb&amp;gt;`)&lt;/span&gt;
gpg &lt;span class="nt"&gt;-K&lt;/span&gt; &lt;span class="nt"&gt;--keyid-format&lt;/span&gt; long
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Insert the &lt;strong&gt;spare&lt;/strong&gt; YubiKey (only this one inserted), then run the same transfer flow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gpg &lt;span class="nt"&gt;--status-fd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2 &lt;span class="nt"&gt;--command-fd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0 &lt;span class="nt"&gt;--expert&lt;/span&gt; &lt;span class="nt"&gt;--edit-key&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$KEYID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
key 1
keytocard
1
save
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gpg &lt;span class="nt"&gt;--status-fd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2 &lt;span class="nt"&gt;--command-fd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0 &lt;span class="nt"&gt;--expert&lt;/span&gt; &lt;span class="nt"&gt;--edit-key&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$KEYID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
key 2
keytocard
2
save
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gpg &lt;span class="nt"&gt;--status-fd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2 &lt;span class="nt"&gt;--command-fd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0 &lt;span class="nt"&gt;--expert&lt;/span&gt; &lt;span class="nt"&gt;--edit-key&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$KEYID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
key 3
keytocard
3
save
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;Pinentry will prompt for your &lt;strong&gt;master (Certify) passphrase&lt;/strong&gt; and the &lt;strong&gt;Admin PIN&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  4) Verify:
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gpg &lt;span class="nt"&gt;--card-status&lt;/span&gt;
gpg &lt;span class="nt"&gt;-K&lt;/span&gt; &lt;span class="nt"&gt;--keyid-format&lt;/span&gt; long
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see &lt;code&gt;ssb&amp;gt;&lt;/code&gt; for all three subkeys again, now on the &lt;strong&gt;spare&lt;/strong&gt; as well.&lt;/p&gt;

&lt;p&gt;Done! Both YubiKeys now hold the same &lt;strong&gt;Sign/Encrypt/Auth&lt;/strong&gt; subkeys.&lt;/p&gt;




&lt;h1&gt;
  
  
  Post-Setup
&lt;/h1&gt;

&lt;h2&gt;
  
  
  Publish your public key (keys.openpgp.org) and link it to your YubiKey
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why?&lt;/strong&gt;&lt;br&gt;
Publishing the public key makes it easy for others (and for your future machines) to discover it.&lt;/p&gt;
&lt;h3&gt;
  
  
  Upload
&lt;/h3&gt;

&lt;p&gt;1) Go to &lt;strong&gt;&lt;a href="https://keys.openpgp.org" rel="noopener noreferrer"&gt;keys.openpgp.org&lt;/a&gt; → Upload Key&lt;/strong&gt;.&lt;br&gt;
2) Export and upload your public key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   gpg &lt;span class="nt"&gt;--armor&lt;/span&gt; &lt;span class="nt"&gt;--export&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$KEYID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; pubkey-&lt;span class="nv"&gt;$KEYID&lt;/span&gt;.asc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;3) Click &lt;strong&gt;Send verification email&lt;/strong&gt;.&lt;br&gt;
4) Check the inbox for the email on your key and click the verification link.&lt;/p&gt;
&lt;h3&gt;
  
  
  Put the public key URL on your YubiKey
&lt;/h3&gt;

&lt;p&gt;We’ll store a permalink to your key on the card so any machine can fetch it with one command.&lt;/p&gt;
&lt;h4&gt;
  
  
  1) Get your &lt;strong&gt;full fingerprint&lt;/strong&gt; and build the permalink:
&lt;/h4&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;FPR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;gpg &lt;span class="nt"&gt;--with-colons&lt;/span&gt; &lt;span class="nt"&gt;--fingerprint&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$KEYID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt;: &lt;span class="s1"&gt;'/^fpr:/ {print $10; exit}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Fingerprint: &lt;/span&gt;&lt;span class="nv"&gt;$FPR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Permalink  : https://keys.openpgp.org/vks/v1/by-fingerprint/&lt;/span&gt;&lt;span class="nv"&gt;$FPR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h4&gt;
  
  
  2) Write the URL into the card:
&lt;/h4&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gpg &lt;span class="nt"&gt;--card-edit&lt;/span&gt;
gpg/card&amp;gt; admin
gpg/card&amp;gt; url
https://keys.openpgp.org/vks/v1/by-fingerprint/&lt;span class="nv"&gt;$FPR&lt;/span&gt;
gpg/card&amp;gt; quit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;blockquote&gt;
&lt;p&gt;Do this for &lt;strong&gt;each YubiKey&lt;/strong&gt; you use (primary and spare).&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;
  
  
  On a new machine: fetch straight from the card
&lt;/h3&gt;

&lt;p&gt;Insert the YubiKey and run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gpg &lt;span class="nt"&gt;--card-edit&lt;/span&gt;
gpg/card&amp;gt; admin
gpg/card&amp;gt; fetch
gpg/card&amp;gt; quit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This pulls your public key from the URL stored on the card and imports it into the new machine’s keyring.&lt;br&gt;
You can verify with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gpg &lt;span class="nt"&gt;--card-status&lt;/span&gt;
gpg &lt;span class="nt"&gt;-K&lt;/span&gt; &lt;span class="nt"&gt;--keyid-format&lt;/span&gt; long
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  GitHub: sign commits with your YubiKey subkey
&lt;/h2&gt;

&lt;p&gt;We’ll upload your public key to GitHub and configure Git to sign with the &lt;strong&gt;Sign&lt;/strong&gt; subkey that lives on your YubiKey.&lt;/p&gt;

&lt;h3&gt;
  
  
  1) Get the Sign subkey’s long ID (automatically)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Prints the first subkey with [S] capability (long 0x… ID)&lt;/span&gt;
&lt;span class="nv"&gt;SUBKEY_SIGN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;
  gpg &lt;span class="nt"&gt;-K&lt;/span&gt; &lt;span class="nt"&gt;--with-colons&lt;/span&gt; &lt;span class="nt"&gt;--keyid-format&lt;/span&gt; 0xlong &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$KEYID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; |
  &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt;: &lt;span class="s1"&gt;'/^ssb/ &amp;amp;&amp;amp; $12 ~ /s/ {print $5; exit}'&lt;/span&gt;
&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Signing subkey: &lt;/span&gt;&lt;span class="nv"&gt;$SUBKEY_SIGN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2) Add your public key to GitHub
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Export your public key and copy it:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   gpg &lt;span class="nt"&gt;--armor&lt;/span&gt; &lt;span class="nt"&gt;--export&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$KEYID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | pbcopy   &lt;span class="c"&gt;# macOS; use xclip/clipboard on Linux&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; → &lt;strong&gt;Settings&lt;/strong&gt; → &lt;strong&gt;SSH and GPG keys&lt;/strong&gt; → &lt;strong&gt;New GPG key&lt;/strong&gt; → paste → &lt;strong&gt;Add GPG key&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;Your commit email must match a &lt;strong&gt;verified&lt;/strong&gt; email on GitHub.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  3) Configure Git to use that subkey
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git config &lt;span class="nt"&gt;--global&lt;/span&gt; gpg.program gpg
git config &lt;span class="nt"&gt;--global&lt;/span&gt; gpg.format openpgp
git config &lt;span class="nt"&gt;--global&lt;/span&gt; user.signingkey &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SUBKEY_SIGN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
git config &lt;span class="nt"&gt;--global&lt;/span&gt; user.email &lt;span class="s2"&gt;"yubikey@example.com"&lt;/span&gt;  &lt;span class="c"&gt;# must be a verified GitHub email&lt;/span&gt;
git config &lt;span class="nt"&gt;--global&lt;/span&gt; commit.gpgsign &lt;span class="nb"&gt;true
&lt;/span&gt;git config &lt;span class="nt"&gt;--global&lt;/span&gt; tag.gpgSign &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>yubikey</category>
      <category>pgp</category>
      <category>gpg</category>
      <category>gnupg</category>
    </item>
  </channel>
</rss>
