<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Stories by Nathan Pavlovsky on Medium]]></title>
        <description><![CDATA[Stories by Nathan Pavlovsky on Medium]]></description>
        <link>https://medium.com/@secnate?source=rss-f95ccfcb96af------2</link>
        <image>
            <url>https://cdn-images-1.medium.com/fit/c/150/150/1*NLntUWWe_vFXT_ftupbywQ.jpeg</url>
            <title>Stories by Nathan Pavlovsky on Medium</title>
            <link>https://medium.com/@secnate?source=rss-f95ccfcb96af------2</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Sun, 15 Nov 2026 13:23:12 GMT</lastBuildDate>
        <atom:link href="https://medium.com/@secnate/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com/" rel="hub"/>
        <item>
            <title><![CDATA[Homelab #18: Automated Backup & Disaster Recovery]]></title>
            <link>https://secnate.medium.com/homelab-18-automated-backup-disaster-recovery-e00d648c169f?source=rss-f95ccfcb96af------2</link>
            <guid isPermaLink="false">https://medium.com/p/e00d648c169f</guid>
            <dc:creator><![CDATA[Nathan Pavlovsky]]></dc:creator>
            <pubDate>Mon, 23 Mar 2026 01:11:47 GMT</pubDate>
            <atom:updated>2026-03-23T10:38:46.318Z</atom:updated>
            <content:encoded><![CDATA[<p>Picture this: it’s 3AM, your phone is blowing up, and the VM hosting your critical services has just died. You go to restore from backup — and realize you either have no backups, or you have backups you have never once tested. Either way, you are in the same situation: completely stuck. This is the backup problem.</p><p>In this lab — building directly on the work I did in Homelab #17 — I closed that gap by implementing a production-grade automated backup and disaster recovery pipeline using restic, AWS S3, and Terraform, and systemd-based automation. A backup you have never restored is not a backup. It is a hypothesis — and a very risky one to boot.</p><p>A quick overview of the tools involved on top of the previous lab’s used Proxmox, HAProxy, and Keepalived:</p><ul><li><strong>restic</strong> is a modern backup tool that handles deduplication, compression, and client-side encryption in a single binary. Every snapshot is encrypted before it leaves your machine — AWS cannot read your data. Only you can, with the password you hold.</li><li><strong>AWS S3</strong> is the offsite backup destination. With versioning, cross-region replication, and <strong>S3 Object Lock</strong> enforcing write-once immutability, even ransomware operating with stolen AWS credentials cannot delete a protected backup during its retention window.</li><li><strong>Terraform</strong> defines the entire AWS infrastructure — S3 buckets, IAM user, replication rules, bucket policies — as code. The whole setup is reproducible from scratch with a single <strong>terraform apply</strong></li><li><strong>systemd timers</strong> handle backup scheduling. Unlike cron, they integrate with <strong>journalctl </strong>for persistent logging and handle missed runs automatically via <strong>Persistent=true</strong></li></ul><p>For those interested, here is a “brief” diagram of what this homelab was to look like:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/953/1*l062kuV88wa4cVEVfHHxDg.png" /></figure><p>Onwards, to glory!</p><p><strong>Section I: Terraforming the AWS Infrastructure</strong></p><p>As systems theorist and pediatrician John Gall put it in his 1975 classic Systemantics, <em>“A complex system that works is invariably found to have evolved from a simple system that worked. The inverse proposition also appears to be true: A complex system designed from scratch never works and cannot be made to work.” </em>Or as the legendary homelabber Nathan Pavlovsky put it, “How do you eat an elephant? One bite at a time.”</p><p>This is precisely what I needed to do with Terraform: to build things one part at a time. While I could have gone the “naive” route of having AI generate the code and just copying &amp; pasting the entire setup and sloughing through debuggings, that would have done nothing for the learning process. So I went manually through the steps of creating the Terraform code, step by step. The steps were as follows:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/943/1*J3AanfTO_qrH_JZi7CZCMg.png" /></figure><p><strong>Terraform Step #1: Initial File Setup</strong></p><p>I got started by going into the <a href="https://github.com/secnate/HomelabSeriesGithubRepo/tree/main/Homelab12%20-%20CI%20CD%20Pipeline/terraform/aws">AWS Terraform folder for Homelab #12</a> and copying and pasting the basic file structure of three Terraform files into my folder for this homelab. Specifically, I created the <strong>main.tf</strong>, <strong>output.tf</strong>, and <strong>variables.tf</strong> files. After reading the example files from Homelab #12 (which I was using as an example), I got to work by deleting unnecessary lines of text and making my own modifications. At this point in time, I had the following:</p><ul><li><strong>variables.tf:</strong></li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/584/1*DA0Igo_MrPybbh_5u3GxIA.png" /></figure><ul><li><strong>output.tf:</strong></li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/518/1*MCd9Z8TlZ3kaKfIjaZAgTQ.png" /></figure><ul><li><strong>main.tf:</strong></li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/712/1*I35gnWiTBn8AbfsdD9UuxQ.png" /></figure><p>For the screenshot with the <strong>main.tf </strong>file, notice that I created a **second** <a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs">AWS provider</a> with an alias. <a href="https://www.env0.com/blog/how-to-use-terraform-providers">This is necessary to be able to work with multiple regions simultaneously in AWS</a><strong>.</strong></p><p>Wonderful. From there, I went through the standard Terraform deployment process with the <strong>terraform init</strong>, <strong>terraform plan</strong>, and <strong>terraform apply </strong>commands to confirm that the code was good at this time.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*nxMTmaSedUz8RxUAr_Xopg.png" /></figure><p>It was, and because there were no cloud resources specified, I didn’t create nor destroy any.</p><p><strong>Terraform Step #2: Create Primary S3 Bucket</strong></p><p>I then needed to create my initial S3 bucket for backup storage. For those who don’t know what it is, S3 is AWS’s object storage service that can store basically any type of digital file — as long as it is within a — welp — <a href="https://aws.amazon.com/about-aws/whats-new/2025/12/amazon-s3-maximum-object-size-50-tb/">50 TB limit</a>.</p><p>I did this by adding the following lines to the Terraform files:</p><ul><li><strong>variables.tf:</strong></li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/460/1*quw9avYjYpKPGjLxoxSEwA.png" /></figure><ul><li><strong>main.tf:</strong></li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*tNf2WjOtmM0iTwDubNZNGw.png" /></figure><p>Let’s digest what is going on. In <strong>variables.tf</strong>, I added in a variable that would name the Primary S3 bucket. In <strong>main.tf</strong>, I created and configured the bucket using the following Terraform resources:</p><ul><li><a href="https://registry.terraform.io/providers/hashicorp/aws/4.6.0/docs/resources/s3_bucket"><strong>aws_s3_bucket</strong></a><strong> </strong>— It reserves the bucket name in AWS and establishes the resource that everything else attaches to. Nothing is stored in it yet and none of the safety features are active; it is just an empty vessel</li><li><a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket_versioning"><strong>aws_s3_bucket_versioning</strong></a> — Versioning<a href="https://objectfirst.com/blog/how-object-first-uses-the-s3-versioning-and-object-lock/"> tells S3 to keep every historical version of every object rather than overwriting it on each write</a>. <a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lock.html">This is a critical prerequisite for Object Lock</a> — which we will see in a later Terraform step — because it works by locking specific object versions, not object names. Without versioning enabled first, the Object Lock configuration will fail to apply.</li><li><a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket_server_side_encryption_configuration"><strong>aws_s3_bucket_server_side_encryption_configuration</strong></a> — This tells AWS to automatically encrypt every object stored in the bucket at rest using AES-256. We will see later that <a href="https://oneuptime.com/blog/post/2026-01-25-restic-application-backup/view"><strong>restic </strong>already encrypts backup data client-side before it leaves a machine for a backup server</a>, so this a second layer of encryption protecting against anyone who gains raw access to S3 storage infrastructure rather than going through the API.</li><li><a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket_public_access_block"><strong>aws_s3_bucket_public_access_block</strong></a><strong> </strong>— By default, S3 buckets can be made public if someone adds the wrong policy or ACL — a mistake that has exposed sensitive data for countless organizations. This resource sets four flags that collectively make it impossible for the bucket to ever be made public, even if someone later makes configuration or policy changes deliberately or accidentally</li></ul><p>After doing some testing and tweaks with repeated rounds of <strong>terraform plan </strong>and <strong>terraform apply</strong>, I got the following error:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*p3SBFs8Bacfuyz4OHKBXZw.png" /></figure><p>My Administrator user account is the identity Terraform ran as when it called the AWS API to build infrastructure — and the error indicates that it needs broad permissions to create and configure S3 resources. To prevent AWS from refusing every S3 API call that Terraform would make in this and future homelab sections, I got to work in the AWS Web Console by opening <em>IAM &gt;&gt; Users &gt;&gt; Administrator</em> and then going through the steps to grant it the <strong>AmazonS3FullAccess</strong> permissions.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ZilMZKqlpGI2-HFdWCW1WA.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*5dB52eOFdl2lV9KoGn6_bA.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*7_mjNzxqCW3l4fx0Da5udQ.png" /></figure><p>Epic. A subsequent round of <strong>terraform plan </strong>and <strong>terraform apply</strong> resulted in the S3 bucket being created:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*VC7wYl2F9sahhKE3JQvt8Q.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*axT4DRBdBXbi_F0xyf9wkQ.png" /></figure><p>Wonderful.</p><p><strong>Terraform Step #3: S3 Bucket Object Lock</strong></p><p>I then needed to ensure that once a backup object is written, nothing can delete or overwrite it until the retention period expires, including ransomware operating with stolen AWS credentials or an accidental <strong>aws s3 rm</strong> command. For that to happen, I needed to create an <a href="https://registry.terraform.io/providers/-/aws/latest/docs/resources/s3_bucket_object_lock_configuration"><strong>S3 Bucket Object Lock configuration</strong></a><strong>.</strong></p><p>I went with <strong>Governance mode</strong> rather than Compliance mode because [<a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lock.html">per documentation</a>] Governance still allows an administrator with the right IAM permission to override the lock in an emergency — for example, if I misconfigured a retention period and needed to clean up — while <strong>Compliance </strong>mode is truly permanent and irreversible. This minimizes the risk of costly mistakes very costly in the context of my homelab. For the retention period I chose 30 days — long enough that even if ransomware quietly encrypted my source data for weeks before being discovered, I would still have clean, uncompromised backups available to restore from.</p><p>It is also important to note that <a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lock.html">per AWS’ documentation</a>, “Object Lock works only in buckets that have S3 Versioning enabled.” It was thus important to have my Terraform code use the <strong>depends_on </strong>keyword to ensure that Terraform waited until the <strong>aws_s3_bucket_versioning.primary</strong> resource was fully created before attempting to create the S3 Bucket Object Lock.</p><p>That was a lot of theory/explanation. I did this by adding the following lines to the Terraform files:</p><ul><li><strong>variables.tf:</strong></li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/464/1*WkfgVLnuTcJe_4IBkO-0WA.png" /></figure><ul><li><strong>main.tf:</strong></li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/514/1*U4qSV6QM75dpjqcI-3JR4Q.png" /></figure><p>A subsequent round of <strong>terraform plan </strong>and <strong>terraform apply</strong> confirmed that the code was working without any errors.</p><p><strong>Terraform Step #4: Creating DR Bucket In Second Region</strong></p><p>Next up was to create an S3 bucket for disaster recovery in a second region. In this case, it is to be in the <strong>us-east-2 </strong>region. The Terraform code update looks like the following</p><ul><li><strong>variables.tf:</strong></li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/527/1*w4FkWg_mpenEm-MD5VY2_A.png" /></figure><ul><li><strong>main.tf:</strong></li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/922/1*hXdbCnZYpGGvO8LbQsrbTQ.png" /></figure><p>Notice the red boxes highlighting the line <strong>provider = aws.Disaster_Recovery_region</strong>. This line is <strong>critical </strong>because<strong> </strong>it ensures that the S3 bucket gets created in the disaster recovery region. Otherwise, it would get created in the same region as the first S3 bucket.</p><p>A subsequent round of <strong>terraform plan </strong>and <strong>terraform apply</strong> confirmed that the secondary bucket was properly created:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*T2u7F7XAtvAL_kaIk9PA6g.png" /></figure><p><strong>Terraform Step #5: Cross-Region Replication</strong></p><p>With the two buckets made, I needed to ensure that every object written to the primary S3 bucket gets automatically copied to the DR bucket asynchronously, without any script or chron job involved. <br>To make that happen, I based the next steps <a href="https://oneuptime.com/blog/post/2026-02-23-create-s3-bucket-replication-in-terraform/view">off a nice blog post’s</a> <em>Replication with Encryption</em> section and added the following three resources to <strong>main.tf</strong>:</p><ul><li><a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role"><strong>aws_iam_role</strong></a><strong> </strong>— S3 cannot replicate objects on its own; it needs an identity to act as when copying objects between buckets. This resource creates a dedicated IAM role that S3 is allowed to assume specifically for that purpose.</li><li><a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy"><strong>aws_iam_role_policy</strong></a><strong> </strong>— The role alone grants nothing. This policy, attached to the role above, gives S3 read permissions on the primary bucket and write permissions on the DR bucket — and nothing broader than that.</li><li><a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket_replication_configuration"><strong>aws_s3_bucket_replication_configuration</strong></a><strong> </strong>— This is the actual replication rule, wired to the primary bucket. It tells S3 to use the role above and copy every object written to the primary bucket into the DR bucket. <br>Notice that it has a <strong>depends_on</strong> statement for the two buckets’ versioning resources: versioning has to be active on both buckets before AWS will accept the configuration.</li></ul><p>The code looked like this:</p><ul><li><strong>main.tf</strong></li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/657/1*jPm7i2JrNcW9vyHr1G0viA.png" /></figure><p>After that, I used the <strong>terraform plan </strong>and <strong>terraform apply </strong>commands to test it, and got an error</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*HT155RzqEQDHRo2RYMdTow.png" /></figure><p>Looks like my <strong>Administrator</strong> IAM user was missing IAM permissions. Getting those added was a quick fix in the AWS console</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*2BCrqGUBzyl0Yk-dKN-R8w.png" /></figure><p>Then I used <strong>terraform plan </strong>and <strong>terraform apply</strong> again — and confirmed that the buckets and the replica rules were made:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*DQQn_TUjZINcnLveKtwLWA.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*1vx4gfe41g6CNFD8vphetQ.png" /></figure><p><strong>Terraform Step #7: Dedicated Account For Restic Backups</strong></p><p>I then needed to take one last step in Terraform: creating a dedicated user account in AWS that <strong>restic </strong>would authenticate to when pushing encrypted backups to the S3 buckets. This is both per the <a href="https://restic.readthedocs.io/en/v0.12.1/080_examples.html">Restic documentation’s tutorial</a> (which does not use Terraform) and common sense — we need least privilege account access for security reasons. We do not want to use the AWS <strong>Administrator </strong>account’s credentials — if the credentials for the dedicated <strong>restic </strong>account were ever stolen or leaked, an attacker could only read and write to that one bucket and do nothing else in the AWS account. This is the difference between a compromised backup credential being a minor inconvenience versus a catastrophic account-wide breach.</p><p>The following section of the Restic documentation/tutorial provided the two permissions statements that I needed to create on the S3 bucket and the contents:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/706/1*mL5QaxDX9e9l_-7APIN-eA.png" /></figure><p>Time to plunge into Terraform. I made the following changes:</p><ul><li><strong>output.tf</strong></li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/720/1*ml9bipvjGXT5_kteyXJ6rA.png" /></figure><ul><li><strong>main.tf</strong></li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/549/1*EoDODRk-00Lb4Hbq2tk7Yw.png" /></figure><p>Notice the red-boxed text in the <strong>main.tf </strong>file. The two lines together cover two different levels of S3 permissions that AWS treats as separate things.<strong> aws_s3_bucket.primary.arn</strong> refers to the primary bucket itself — this is required for actions that operate on the primary bucket as a whole. <strong>“${aws_s3_bucket.primary.arn}/*”</strong> refers to every object inside it. It’s very important to have both because an S3 bucket and its contents are considered to be distinct resources in IAM’s permission model.</p><p>Another round of <strong>terraform plan </strong>and <strong>terraform apply</strong> resulted in the <strong>restic-backup-agent</strong> being created with the right permissions</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*DPbQgpn6aApGYbCU2_vkcA.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Yj-gZ_P1ZKzREE_d72OtHQ.png" /></figure><p>With that, the Terraform section of this lab was completed!</p><p><strong>Section II: restic Installation, Repository Init, &amp; First Backup<br></strong>I opened up the Proxmox setup from <a href="https://secnate.medium.com/homelab-17-linux-high-availability-cluster-with-haproxy-and-keepalived-b94feff6864c">Homelab #17</a> and installed <strong>restic </strong>on the three boxes (<strong>haproxy1</strong>, <strong>haproxy2</strong>, <strong>web1</strong>) using the <strong>sudo apt install restic</strong> command. I then did the following the next steps off <a href="https://restic.readthedocs.io/en/stable/080_examples.html#initializing-the-restic-repository">this</a> and <a href="https://restic.readthedocs.io/en/stable/080_examples.html#full-backup-without-root">this</a><strong> restic </strong>documentation page.</p><p>First, on all three VMs, I created a new user called <strong>restic</strong> that is going to create the backups using the <strong>useradd — system — create-home — shell /sbin/nologin restic</strong> command</p><p><strong>Screenshot From The web1 VM</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/736/1*ceo7xyNQadDgi3QsbAA9sQ.png" /></figure><p>Then I needed to give <strong>restic </strong>the ability to read <strong>every file on the system</strong> — including files owned by other users and <strong>root </strong>— **<strong>without** </strong>running as full root itself. By default, a non-root user like <strong>restic </strong>cannot read files it does not own, which would mean critical config files in <em>/etc</em> or application data in <em>/var </em>would simply be skipped during backup. Linux solves this through a feature called capabilities — a way of granting a binary one specific elevated privilege rather than handing it full root power.</p><p>Fortunately, there is a solution. Linux has a concept called <a href="https://man7.org/linux/man-pages/man7/capabilities.7.html">capabilities</a>. They are a security feature that break down the all-powerful <strong>root </strong>privilege into smaller, distinct units, allowing specific administrative permissions (like binding privileged ports or bypassing file read/write checks) to be granted to processes or binaries without blanket root access being granted.</p><p>Restic needs the <strong>cap_dac_read_search</strong> capability, which allows a file or account to read ANY file on the filesystem, which includes all files owned by root. However, Linux only applies a capability to the exact binary file it was set on, so instead of modifying the system-wide <strong>/usr/bin/restic</strong> binary (which would grant that capability to anyone on the system who runs it), I needed to create a private copy of the binary inside the <strong>restic </strong>user’s home directory and apply the capability there instead. This would be in line with the security principle of least privilege: only the <strong>restic </strong>user would be able to execute a capability-enabled copy of <strong>restic</strong> to back up the server’s files.</p><p><strong>Screenshot From web1 VM</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/565/1*mloUlqGBlcgGzq-vZ2mgnQ.png" /></figure><p>Notice that there was only one screenshot for brevity’s sake. These steps were derived <a href="https://restic.readthedocs.io/en/stable/080_examples.html#full-backup-without-root">from a restic documentation page</a> and I also did the same on the <strong>haproxy1 </strong>and <strong>haproxy2 </strong>VMs too.</p><p>The next step was to make the password to encrypt the backups with. On each VM I first installed the <a href="https://linux.die.net/man/1/apg">apg</a> Linux utility using the <strong>sudo apt install apg </strong>command, this would be the password for each of my VMs.</p><p><strong>Screenshot From web1 VM</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*zVHZWySuBtjmWC6Eqk_raw.png" /></figure><p>After that, I needed to create the environmental variables on each VM per the <a href="https://linux.die.net/man/1/apg">restic tutorial</a>. Notice that these included the generated restic passwords, information about the primary backup region in AWS, and the AWS-related credentials. Because this information is sensitive, it was imperative that the command <strong>did not end up in the shell’s history file</strong>. To do that, I used the <a href="https://www.cyberciti.biz/faq/disable-bash-shell-history-linux/"><strong>unset HISTFILE</strong></a><strong> </strong>command. The variables were titled <strong>AWS_DEFAULT_REGION</strong>, <strong>RESTIC_REPOSITORY</strong>, <strong>AWS_ACCESS_KEY_ID</strong>, <strong>AWS_SECRET_ACCESS_KEY</strong>, and <strong>RESTIC_PASSWORD</strong>.</p><p><strong>Screenshot From web1 VM</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*W8P075anBw4e6Qyfskqh3w.png" /></figure><p>Notice that this was done on the <strong>haproxy1 </strong>and <strong>haproxy2 </strong>VMs as well.<br>I then tried to create the repository with the <strong>restic init </strong>command — and immediately got a fatal error: <strong>client.PutObject: Content-MD5 OR x-amz-checksum- HTTP header is required for Put Object requests with Object Lock parameters</strong>. This happened because the <strong>apt</strong>-installed version of restic (0.12.1 on Ubuntu) did not compute and send a checksum header when writing to S3, which AWS strictly requires when a bucket has Object Lock enabled, as documented in<a href="https://github.com/restic/restic/issues/2202"> this long-standing GitHub issue</a> that dates back to 2019. The fix of calculating content hashes during S3 uploads was introduced in<a href="https://github.com/restic/restic/pull/3246"> PR #3246</a>.</p><p>I thus needed to update <strong>restic </strong>to the latest version and then repeat the steps for copying the binary from <strong>/usr/bin/restic </strong>into the <strong>/home/restic/bin </strong>folder and configuring the appropriate permissions</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/560/1*tYApFJPlJBWazTAEN3PAmA.png" /></figure><p>I then used the <strong>restic init</strong> command on one machine to create the repository in the S3 primary bucket.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*xhKWRCGQfCAQWYCidwLMjw.png" /></figure><p>And then confirmed that all three machines could access it</p><p><strong>Screenshot From haproxy1 VM</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/467/1*NLXuRQyNgqoEuRdMJl8Wwg.png" /></figure><p><strong>Screenshot From haproxy2 VM</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/458/1*BqkCDJdTGv3cJrVUZAX5hw.png" /></figure><p><strong>Screenshot From web1 VM</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/460/1*CPwVBu_AhnJYqknQjsjB8g.png" /></figure><p>The next step was to create a sample initial backup. I followed the steps in <a href="https://restic.readthedocs.io/en/stable/080_examples.html#initializing-the-restic-repository">the tutorial</a> and generated a <strong>test.bin </strong>binary file with the <strong>dd if=/dev/urandom bs=1M count=10 of=test.bin</strong> command and backed it up with the <strong>restic backup test.bin</strong> invocation.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*SlcnxsSejnGbn_U14y4ICw.png" /></figure><p>Going into my primary bin in the AWS S3 console, we see the new folders called <strong>data/</strong>, <strong>index/</strong>, and <strong>snapshots/</strong> were created on top of the previously-existing <strong>config</strong> file and <strong>keys/</strong> folder — and that the first snapshot was made</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*dXR8f32kdL2ilo0Pogq55Q.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*fMmW0Atky1mN3-64rTKVjw.png" /></figure><p>I then did a little test where I deleted the <strong>test.bin</strong> file locally and then restored it with the <strong>restic restore c72a8b6f </strong>command (where <strong>c72a8b6f </strong>is the string that identified the snapshot) — and proved that the <strong>test.bin </strong>file was there with the <strong>ls </strong>command:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/862/1*_bBHIwbU0e7LiB2XwS3TIw.png" /></figure><p><strong>Section III: Making The Credentials More Long-Term</strong></p><p>I have been working on this homelab over the course of multiple days. Being a pragmatist, I didn’t want to have to retype and troubleshoot the environmental variables that were critical to working with <strong>restic</strong> on each endpoint each time my VMs got rebooted for whatever reason.</p><p>The solution was to create a file where I could have all the necessary commands at the ready. Then I could use the <a href="https://www.geeksforgeeks.org/linux-unix/source-command-in-linux-with-examples/"><strong>source</strong></a> Linux command to take the file that I needed to make and to treat its contents as a set of commands to execute. These commands would prepare the environmental variables that I needed to get working.</p><p>To get started, I needed to first get the ID and password of the <strong>restic-backup-agent</strong> account that I created in Terraform when spinning up the buckets. I did this with the <strong>terraform output -raw iam_access_key</strong> and <strong>terraform output -raw iam_secret_key</strong> commands</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*guQWMNToIX5_GIgetET4Wg.png" /></figure><p>Then I went into my <strong>haproxy1</strong> box and created the <strong>restic-env </strong>file as <strong>**root**</strong> using the <strong>sudo nano /etc/restic-env</strong> command</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ulaRC8sLS-XYYXcU9htXLA.png" /></figure><p>I then needed to lock down the permissions to it. I did this with the <strong>sudo chown root:root /etc/restic-env </strong>command to set the file’s owner and group to root so that no other user account could accidentally stumble upon it, while <strong>sudo chmod 600 /etc/restic-env</strong> restricted permissions to read/write for root only.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*lV4UoSUqBXsGAgvXOmB7Bg.png" /></figure><p>This ensured that only I as the authorized root user (with the appropriate privilege escalation with <strong>sudo</strong>) would be able to access the file — and that no other account could.</p><p>I then wanted to save myself some time, so instead of doing the same steps manually on the <strong>haproxy2 </strong>and <strong>web1 </strong>VM, I used the <strong>sudo scp /etc/restic-env nathan@192.168.253.102:/tmp/restic-env </strong>and <strong>sudo scp /etc/restic-env nathan@192.168.253.103:/tmp/restic-env</strong> commands.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*l8-1ThvDqB7oCyocPLADqA.png" /></figure><p>The reason the file was copied into<strong> /tmp</strong> first rather than directly into<strong> /etc</strong> is that <strong>scp </strong>runs as the regular <strong>nathan </strong>user, who does not have write access to <strong>/etc</strong>. <strong>/tmp</strong> serves as an authorized public staging area because Linux designates it as <a href="https://www.baeldung.com/linux/fully-open-directories">world-writable</a>, meaning<a href="https://docs.datadoghq.com/security/default_rules/def-000-re3/"> any user on the system can create files there</a>.</p><p>A subsequent sudo mv then promoted the file into its final home in /etc with the correct ownership and permissions applied immediately after</p><p><strong>On haproxy2</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*UxBYIYXsSDEaO1cKNEodcA.png" /></figure><p><strong>On web1</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*72sYUiwo4Kny39KgAZpLfw.png" /></figure><p>The main groundwork has thus been laid. Now each time I needed to run a <strong>restic </strong>command, I could lay the groundwork by invoking the <strong>source /etc/restic-env</strong> command to get the environment variables properly configured. See for example this command where I did this on the <strong>web1 </strong>VM.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*RoGX8E-MlumXDm81-hK26A.png" /></figure><p>Perfect.</p><p><strong>Section IV: Cross Region Replication</strong></p><p>I then opened up the secondary bucket in the AWS S3 website console and got a little unpleasant surprise. The data from the initial snapshot and all subsequent ones were not fully replicated to the secondary S3 bucket. This was a problem that needed to be fixed. Getting cross-region replication working required multiple rounds of debugging and iterative fixes.</p><p>I tried a few things in Terraform and then after consulting with a friend decided to create a temporary testing third S3 bucket and use “clickops” (i.e. manually creating the rules in the AWS website console) to create a replication rule. If that worked, then I could see what was up with regards to it and compare that to my current Terraform.</p><p>I did precisely that — and the replication worked! Below is the screenshot from my primary bucket on which I created the manual replication rule. In the red box is the <strong>test-replication-rule</strong> that I manually created in the GUI.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/679/1*wWubd_dMMcWDWIVthmxT1g.png" /></figure><p>Notice that it had the <strong>Storage Class </strong>being <strong>Same as source </strong>instead of my other (Terraform-made replication rule) being <strong>Transition to Standard</strong>. It got me thinking that there was something with the Terraform code specifying the <strong>aws_s3_bucket_replication_configuration</strong> specifying the destination bucket being of a <strong>STANDARD</strong> storage class. I went in and started debugging and realized that the approach needed to be reworked</p><p>I then spoke back with my friend again and was told that it would be a better idea to just follow the Terraform documentation from the official <a href="https://registry.terraform.io/providers/-/aws/latest/docs/resources/s3_bucket_replication_configuration"><strong>Resource: aws_s3_bucket_replication_configuration</strong></a> configuration example for the one-way replication. I did exactly that with the <strong>Terraform AWS Provider v6 (and above)</strong> example and took advantage of the opportunity to update my AWS provider to version ~6 to keep it more up to date. I completely reworked the code for <strong>Step #5: Cross-Region Replication</strong> in line with the provided example. I should note that from the original example, I **removed** the <strong>aws_s3_bucket_acl resource </strong>which is no longer supported in modern AWS S3 buckets</p><p>Two of the most notable changes were for my <strong>primary_to_dr aws_s3_bucket_replication_configuration </strong>resource. These were as follows:</p><ol><li>Inserting a marker enabling soft deletes (very important due to this cloud infrastructure acting as a backup system — we want accidental deletes to not be fatal)</li><li>Adding in a <strong>filter {}</strong> block that was previously missing and <a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket_replication_configuration">required to exist per documentation</a>. This is a filter that allows for the rule to select a subset of objects to which the replication rule applies.</li></ol><p>Once that was fixed, replication <strong>was still failing</strong> because the buckets had Object Lock enabled in GOVERNANCE mode, which requires the replication IAM role to carry additional permissions beyond basic read/write shown in the example.</p><p>What made this particularly confusing was that querying the object metadata via the AWS CLI was reporting a replication status of COMPLETED — despite the objects never actually appearing in the secondary bucket.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/963/1*kEoop8hnsSPo2tJMnlC1hA.png" /></figure><p>After progressively broadening the IAM policy using wildcard permissions (<strong>s3:Get*</strong>,<strong> s3:Put*</strong>, <strong>s3:List*</strong>) to confirm the root cause was permissions rather than a misconfiguration elsewhere, the policy was then locked back during multiple rounds of testing to only the permissions that the replication in this setup actually required. The final permissions looked like this</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/519/1*0-2MfIHwwkcilQevE0acWw.png" /></figure><p>Testing with some sample <strong>restic backup &lt;FILE_NAME&gt; </strong>commands confirmed that the replication from the primary to secondary worked</p><p><strong>Doing The Backup On haproxy1 VM</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*8fcV9lzHkvh87LZ4jgYjfA.png" /></figure><p><strong>The Snapshot On The Primary S3 Bucket</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*DmQss9a8_8XyE02VQZFIyw.png" /></figure><p><strong>The Snapshot On The Backup S3 Bucket</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Oqk0FvFWecb_xLGp0WnMJA.png" /></figure><p>Notice the same hash corresponding to the same backed-up snapshot on both the primary and backup S3 buckets.</p><p><strong>Section V: Retention Policy</strong></p><p>With backups working, the next question is: how long do I keep them? Keeping every snapshot forever would balloon S3 costs over time. Removing some snapshots after a period also makes sense: there is no point in keeping older backups when newer ones are more than sufficient.</p><p>Because removing snapshots manually is tedious and error-prone, <strong>restic</strong> allows for a <strong>forget</strong> policy that automates this (check out the <a href="https://restic.readthedocs.io/en/latest/060_forget.html#removing-snapshots-according-to-a-policy">documentation</a>).</p><p>Since all three machines shared the same repository, I only needed to run the command on one machine for it to affect all three VMs.</p><p>I opened up the console for the <strong>web1 </strong>VM and used the <strong>restic forget –keep-monthly 1 –prune </strong>command.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*TfUSfz25vGsL5NtTkd7AEQ.png" /></figure><p>An explanation of the command’s flags:</p><ul><li><strong>— keep-monthly 1</strong> — This specifies that we keep <strong>the latest</strong> snapshot from each of the past 1 calendar month</li><li><strong>— prune </strong>— Immediately removes the underlying data blobs from the primary S3 backup bucket that are no longer referenced by any kept snapshot, freeing up storage rather than leaving orphaned data behind.</li></ul><p><strong>Section VI: Backup Hooks</strong></p><p>Next up was to learn the concept of backup hooks. Without them, a backup tool like restic captures files exactly as they exist on disk at the moment the backup runs. Having hooks is especially important for databases since they are constantly writing to their data files.</p><p>A database is a collection of data files, index files, and transaction logs that must all be consistent with each other to be valid — and if restic snapshots these files at slightly different moments during an active write, the backup may capture some files mid-transaction. This can potentially leave the data files and transaction logs out of sync in a way that makes a backup unrestorable. Hooks resolve this problem by running a database dump command before restic starts, which results in a clean export file that represents a guaranteed consistent point in time state of the database.</p><p>I got started by opening up the <strong>web1 </strong>VM and installing SQLite. It’s a good tool for us to work with in this homelab section since <a href="https://sqlite.org/onefile.html">it is a single file database</a> — perfect for this stage of this exercise.</p><p>I quickly installed it using the <strong>sudo apt install sqlite3 -y</strong> command and then created a sample database with some test data <a href="https://www.sqlitetutorial.net/sqlite-transaction/">per a SQLite tutorial</a> I found online.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*vfJ8Ln-9oqSFKQRQs0_Gvg.png" /></figure><p>I then quickly installed <strong>resticprofile </strong>per the instructions on the <a href="https://creativeprojects.github.io/resticprofile/installation/linux/index.html">documentation page</a> and confirmed it was working with the <strong>resticprofile version </strong>command</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/527/1*5VvncFTmL-t1rZLHZn21og.png" /></figure><p>The next thing I needed to do was to make a pre-backup hook script that would dump the SQLite database. To do this, I created a folder called <strong>scripts</strong> in which the hook script was to be stored and created a <strong>database_backups</strong> folder in which the backups would be placed. The final <strong>pre-backup.sh </strong>bash script looks like this</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/421/1*vpuNI7yiUijznYh1Jrfz1A.png" /></figure><p>Great. I then needed to make a configuration file that would run this script before the restic backup. To get started, I made a <strong>resticprofile_config </strong>folder in which I placed a newly-created <strong>profiles.yaml</strong> file. It looks like this:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*fvDzrmwWFdOreKs99FgW7g.png" /></figure><p>What it does is specify that the <strong>pre-backup.sh </strong>script will run to add in a new backup database file before the actual backup is performed. The backup will take all the files in the <strong>/home/nathan/database_backups </strong>folder — and back them up.</p><p>I then executed the <strong>source /etc/restic-env &amp;&amp; resticprofile backup </strong>command to get the environment variables loaded in and to run the hook + restic backup combination.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*OP9aCtx9zpa-XMX1meJS3A.png" /></figure><p>It worked!</p><p>Then I needed to test the restoration command. To do this, I first deleted every single <strong>.db </strong>backup file in the <strong>database_backups/ </strong>folder, and then used the <strong>source /etc/restic-env &amp;&amp; restic restore latest –target / –path “/home/nathan/database_backups” </strong>command</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*nwtFabJaFgvjHskqmrpLbQ.png" /></figure><p>It worked. To briefly explain the command used, the <strong>–target / </strong>indicates that the restoration process will be based at the root of the file system while the <strong>–path “/home/nathan/database_backups”</strong> ensured that only the files in that specific directory were restored. The combination of <strong>/ </strong>and <strong>“/home/nathan/database_backups” </strong>ensured that the restored <strong>.db </strong>files landed exactly where they originally were.</p><p>The last thing that I did for this section was open up the latest backup in <strong>sqlite3</strong> and opened up the <strong>accounts </strong>table to see that the data was the same as it was originally.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/883/1*vBI04sXSM7njjmFX-wXYWw.png" /></figure><p>It was. It should be noted that <a href="https://creativeprojects.github.io/resticprofile/configuration/run_hooks/index.html"><strong>restic profile </strong>supports other hooks</a> in addition to the one I used (<strong>run-before)</strong> — specifically <strong>run-after</strong>, <strong>run-after-fail</strong>, and <strong>run-finally</strong>. <strong>Run-after</strong> can be useful for something that needs to be done after a backup completes successfully, for example, cleaning up temporary dump files or triggering a downstream process that depends on a fresh backup existing. The <strong>run-after-fail </strong>hook will only fire when a backup fails and would be useful for alerting an administrator that a backup job did not complete as expected.</p><p><strong>Section VII: Automation With Systemd Timers</strong></p><p>Next up was to get my <strong>restic </strong>backup invocations done on an automated scheduling. To do that, I settled on using <strong>systemd</strong> instead of <strong>cron</strong>. Unlike cron, which simply fires a command and discards any record of what happened, systemd tracks each job run as a proper service with a full log captured by <strong>journald </strong>— meaning if a backup fails at 3AM you can run <strong>journalctl </strong>the next morning and see exactly what went wrong rather than wondering why your snapshots stopped appearing. (The archlinux Wiki page also had a nice section providing <a href="https://wiki.archlinux.org/title/Systemd/Timers#As_a_cron_replacement">some additional benefits</a> of using <strong>systemd </strong>over <strong>cron</strong>)</p><p>A systemd timer setup requires two files working together: a <strong>.service</strong> file that defines what to run, and a <strong>.timer</strong> file that defines when to run it. For brevity, I got to work only on web1 VM — and the same steps and logic would identically apply to <strong>haproxy1 </strong>and <strong>haproxy2</strong></p><p>First, I created a <strong>restic-backup.sh </strong>script that would back up the files in the <strong>/home/nathan/database_backups </strong>and <strong>/home/nathan/sample_backups </strong>folders. Notice that it would also run the script for running the pre-backup hook and also apply the retention policy — the same steps that I did and explained in the previous sections. The script looked like this:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/543/1*ygfAXfxBI--guzfE9SVFcg.png" /></figure><p>Then I created the <strong>/etc/systemd/system/restic-backup.service </strong>file. The screenshot of its text is below with explanatory comments. The actual information regarding how each field works had to be compiled through a series of websites, whose links are <a href="https://www.freedesktop.org/wiki/Software/systemd/NetworkTarget/#:~:text=The%20network%2Donline%20target%20is%20not%20pulled%20in,network%20*%20systemd%2Dnetworkd%2Dwait%2Donline.service%20if%20systemd%2Dnetworkd%20is%20used">this</a>, <a href="https://www.digitalocean.com/community/tutorials/understanding-systemd-units-and-unit-files">this</a>, and <a href="https://www.baeldung.com/linux/systemd-target-multi-user">this</a></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*zZTGH7HMLsI_8-zn3CkJRg.png" /></figure><p>After that, I created the <strong>/etc/systemd/system/restic-backup.timer </strong>file that would act as the scheduling mechanism for the backup script</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Cx0v9E0Aaf90C9IL4xIYvg.png" /></figure><p>The <strong>Persistent=true</strong> line deserves explanation — if a VM is powered off at the scheduled time, systemd will check on next boot whether the timer was missed and if so, run the backup within 10 minutes. Without this, a VM that was rebooted would simply skip that day’s backup entirely with no indication that anything was missed.</p><p>I then verified that the Service and Timer files that I created had no errors with the <strong>sudo systemd-analyze verify restic-backup.* </strong>command (I found the idea for this step from <a href="https://documentation.suse.com/smart/systems-management/html/systemd-working-with-timers/index.html">this website</a>)</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/518/1*1xljErmJbDU1jUWQcMyV5g.png" /></figure><p>Then, I enabled and started the timer with the following commands listed on the following screenshot</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Foyz_4besQ-W-syyphRZRA.png" /></figure><p>Notice the usage of the <strong>daemon-reload </strong>command. It is required because systemd reads and caches all unit files into memory at boot, so after I created the <strong>.service </strong>and <strong>.timer </strong>files on the disk, I needed to tell the running systemd process to get those files in.</p><p>The last thing that I needed to do was that I wanted to trigger the service manually to confirm that everything works properly. I didn’t want to wait until 2 AM when the service would automatically fire up. To do this, I used the <strong>sudo systemctl start restic-backup.service</strong>, confirmed that it was running successfully with the with the <strong>sudo systemctl status restic-backup.service </strong>command, and then <strong>source /etc/restic-env &amp;&amp; restic snapshots</strong> command confirmed that the new snapshot was made</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*THgvMSMVrXfyE7sWiNTMkA.png" /></figure><p>The last thing I needed to do was to get the logs regarding the run. To do this, I used the <a href="https://man7.org/linux/man-pages/man8/systemd-journald.service.8.html"><strong>journalctl</strong></a> command, which can filter logs by <a href="https://www.hostinger.com/tutorials/journalctl-command?utm_source=google&amp;utm_medium=cpc&amp;utm_id=18647760618&amp;utm_campaign=Generic-Tutorials-DSA-t2%7CNT:Se%7CLO:USA&amp;utm_term=&amp;utm_content=%7Bsearchterm%7D&amp;gad_source=1&amp;gad_campaignid=18647760618&amp;gbraid=0AAAAADMy-hYl_GX7KuHKksYvBDhZc-ATu&amp;gclid=Cj0KCQjwmunNBhDbARIsAOndKpn3B3z9ZJ_Bj1b_9gGqkBJyfiULVjoCeWYn5VPLvd0y5hUkgazuftEaAuRWEALw_wcB">specific services with the <strong>-u </strong>flag</a>.The command was <strong>journalctl -u restic-backup.service –no-pager | more</strong> (the <strong>| more </strong>was used to display the output one screen at a time)</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/460/1*iZDpYou61Q_JPp7MCqIggQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*lN9A6Rnq-CgWmbhs1dqR7w.png" /></figure><p>Every future backup run will append to this log automatically, giving a complete audit trail of every backup that ran or failed. This is understood per the <strong>systemd-journald </strong>man page, which explains that “creates and maintains structured, indexed journals based on logging information that is received from… standard output and standard error of service units… and stores log data either persistently below <strong>/var/log/journal</strong> or in a volatile way below <strong>/run/log/journal/</strong>”</p><p><strong>Section VIII: Alerting — Failure Notification</strong></p><p>The next thing that I needed to implement is alerting for any backup failures. An unmonitored backup is not a backup. For example, if it silently fails for two weeks, you discover it only when you need to restore — and that is not good news.</p><p>For a lightweight implementation, I went with <a href="http://healthchecks.io/">healthchecks.io</a>, which is an open-source monitoring service that verifies if scheduled tasks — such as cron jobs, backups, or API calls — run on time and successfully. To get started, I got to work on registering my account</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/985/1*FWNBz37c2EKanvX481epDQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*m3BMjnDJkqw6birDX_qo3w.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*WPFdGq9HdnYlYMJFG-QVlg.png" /></figure><p>And it created an initial health check. I then needed to configure it, which I did per the following screenshots</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1005/1*I2VBHCSqeLbAZ3Cvb3_1pA.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1001/1*gMq0pyRusWOPAPm7CSAZRw.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/985/1*uqryHbodJAhesaVrNI_VOQ.png" /></figure><p>Then <a href="https://healthchecks.io/docs/bash/">per the documentation</a>, I needed to insert in a curl request into the <strong>restic-backup.sh </strong>script</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/995/1*cYug3fMCZ1GI3DqG4EWsRQ.png" /></figure><p>After that, I decided that it would be a good move to run it manually instead of waiting for the next 2 AM backup and confirm that I would get the ping coming in. I launched the <strong>./restic-backup.sh </strong>script and lo and behold — it came in</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/829/1*Ey2PyeAYNpBoVW2h_70hFg.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/998/1*J3e6_S1s4i3TvGALdNwyWQ.png" /></figure><p>To wrap up this section, I should note that “I initially considered self-hosting a full Prometheus and Alertmanager stack — where Prometheus would scrape metrics from the backup jobs and Alertmanager would route failure notifications to an alert destination such as email or Slack. However, my homelab workstation was configured in such a way to allow Proxmox to execute that I could not run Docker Desktop Engine. Therefore, I went with a quick implementation of <a href="http://healthchecks.io/">healthchecks.io</a> instead.</p><p><strong>Section IX: File-Level and Directory Restore Testing</strong></p><p>A backup that has never been restored is a hypothesis. Everything built up to this point — the S3 buckets, the cross-region replication, the retention policy, the systemd timer, the healthchecks.io alerting, and the <strong>resticprofile </strong>hooks — was infrastructure. This section is the proof that all of it actually works when it matters most.</p><p>The two restore tests below cover the most operationally relevant recovery scenarios for this setup — targeted single-file recovery and full directory recovery with application data verification. Since the retention policy was configured in Section V to keep only the latest monthly snapshot, both tests work against that single latest snapshot. In a production environment with daily snapshots and a longer retention window, an additional historical point-in-time restore test would verify that specific past states are recoverable.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*lzD7-825eknTOxmudKhdag.png" /></figure><p>Notice that in this screenshot I did the initial backup of the <strong>/home/nathan/database_backups </strong>and <strong>/home/nathan/sample_backups </strong>folders and then immediately afterwards used <strong>ls </strong>to display their contents so that you — my loyal readers — can get the sense of what exactly was in them.</p><p><strong>Restore Test #1: Single File Restore With Hash Verification<br></strong>In this round, I needed to restore a single <strong>.db </strong>file from the database backups folder and prove that what came back from the cloud-hosted restic snapshot matched what was originally backed up.</p><p>To prepare for this step, I first used the <a href="https://www.ibm.com/docs/en/powervc-cloud/2.3.2?topic=commands-sha256sum-command"><strong>sha256sum</strong> command</a> to get the hashes of all the <strong>.db </strong>backup files in the <strong>/home/nathan/database_backups</strong>. This was done with the <strong>sha256sum database_backups/*.db</strong> command</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*-HFfqEdGwmvbIiuvW9B7Bw.png" /></figure><p>Excellent. I then created a <strong>/home/nathan/restore </strong>directory with the <strong>test1 </strong>subfolder and entered it</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*OGpXqrniNt6lVBs8KXdtUQ.png" /></figure><p>I then decided that out of all the files, I wanted to restore one called <strong>backup-20260322–020006.db</strong> — which I did with the <strong>restic restore 8e65ace0:/home/nathan/database_backups –target . –include * </strong>command</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*zjTyqlNOGgjANbZQT9I7zw.png" /></figure><p>Notice that <strong>8e65ace0 </strong>is the identifier for the restic snapshot that was taken. Next up was to use the <strong>sha256sum </strong>command to get its hash and to compare it with the original one — to confirm file integrity.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/605/1*96fVqwWeYHUctqnytFxfsA.png" /></figure><p>The two hashes were the same!</p><p><strong>Restore Test #2: Directory Restore With Simulated Data Loss and Database Verification</strong></p><p>The second test simulated a more serious scenario — the complete loss of both the <strong>database_backups </strong>and <strong>sample_backups </strong>directories — and verified that a full restore brings everything back intact, with the SQLite database confirmed queryable at the end. This is the test that proves the entire pipeline from the <strong>resticprofile </strong>pre-backup hook through S3 storage through restore actually recovers real application data in a usable state, not just files that are present on disk.</p><p>First, I recorded the hashes of all the files in both the <strong>database_backups </strong>and <strong>sample_backups </strong>folders with the <strong>sha256sum </strong>command, before simulating any loss.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*NXDwZMNjRC5jEbx7FcrlCg.png" /></figure><p>Now for the next step: simulating catastrophic data loss by wiping both directories entirely. I did this using the <strong>rm -rf database_backups/* </strong>and <strong>rm -rf sample_backups/* </strong>commands</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*bXMSvJwdfaEO0CoBPjLV9g.png" /></figure><p>Then I used the <strong>source /etc/restic-env &amp;&amp; restic restore 8e65ace0 –target / –include “/home/nathan/database_backups” &amp;&amp; restic restore 8e65ace0 –target / –include “/home/nathan/sample_backups”</strong> to restore the files. Then, I used the <strong>sha256sum database_backups/* </strong>and <strong>sha256sum sample_backups/* </strong>command to confirm that the hashes were the same as originally</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Hs_x84SLsSy-DKcSVQQCKA.png" /></figure><p>It worked! I then took a sample database snapshot and checked on the SQLite table inside of it to see if the values were still valid</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*g6ythweC1gCo-Y07N5bMEg.png" /></figure><p>They were!</p><p>I then did one last check by going into my AWS web console and looked in the S3 buckets in both my primary <strong>us-east-1 </strong>and DR <strong>us-east-2 </strong>regions to confirm that S3 successfully replicated the initial snapshot into the DR bucket in <strong>us-east-2</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*6W7DA6bZw4rzkQuL_XKDNA.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*h6dExNbcirbVlTxeh1nelQ.png" /></figure><p>It was. The resticprofile pre-backup hook had correctly dumped the SQLite database before the backup ran, <strong>restic </strong>had encrypted and uploaded the dump to S3, S3 had replicated it to the DR bucket in us-east-2, and the restore brought it back in a fully usable state. The entire backup/disaster recovery pipeline was verified end to end.</p><p><strong>Section X: Recovery Metrics</strong></p><p>All this was nice and pretty — and very important, but management needs to answer the question of “How?” — how effective is and do we want our backup systems to be.</p><p>There are two key metrics that need to be accounted for when both planning a backup system and actually working with it: the <strong>RPO (Recovery Point Objective)</strong> and<strong> RTO (Recovery Time Objective)</strong>.</p><ul><li><strong>Recovery Time Objective (RTO): </strong>It is the maximum acceptable duration of downtime a business can tolerate for restoring systems &amp; data after an unexpected outage. The units of measurement can be minutes or hours and answers the question of <em>how quickly after an outage do we need a system to be available?</em></li><li><strong>Recovery Point Objective (RPO): </strong>It refers to how much data loss can your application tolerate — or restated — how old can this data be when the application is recovered. It is the maximum data loss that can happen/is tolerated.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*KnxHkMBBFrkVJyJbP0JdxA.png" /><figcaption><em>Image Is From </em><a href="https://aws.amazon.com/blogs/mt/establishing-rpo-and-rto-targets-for-cloud-applications/"><em>Amazon’s Page</em></a></figcaption></figure><p>Let’s apply this concept to this setup.</p><ul><li><strong>Recovery Time Objective (RTO) </strong>— It<strong> </strong>was not formally measured during these tests with a stopwatch but based on the restore commands completing within a minute or two when invoked, a reasonable estimate would be <strong>&lt; 5 minutes </strong>for a full directory restore. <br>In a real-world environment with larger datasets, measuring RTO precisely with a stopwatch during a planned DR test would be mandatory before relying on the system</li><li><strong>Recovery Point Objective (RPO)</strong> — This is determined by the <strong>systemd </strong>timer schedule, with a backup running every day at 2 AM. In this case, it is <strong>24 hours</strong>. In the worst case scenario a backup failure discovered the following morning means up to 24 hours of data could be lost</li></ul><p><strong>Section XI: Runbook</strong></p><p>A backup system without a written recovery procedure is incomplete. The runbook is the document that someone — including a future version of myself under pressure at 3AM — can follow step by step to recover this system without needing to remember how any of it was configured.</p><p>I wrote <strong>RUNBOOK.md</strong> and pushed it to the homelab GitHub repository. It covers the four recovery scenarios tested in Section IX, the measured RPO and estimated RTO, credential setup instructions for a fresh recovery machine, how to switch to the DR bucket if the primary is unavailable, how to check backup logs via journalctl, and a restore test log table that gets updated every time a DR test is performed.</p><p>The restore test log is the most important part of the runbook — a recovery procedure that was written once and never tested again is almost as dangerous as having no procedure at all.</p><p>You can find it <a href="https://github.com/secnate/HomelabSeriesGithubRepo/blob/main/Homelab18%20-%20Automated%20Backup%20and%20Disaster%20Recovery/RUNBOOK.md">here</a>.</p><p><strong>Section XII: Cleanups</strong></p><p>With the lab over, I needed to then tear the cloud infrastructure down with the <strong>terraform destroy </strong>command. Before I would be able to tear down the S3 buckets, I needed to ensure that they were empty.</p><p>Remember I had versioning enabled as a prerequisite for S3 Object Lock? That meant that I had multiple versions of the same data in S3. The AWS console for the two buckets having the <em>Show Versions</em> toggle indicates that very clearly</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*iWiEQG7lq0pT_9foHcm_Bw.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Hzl5rCK1uB2-u3sAlajoBg.png" /></figure><p>In the two screenshots I used the red curly bracket to show how the <strong>config</strong> file had multiple versions — as an illustrative example.</p><p>Because S3 Object Lock in GOVERNANCE mode prevents any object version from being deleted until its retention period expires, Terraform cannot remove the buckets on its own during <strong>terraform destroy</strong>. Fortunately I had the ability using my Administrator account to go into the AWS website consoles and delete all the data. After I did that, I quickly spun down the infrastructure using the <strong>terraform destroy </strong>command.</p><p>That was it for this lab. I can now answer one question confidently that if everything went wrong tonight, could I recover? After building a multi-region AWS backup pipeline from scratch with Terraform, instrumenting a VM with automated encrypted backups via <strong>restic </strong>and <strong>systemd</strong>, wiring up pre-backup database hooks with <strong>resticprofile</strong>, implementing failure alerting via healthchecks.io, and verifying the entire pipeline with hash-verified restore tests — the answer is yes.</p><p>All the collateral is on <a href="https://github.com/secnate/HomelabSeriesGithubRepo">my GitHub page</a>.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=e00d648c169f" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Homelab #17: Linux High-Availability Cluster with HAProxy and Keepalived]]></title>
            <link>https://secnate.medium.com/homelab-17-linux-high-availability-cluster-with-haproxy-and-keepalived-b94feff6864c?source=rss-f95ccfcb96af------2</link>
            <guid isPermaLink="false">https://medium.com/p/b94feff6864c</guid>
            <dc:creator><![CDATA[Nathan Pavlovsky]]></dc:creator>
            <pubDate>Tue, 03 Mar 2026 00:57:38 GMT</pubDate>
            <atom:updated>2026-03-03T01:05:21.649Z</atom:updated>
            <content:encoded><![CDATA[<p>Picture this: it’s 2am, your phone is blowing up, and the service you’re responsible for is completely unreachable. You SSH in, and the culprit is immediately obvious — the single load balancer node that everything was routing through has gone down, and with it, every user requests. There’s no failover. There’s no redundancy. There’s just you, a dead VM, and a very unhappy on-call experience. This is the single-point-of-failure problem, and it’s one of the most common — and most preventable — failure modes in real infrastructure. In this lab, I set out to solve it by building a Linux high-availability cluster using HAProxy for load balancing and Keepalived for VRRP-based virtual IP failover across two nodes, so that when one goes down, the other picks up traffic automatically — no manual intervention, no downtime, no 2am panic.</p><p>Before diving in, here’s a quick overview of the tools involved:</p><ul><li><strong>Proxmox VE</strong> [<em>Proxmox Virtual Environment</em>] is an open-source server virtualization platform that allows you to run and manage virtual machines from a single web-based interface. Built on Debian Linux with KVM under the hood, it handles the provisioning, networking, and lifecycle of all the VMs in this lab — making it the foundation that everything else runs on top of.</li><li><strong>HAProxy</strong> [<em>High Availability Proxy</em>] is an open-source load balancer and proxy server that distributes incoming network traffic across multiple backend servers. Rather than a single server absorbing every request — and becoming a single point of failure — HAProxy sits in front of your infrastructure, intelligently routing traffic and continuously health-checking the backends behind it. If a backend goes down, HAProxy stops sending traffic to it automatically, without any manual intervention required.</li><li><strong>Keepalived</strong> is a Linux daemon that provides high availability at the network layer by managing a floating Virtual IP address (VIP) between two or more nodes. Think of it as an automatic traffic cop: it continuously monitors the health of a designated primary node, and the moment that node fails, it reassigns the VIP to a standby node so that clients connecting to that address never know anything has changed. This is the mechanism that turns two separate load balancer nodes into a single, resilient service.</li><li><strong>VRRP</strong> [<em>Virtual Router Redundancy Protocol</em>] is the underlying network protocol that <em>Keepalived </em>uses to coordinate which node currently “owns” the VIP. Nodes participating in a VRRP group constantly exchange heartbeat messages — a MASTER node broadcasts its presence, and BACKUP nodes listen. The moment the MASTER goes silent, the highest-priority BACKUP node promotes itself to MASTER and claims the VIP, completing the failover in a matter of seconds.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/928/1*8zoE-6FMM2_pfZnGFxt2UA.png" /></figure><p>That’s enough theory. Let’s build it!</p><p><strong>Section I: Proxmox Host Setup</strong></p><p>First, I downloaded the Proxmox VE ISO from <a href="http://proxmox.com/">proxmox.com</a></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*MCz2Oyq1AFgBF7230CJoqA.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*G4Rtb4PWdHV5uPqjylRXKw.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*teMK-cQQvnZCfeX5of4NTw.png" /></figure><p>Afterwards, I got to work on making the proxmox host in VMware Workstation Pro:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/423/1*NLhvApjJhepUncTKKgnsQQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/420/1*lZAexntk3PwTxSgZYqbCVw.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/421/1*gftwq5CITADoRHIeZKt2KA.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/420/1*uU8rWKr5gbacf1BdwTEfgg.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/738/1*2nZoRPUWHKQraS44fxwR1Q.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/740/1*ozBrl4ffFASt17uKJCyaQA.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/421/1*0XF3hqq4wxwD5Mqdi7FW0Q.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*L8XsB7z-QZhK8DUZNuIXcA.png" /></figure><p>Notice that I enabled the <em>“Virtualize Intel VT-x/EPT”</em> in VMware Workstation’s processor settings to allow Proxmox — itself running as a VM — to use KVM hardware virtualization for the to-be-created Ubuntu VMs nested inside it. Without this flag, Proxmox cannot create and run VMs of its own. 120GB was chosen to comfortably accommodate the Proxmox OS, the planned three Ubuntu VM virtual disks (20GB + 20GB + 10GB), ISO storage, and snapshot headroom for destructive failover testing — without risking running out of space mid-lab at the worst possible moment.</p><p>I then tried to launch the VM and got the following message in a pop-up window: <em>“Virtualized Intel VT-x/EPT is not supported on this platform. Continue without virtualized Intel VT-x/EPT?”</em></p><p>This message was bad news. I needed to get Proxmox — itself running as a VM — to use KVM hardware virtualization for the Ubuntu VMs nested inside it. By definition, this message had to go. Enabling nested virtualization required disabling Credential Guard, which resisted fixes including turning off Hyper-V via <strong>bcdedit</strong>, unchecking all Hyper-V-related Windows features, disabling Memory Integrity, and manually zeroing out the relevant registry keys under <strong>HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\DeviceGuard</strong> and <strong>Lsa</strong>.</p><p>What finally worked was finding Microsoft’s <a href="https://www.microsoft.com/en-us/download/details.aspx?id=53337"><strong>Device Guard and Credential Guard hardware readiness tool</strong></a>, a PowerShell script that allowed me to disable Device and Credential Guard on my Homelab workstation.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ePdWfSjb3HqhWhycDuhkrg.png" /></figure><p>I then successfully spun up the VM and went through the appropriate installer steps:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1015/1*tRqFzz-l8ASBMJWyLm_DQw.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*MrVt6EjXkVoLqTGqQ89oaQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*YtcumzRvzNbr8QWw4_eENQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ZyLD9b98WUytjiK8mJ_kzw.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*yWZMBaNPWrhqQyhiNbs8mA.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*zQUNKqHlBxVubE7UMTGaPw.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*tzvuL-9bieCf6fMq22IeRA.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*fXl_-DIKqxIvT5PfYLvvMQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*beF2dcX3X84_-NJCJ9dVxg.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/821/1*V4KJ1CnFTxovO1DWwaWXsw.png" /></figure><p>And then went to the <a href="https://192.168.253.136:8006/"><strong>https://192.168.253.136:8006/</strong></a> address in my homelab workstation’s Chrome browser and logged in as <strong>root</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*mOQtY8WYLR0J5cqzTZfAxw.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*yp6sye4SslbSlWUXzeXrhg.png" /></figure><p>Great. I then popped into the <strong>System &gt;&gt; Network </strong>tab and confirmed that I had a Linux Bridge titled <strong>vmbr0 </strong>for VM traffic. It is essentially a virtual network switch built into Proxmox that allows VMs created inside it to communicate with each other and with the outside network. Without it the VMs would be completely network-isolated with no way to pass traffic between them.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*zLc9hjNNYUFANH_yLT9_3Q.png" /></figure><p>I then tested connectivity to external websites by opening up the Proxmox shell and using <strong>ping</strong>. This confirmed that the Proxmox VM’s network adapter, bridge configuration, and VMware NAT were all working together correctly end-to-end — because a Proxmox host that can’t reach broader internet can’t download packages, upload ISOs, or allow the VMs created inside it to communicate outside their local subnet.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*twr3SgNgvD-nDUo2A__Sug.png" /></figure><p>I then tried to update the VM using the <strong>apt update &amp;&amp; apt full-upgrade </strong>command — and got an error</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*p2bPsj402LyvsH6s4wQ-hQ.png" /></figure><p>The root cause behind the failure is that I did not have a Proxmox subscription. For the purposes of this homelab, I did not feel the need to get one — since per <a href="https://forum.proxmox.com/threads/free-vs-paid-in-term-of-features-is-there-differences.145151/">a Proxmox forum</a>, “there is [effectively] no difference in functionality between an installation with a subscription and without.” To fix this, I went into the <strong>Updates &gt;&gt; Repositories</strong> window, disabled the Enterprise-related ones and added in the <strong>pve-no-subsciption</strong> ones as a replacement.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*SJ0pxO2mAEsWkFEUXpfeGw.png" /></figure><p>I then went back into the CLI and used the <strong>apt update &amp;&amp; apt full-upgrade </strong>command to get the updates for this box underway</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*J69xfQhuexocmLqEqpnuYQ.png" /></figure><p>Wonderful. The web interface was working, and the VM was fully configured and installed. To wrap up this section, I took a snapshot of the Proxmox VE VM in VWare Workstation, so that I could have a reference point for any potential rollbacks if needed.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*t2B8Q7V-bGL2d9_xcusmOg.png" /></figure><p><strong>Section II: Ubuntu VM Creation and Cluster Infrastructure Setup<br></strong>The first thing I did was to download the ISO for the Ubuntu Server — version 22.04.5 LTS (Jammy Jellyfish).</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*nyzXIOSqf7Ma9dgz1FBPoA.png" /></figure><p>I then opened up Proxmox’s <strong>Datacenter &gt;&gt; pve &gt;&gt; local (pve) &gt;&gt; ISO Images </strong>tab, and used the <strong>Upload </strong>button</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*u5q2yijgFxUEyVcIbh4ImQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Z0ldHAGAcvXP1u1k9rW_Zw.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*CX47vaUzSqV3oPHzYd6Jfg.png" /></figure><p>Great! I then quickly made the first Ubuntu Server VM named <strong>haproxy1</strong>, which was supposed to act as the primary load balancer. The specs were <strong>2 cores, 4GB RAM, and a 20GB disk. </strong>To do this, I clicked the <strong>Create VM </strong>button in the top right-hand corner, and did the following:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*LGVsBy4tX4Q0y20ivU8lqg.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/714/1*egBZIh1EOG8exgORO65aug.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/717/1*Y5Ja8_dsJLNVICw1Z12JfA.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/717/1*bULfRVFOQz8NhJVaoRhsdg.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/716/1*EL90gyfd1CVr51J6f2jk8A.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/717/1*NeWGwjauUNlD5il_bXdrBw.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*CVceyETF5t3ZbQBYt9Oh0Q.png" /></figure><p>Great. I then repeated the same series of steps to create two more Ubuntu Server VMs: <strong>haproxy2</strong> (the secondary load balancer — same specs of <strong>2 cores, 4GB RAM, and a 20GB disk</strong>) and <strong>web1 </strong>(the backend web server — the specs were <strong>1 core, 2GB RAM, 10GB disk</strong>)</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ioN2O0GimcDjP7gMzfPRsA.png" /></figure><p>I then needed to fire up the VMs and to go through the installers for each one. I started with the one for <strong>haproxy1</strong> — and assigned to it the static IP address of 192.168.1.101/24 and had the OpenSSH server installed on it:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*FyBlON2t5-cOuCdQlxcYhg.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*VzDx_B7DNjdceB7s9hjWRQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Cvvz3cUDTnlSjEAXyTvIZw.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*TvWj5v6LiIxev0X8W355Og.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*5JuwC_6B14ABfh7J416yng.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*JNI3xnKj_4Jw-7S4YE40Kw.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*uq1xqby7nUW_6v1Qn6PFxw.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*lVeLjyNDUBiASN4BW_8sFg.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*U6oyQlM2X0SQciQKOtKSzw.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*_RHhYkFvjafYJaUOVwRPTg.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*jd01V4frlyMQnMfYeGHE7A.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*D6nE2DzCKdkepPQevkvaRA.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*-7Z55LrISnUao03we7wpYA.png" /></figure><p>This launched a reboot, and then I got the message: <strong>“Failed unmounting /cdrom”</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*345iDLwKtyTcxjJlpV83gg.png" /></figure><p>This error appears because the Ubuntu installer leaves the ISO still attached to the virtual CD drive when it triggers the reboot. To resolve it, the CDROM drive needed to be detached from the VM in the Proxmox UI before the VM could boot cleanly from the installed virtual disk.</p><p>I did this by opening up the <strong>100 (haproxy1) </strong>VM’s <strong>Hardware </strong>tab and edited the <strong>CD/DVD Drive’s </strong>settings to have it not use any media</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*iJX1cLj4xkvWk8Gl7iJeBQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*K5Hawhzlmb94XnmteO9prQ.png" /></figure><p>I then booted the VM successfully, logged in, and confirmed with the <strong>ip a </strong>command that it had the <strong>192.168.101/24 </strong>IP address working properly</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Zz0fJktLEyIemc6seafmTA.png" /></figure><p>Then I quickly did the <strong>sudo apt update &amp;&amp; sudo apt upgrade </strong>command to ensure its packages were updated and installed properly</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*xkpbTzx7Y0QUrIZxJ2s7ag.png" /></figure><p>I walked through the steps for the creation and configuration of the <strong>haproxy1</strong> VM. I then did the same for the <strong>haproxy2 </strong>VM (with its IP address = <strong>192.168.102/24</strong>) and for the <strong>web1 </strong>VM (with its IP address = <strong>192.168.1.103/24</strong>).</p><p>I then tried to <strong>ping </strong><a href="http://www.google.com/"><strong>www.google.com</strong></a> from each of these VMs — and got an error. Opening up the VMware application’s Virtual Network Editor, I saw that the NAT Network was of the <strong>192.168.253.0/24</strong> IP CIDR. This was the problem: I had assigned my VMs static IPs in the <strong>192.168.1.x</strong> range, but VMware’s NAT gateway — the VMs’ only route to the internet — lives at <strong>192.168.253.2</strong>. The VMs were trying to send traffic through a gateway that didn’t exist on their subnet, so every packet going outside their local network was dropped.</p><p>The fix was straightforward: to reassign all three VMs to the <strong>192.168.253.x </strong>subnet so they could actually reach their gateway, and with it, the Internet. The hitch was that my minimal Ubuntu server installations didn’t have text editors like <strong>nano </strong>or <strong>vi </strong>installed, so I first had to flush the old IP address from the ens18 interface using <strong>sudo ip addr flush dev ens18</strong>, then install ping with <strong>sudo apt install iputils-ping -y </strong>and nano with <strong>sudo apt install nano -y</strong>. With those tools in place, I used <strong>nano </strong>to update <strong>/etc/netplan/00-installer-config.yaml</strong> on each VM — replacing the old <strong>192.168.1.x </strong>addresses with the correct <strong>192.168.253.x </strong>addresses, and swapping the deprecated <strong>gateway4</strong> key for a proper <strong>routes </strong>block pointing to <strong>192.168.253.2</strong>.</p><p>I was then able to ping <a href="http://www.google.com/"><strong>www.google.com</strong></a><strong> </strong>on each of the three VMs successfully! This meant that the connection to the broader internet was working.</p><p>I then used the <strong>sudo apt install nginx -y </strong>command to install the nginx web server onto the <strong>web1 </strong>VM, confirmed with the <strong>sudo systemctl status nginx </strong>command that it was running, and used the <strong>echo “&lt;h1&gt;Hello from web1&lt;/h1&gt;” | sudo tee /var/www/html/index.html</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*8VSyWeXyH4ppzTmYNg-0dw.png" /></figure><p>The <strong>curl </strong><a href="http://localhost/"><strong>http://localhost:80</strong></a><strong> </strong>command then confirmed that this website was successfully up and running:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/337/1*LgTocunj854uze-C98AZzQ.png" /></figure><p>Great. I now had three VMs of the following IP addresses:</p><ul><li><strong>haproxy1: </strong>192.168.253.101</li><li><strong>haproxy2: </strong>192.168.253.102</li><li><strong>web1: </strong>192.168.253.103</li></ul><p>And needed to test the connectivity and communication basics. I did this on <strong>each server</strong> by confirming that I could ping the other two servers, that the <strong>web1 </strong>server’s application could be accessed, and that I could perform an SSH connection between them. See the following screenshots for more details:</p><p><strong>Testing On Server haproxy1:</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*AgMCB9Vk9tkaPAoCKXg05w.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*IvZmn-gyHACjdstAM5mlEQ.png" /></figure><p><strong>Testing On Server haproxy2:</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*uSzwSWZ60tHxTb9ayCWd6w.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Eft9vJxGkMKLGGkxcuF1jQ.png" /></figure><p><strong>Testing On Server web1:</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*wHDZmtYxjcTPY0bDIhJbwQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ORdHA1KhQw4-6CZ8TK9mJw.png" /></figure><p>The last thing that I needed to do for this section was to take snapshots of each VM, now that they are fully configured and confirmed to be working well. I started with the <strong>haproxy1 </strong>server, opened up the <em>Snapshots </em>page and used the <em>Take Snapshot </em>button to take it</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*3OglYfp8K9KjK_S5e70Bww.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*gvzrNAegCpU2OA7Ia46uiA.png" /></figure><p>I then quickly did the same for the <strong>haproxy2 </strong>and <strong>web1 </strong>server.</p><p><strong>Section III: Deploy HAProxy and Keepalived</strong></p><p>The time has come for the heart of this lab. The first step was to get <strong>HAProxy </strong>installed. It is an open-source load balancer that sits in front of your backend servers and distributes incoming traffic across them, continuously health-checking each one so that if a backend goes down it automatically stops sending traffic there — and it needed to be installed on both haproxy1 and haproxy2 so that the load balancer itself had no single point of failure.</p><p>I did this by opening up the <strong>haproxy1 </strong>VM and used the <strong>sudo apt install haproxy </strong>command</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*hlTOpNk1_u3Ci633y2faxQ.png" /></figure><p>The next step was to get the haproxy configured. I first used the <strong>cat /etc/haproxy/haproxy.cfg </strong>command on <strong>haproxy1</strong> to see what the primary configuration file for the HAProxy load balancer software looks like. It defines the rules and settings for how HAProxy accepts incoming client connections and distributes them among a pool of backend servers to ensure high availability and optimal performance of web services</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*OzMSUToXPZbO6tVdem29eg.png" /></figure><p>We see that the default HAProxy configuration file came by default with two sections:</p><ul><li><strong>global</strong>, which sets system-level settings like logging, the stats socket, file paths, user/group permissions, and SSL cipher configurations</li><li><strong>defaults</strong>, which defines baseline behavior inherited by all frontends and backends — including HTTP mode, logging format, connection timeouts, and standard error page mappings</li></ul><p>It is missing the <strong>frontend </strong>and <strong>backend </strong>sections, which are the parts that actually tell HAProxy what traffic to listen for and where to route it — without those, HAProxy has no idea what to do with incoming requests. I fixed this by inserting in the following:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*qbNMsCoMDxHm_jeX3Mn0pA.png" /></figure><p>Let’s digest what this does.</p><ul><li><strong>frontend http_front</strong> is the entry point — it defines what HAProxy listens for. <strong>bind *:80 </strong>means it listens on port 80 on all network interfaces. When traffic arrives it gets forwarded to the <strong>default_backend</strong> called <strong>web_servers</strong></li><li><strong>backend web_servers</strong> is where you define the actual servers that handle the requests. <strong>balance roundrobin</strong> distributes requests evenly across all available servers in rotation (yes, in this case we have only one — but it is the good pattern). <strong>option httpchk GET / </strong>tells HAProxy to health-check each server by making a real HTTP request to <strong>/</strong> rather than just checking if the port is open — more realistic because it confirms the web server is actually responding. <strong>server web1 192.168.253.103:80 check</strong> registers the <strong>web1 </strong>VM as a backend with health checking active.</li></ul><p>Then I had everything run again with the <strong>sudo systemctl restart haproxy </strong>and <strong>sudo systemctl status haproxy</strong> commands</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*0PCDtCoJffHfoOKHKYMZ1w.png" /></figure><p>I then did the same on the <strong>haproxy2 </strong>machine.</p><p>Then I needed to get <strong>Keepalived</strong> installed. It is a Linux daemon that provides high availability at the network layer by managing a floating Virtual IP address (VIP) between two or more nodes by continuously monitoring the health of the primary node and automatically reassigning the VIP to a standby node the moment the primary fails. This ensures that clients always have a single, stable address to connect to regardless of which physical node is actually serving traffic.</p><p>I first used the <strong>sudo apt install keepalived </strong>on both haproxy servers, and then used the <strong>sudo nano /etc/keepalived/keepalivd.conf</strong> command to edit the configuration files. Please see the following screenshots with the explanatory comments regarding what each one does:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*uxLGrWuzDZzKRlpNNsHesQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*q-RDc-picfP9VYAAgSjr7Q.png" /></figure><p>With the configuration files in place on both nodes, the final step was to enable and start both services together using <strong>systemctl enable — now haproxy keepalived</strong> on each node.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*GR2c3LDvN0D9E5ralNe6yw.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*89dOYBbjaZ-QZ_PKLhCVVQ.png" /></figure><p><strong>systemctl enable</strong> tells the system to automatically start the service on every boot — without this, HAProxy and Keepalived would need to be manually started every time the VM restarts, which defeats the purpose of building an automated HA setup. <strong>— now</strong> is a convenience flag that combines <strong>enable </strong>and <strong>start </strong>into a single command, so rather than running two separate commands (<strong>systemctl enable</strong> followed by <strong>systemctl start</strong>), this does both in one shot. Passing both <strong>haproxy </strong>and <strong>keepalived </strong>as arguments applies the same operation to both services simultaneously rather than running the command twice.</p><p>The result is that on both haproxy1 and haproxy2, <strong>HAProxy </strong>and <strong>Keepalived </strong>are now running and will automatically restart on reboot — with <strong>haproxy1 </strong>holding the VIP as <strong>MASTER </strong>and <strong>haproxy2 </strong>sitting in <strong>BACKUP </strong>state, ready to claim the VIP the moment haproxy1’s health check fails.</p><p>Epic. I then opened my Windows workstation and used the <strong>ping 192.168.253.200 </strong>command to confirm that I could reach that virtual IP address</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/547/1*Wru19bSJ1HzZn2APv03YRw.png" /></figure><p>Then the <strong>curl 192.168.253.200 </strong>command confirmed that I could retrieve the website from the <strong>web1 </strong>server</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/350/1*bwWI5_cKJR67DAs6DtWUyw.png" /></figure><p>Wonderful. The setup was completed. There are good documentation/website resources regarding Haproxy works, including <a href="https://www.digitalocean.com/community/tutorials/how-to-use-haproxy-as-a-layer-4-load-balancer-for-wordpress-application-servers-on-ubuntu-14-04">a DigitalOcean</a>, <a href="https://oneuptime.com/blog/post/2026-01-26-haproxy-acls/view">this</a>, <a href="https://phoenixnap.com/kb/haproxy-load-balancer">this</a>, and <a href="https://phoenixnap.com/kb/haproxy-load-balancer">this</a>. Likewise, good references regarding Keepalived are <a href="https://techdocs.akamai.com/cloud-computing/docs/use-keepalived-health-checks-with-bgp-based-failover">this</a>, <a href="https://manpages.debian.org/testing/keepalived/keepalived.conf.5.en.html">this</a>, <a href="https://github.com/JacobMoller/Keepalived-DigitalOcean-Ubuntu-22.04">this</a>, <a href="https://www.redhat.com/en/blog/keepalived-basics">this</a>.</p><p><strong>Section IV: Finalizing &amp; Troubleshooting Failovers</strong></p><p>I started by shutting down the <strong>haproxy1</strong> VM</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*E0xG7x-MGxr_VKeyv-Je9Q.png" /></figure><p>And was able to successfully ping the Virtual IP address of 192.168.253.200 and to get its website:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/551/1*3GKGiBaJCJjEAnW3uAyfFQ.png" /></figure><p>Great. The Virtual IP successfully failed over to haproxy2 — and it was able to communicate to and get the website from the <strong>web1 </strong>backend web server (specified in its <strong>/etc/haproxy/haproxy.cfg</strong> file).</p><p>I then turned <strong>haproxy1 </strong>back on and used the <strong>sudo systemctl stop haproxy</strong> command to stop the haproxy service. I then also used the <strong>sudo systemctl status haproxy</strong> command to confirm that it was not running. This was a test of a more subtle failure: <strong>haproxy1 </strong>is still on but its <strong>haproxy</strong> service is not running on it.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*-y5MHCNxF_UOGNcNf83YTw.png" /></figure><p>I then tried to do the testing — and was not able to curl the website from the Virtual IP from my Windows workstation:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/897/1*osyzpguA3MLOxqXmiJ7GGw.png" /></figure><p>Interesting. It is important to note that then opened up the <strong>haproxy2 </strong>command line and was able to execute the <strong>curl 192.168.253.103</strong> command successfully, so it wasn’t an issue stemming from the <strong>haproxy2 ←→ web1 </strong>connection</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/331/1*OQPsi3m0zt1giADfWOmUnQ.png" /></figure><p><strong>Issue 1: Missing keepalived_script User</strong></p><p>Time to look at the logs. I used the <strong>sudo journalctl -u keepalived -n 20</strong> logs to see what was going on:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*9h32pkJ-g8bW3uQJ9KhLMg.png" /></figure><p>Aha! The default user ‘<strong>keepalived_script</strong>’ for script execution does not exist! Per the <a href="https://manpages.debian.org/testing/keepalived/keepalived.conf.5.en.html">man page for keepalived_script</a>:</p><blockquote>By default the scripts will be executed by user keepalived_script if that user exists, or if not by root, but for each script the user/group under which it is to be executed can be specified.</blockquote><blockquote>There are significant security implications if scripts are executed with root privileges, especially if the scripts themselves are modifiable or replaceable by a non root user. Consequently, security checks are made at startup to ensure that if a script is executed by root, then it cannot be modified or replaced by a non root user.</blockquote><p>While the man page doesn’t explicitly specify what the “security implications” are, the basic idea is that it would violate the principle of least privilege — the security principle that any process should only run with the minimum permissions it needs to function. Creating a dedicated, non-login system user specifically for script execution ensures the health check runs with minimal privileges, limiting the potential damage if the script were ever compromised.</p><p>The fix was to create the <strong>keepalived_script </strong>user on both nodes using <strong>sudo useradd -r -s /sbin/nologin keepalived_script</strong>, followed by <strong>sudo systemctl restart keepalived</strong>.</p><p>Notice that the usage of the <strong>/sbin/nologin </strong>shell prevents anyone from ever logging into the system interactively as that user. Giving it a real shell like <strong>/bin/bash </strong>would create an unnecessary attack surface if the account were ever compromised.</p><p><strong>Issue 2: killall Not Installed</strong></p><p>Then, I tried again, and failed to curl the website from the 192.168.253.200 VIP. I took a look on <strong>haproxy2</strong>’s logs using the <strong>sudo journalctl -u keepalived -n 30 </strong>command, and got the following:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Bx8H_oNag54BLbiH4NE7kQ.png" /></figure><p>Aha. The culprit was that the <a href="https://man7.org/linux/man-pages/man1/killall.1.html"><strong>killall </strong>Linux command</a> was not on the <strong>haproxy1 </strong>nor on the <strong>haproxy2</strong> machines, since they were originally installed as minimal Ubuntu web server installations! I confirmed that <strong>killall </strong>was not on the boxes with the <strong>which killall </strong>command showing nothing — and then installed it quickly with the <strong>sudo apt install psmisc</strong> command. The repeated invocation of <strong>which killall </strong>confirmed that it was properly installed.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/280/1*BWDzX-GNfkbVqfOKIua-bw.png" /></figure><p>I then on both boxes again did <strong>sudo systemctl restart keepalived </strong>and then tried to ping and curl from the VIP again. Remember that at this point in time my <strong>haproxy </strong>service remained stopped on the <strong>haproxy1 </strong>server and I still wanted to get the website from the <strong>web1 </strong>server by way of <strong>haproxy2</strong>. I then opened the logs — again — with the <strong>sudo journalctl -u keepalived -n 30 </strong>command</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*3kUKkJ8G8Zjyj3to7K8pWw.png" /></figure><p><strong>Issue 3: Full Path and Script Security Requirements</strong></p><p>The logs indicated two issues:</p><ol><li>I needed to specify the full path to the <strong>/usr/bin/killall </strong>binary explicitly. This is because Keepalived refuses to run scripts using bare command names like <strong>killall </strong>because it won’t search the <strong>PATH </strong>for security reasons — an attacker who could manipulate <strong>PATH </strong>could redirect the script to a malicious binary. By requiring the full path<strong> /usr/bin/killall </strong>you’re telling Keepalived exactly which binary to run with no ambiguity.</li><li>I needed to enable the <strong>script_security </strong>feature in Keepalived. This is because Keepalived detected that the <strong>keepalived.conf</strong> file defining the script was owned by root and refused to execute it as a security violation</li></ol><figure><img alt="" src="https://cdn-images-1.medium.com/max/605/1*GA9nT9qNSMh3OZhnmTIbLQ.png" /></figure><p>I fixed this on both <strong>haproxy</strong> servers by updating the <strong>/etc/keepalived/keepalived.conf </strong>file to the following</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*_53Yk70ZnMJEswqfzk3gvg.png" /></figure><p>I explicitly named the <strong>/usr/bin/killall </strong>binary and per <a href="https://github.com/acassen/keepalived/issues/901">this</a>, <a href="https://stackoverflow.com/questions/71202129/keepalived-transitioning-to-backup-node-not-happening-when-check-script-on-mast">this</a>, and <a href="https://manpages.debian.org/testing/keepalived/keepalived.conf.5.en.html">this</a> Internet pages that I read, inserted a <strong>global_defs </strong>global definitions configuration block with <strong>enable_script_security </strong>to tell Keepalived that the health check script would run as the dedicated unprivileged <strong>keepalived_script </strong>user rather than root (super important per the <a href="https://manpages.debian.org/testing/keepalived/keepalived.conf.5.en.html">man page</a>), satisfying its security requirements and allowing the <strong>vrrp_script </strong>to execute cleanly. Then I did <strong>sudo systemctl restart keepalived </strong>again and looked at the logs with the <strong>sudo journalctl -u keepalived -n 30 </strong>command</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*GH9qB3W7yi8B3YO9RuXHaA.png" /></figure><p><strong>Issue 4: killall Permission Errors on HAProxy Processes</strong></p><p>Testing the line <strong>/usr/bin/killall -0 haproxy</strong> manually revealed that this line of the script was the problem.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/327/1*VxPz2XjAAWIARc65cT6uDQ.png" /></figure><p>I used some linux commands such as <strong>ps </strong>to dig into the <strong>haproxy </strong>processes of IDs = 117277 and 117279 and found the root cause. The original <strong>killall -0 haproxy</strong> check failed with permission errors and “<em>no process found</em>” because there are two HAProxy processes, running as the <strong>root </strong>and the <strong>haproxy </strong>user, while the script executed as a non-privileged user without permission to signal them. (See the fantastic explanation on a StackOverflow article <a href="https://serverfault.com/questions/911514/haproxy-two-processes-are-created-by-systemd#:~:text=This%20is%20not%20correct.,51:33%20+00:00">regarding what is going on</a>)</p><p>I then quickly rewrote the script in the <strong>/etc/keepalived/keepalived.conf </strong>file on both boxes to <br>use <strong>script “/usr/bin/systemctl is-active haproxy” </strong>to see if haproxy is up and running</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*dEZrOfHA0yhcw86S4pP3sw.png" /></figure><p>Then I used <strong>sudo systemctl restart keepalived </strong>again and looked at the logs with the <strong>sudo journalctl -u keepalived -n 30 </strong>command. The logs showed that everything was working, and so did the <strong>ping 192.168.253.200 </strong>and <strong>curl 192.168.253.200</strong> commands.</p><p>Excellent. I need to note that during the course of this extensive troubleshooting I brought the <strong>haproxy </strong>service back online on the <strong>haproxy1 </strong>machine.</p><p>I think that I was now ready to do the final testing. I also took two screenshots of the final configuration in the <strong>/etc/keepalived/keepalived.conf </strong>file and put it into the appropriate folder in my <a href="https://github.com/secnate/HomelabSeriesGithubRepo">Homelab GitHub repository</a>.</p><p><strong>Section V: Final Failover Tests</strong></p><p>With all the troubleshootings done, I needed to do the final failover testing from my Windows homelab workstation</p><p><strong>Initial State: haproxy is running on both machines</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/532/1*SieDBzZztwPfAFBAum5NJg.png" /></figure><p>Pass!</p><p><strong>Test #1: Shut Down haproxy1 VM</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/526/1*OuLPXcdOp_rGuAg1PfWW3g.png" /></figure><p>Pass! I also took a look into the logs with the <strong>sudo journalctl -u keepalived -n 30 </strong>command and confirmed that <strong>haproxy2 </strong>entered the <strong>MASTER STATE </strong>as expected.</p><p><strong>Test #2: Turned On haproxy1 VM and Stopped haproxy</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*u0wlWzb3CVbFAJRhfE5sgg.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/529/1*PSWxkL1Uld2n0Z0oNtuycQ.png" /></figure><p>Pass! I also took a look into the logs with the <strong>sudo journalctl -u keepalived -n 30 </strong>command and confirmed that <strong>haproxy2 </strong>entered the <strong>BACKUP STATE</strong> when I booted the <strong>haproxy1 </strong>VM on and then when I shut down that service, it reverted into <strong>MASTER STATE </strong>— as expected.</p><p>With all the tests complete, I quickly used the <strong>sudo systemctl start haproxy </strong>command on <strong>haproxy1 </strong>to get it back up and running.</p><p><strong>Section VI: Automate HA Cluster With Ansible</strong></p><p>To start, I needed to create a new VM in Proxmox for running Ansible with IP address = 192.168.253.104/24. The specs were as follows:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*_Sh5AajCUsp4yXumnNQR9A.png" /></figure><p>I created this VM to act as my Ansible control node and jump box. This is similar to how a real environment would handle automation — a dedicated machine would manage other nodes over SSH rather than running tooling directly on a Windows workstation.</p><p>Setting up the a<strong>nsible-ctrl</strong> VM began with attempting to install Ansible directly using <strong>sudo apt install ansible -y</strong>. This failed immediately because the VM could not reach the package repositories.</p><p>The root cause was the same issue that I experienced with the other three VMs that I installed: the default netplan configuration that shipped with the minimal Ubuntu Server installation was missing a gateway entirely, which meant the VM had no route out of the subnet and could not resolve DNS or reach the internet despite having <strong>8.8.8.8</strong> configured as a nameserver. Making things harder, the minimal Ubuntu Server installation came with no text editors — neither nano nor vi were present — so the only way to write the correct netplan configuration was using the <strong>sudo tee /etc/netplan/50-cloud-init.yaml &lt;&lt; EOF </strong><a href="https://linuxize.com/post/bash-heredoc/#:~:text=in%20shell%20scripts-,Heredoc%20Syntax,not%20quoted%2C%20the%20shell%20performs:">heredoc command</a> to write the file directly from the terminal.</p><p>Once the corrected <strong>50-cloud-init.yaml</strong> was applied with the VMware’s NAT gateway (the VMs’ only route to the Internet) at <strong>192.168.253.2</strong>.properly specified, connectivity was restored and both <strong>iputils-ping</strong> and <strong>nano </strong>installed cleanly — after which Ansible installed without issue.</p><p>To enable passwordless SSH access from <strong>ansible-ctrl</strong> to the three managed nodes, I generated an SSH key pair on <strong>ansible-ctrl</strong> using the <strong>ssh-keygen -t ed25519 </strong>command and copied the public key to <strong>haproxy1</strong>, <strong>haproxy2</strong>, and <strong>web1 </strong>using <strong>ssh-copy-id</strong>. Notice that I selected the <a href="https://messari.io/copilot/share/understanding-ed25519-ee5c6e19-3f05-4557-ac8f-9a1e84b9e8ff">Ed25519 algorithm</a> for generating the keys over the older RSA algorithm; it produces shorter keys that are faster to generate and verify, offers stronger security per bit, and is considered the modern standard for SSH authentication.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/460/1*tXY4ifxvVZVdMto4mz5BFA.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/801/1*mN_uherz4TrTB1UT_ojIWw.png" /></figure><p>I then quickly tested that the passwordless SSH worked to all three target boxes before proceeding.</p><p>After that, I got to work on creating the inventory file (<strong>hosts.ini</strong>) on my <strong>ansible-ctrl </strong>machine</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/458/1*rhLXGfpi41-ZBZuuHCV-_g.png" /></figure><p>The syntax is fairly self-explanatory. The <strong>[haproxy] </strong>and <strong>[webservers] </strong>are the group definitions, while the <strong>[all:vars] </strong>is defining the variables for the <strong>[all] </strong>group — that is all the servers defined in the <strong>hosts.ini</strong> file. Per <a href="https://docs.ansible.com/projects/ansible/latest/inventory_guide/intro_inventory.html#default-groups">Ansible documentation</a> and <a href="https://docs.ansible.com/projects/ansible/latest/inventory_guide/intro_inventory.html#inheriting-variable-values-group-variables-for-groups-of-groups">another section</a> of the same documentation page:</p><blockquote>Even if you do not define any groups in your inventory, Ansible creates two default groups: <strong>all </strong>and <strong>ungrouped</strong>. The <strong>all </strong>group contains every host. The <strong>ungrouped </strong>group contains all hosts that do not belong to any other group….</blockquote><blockquote>You can apply variables to parent groups (nested groups or groups of groups) as well as to child groups. The syntax is the same: :<strong>vars </strong>for INI format and vars: for YAML format</blockquote><p>Excellent. Now it was time for the playbooks. As explained in the previous Ansible homelab, they ensure that everything is done consistently across environment and reduce risk of config drift — a common cause of outages in real clusters.</p><p><strong>Playbook #1: Deploy Nginx And Website On Webserver</strong><br>This one deployed nginx and an updated version of the website’s <strong>index.html</strong> file. This playbook was heavily based off some research and <a href="https://vidushibansal.hashnode.dev/ansible-playbook-running-nginx-on-ec2-servers">a blog post</a>:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/692/1*OiFvqUYmqPDVlQvH7cZXEA.png" /></figure><p>I then ran it, with the main change on the <strong>web1 </strong>server being that the VM got an updated <strong>index.html</strong> file installed on it, with the other steps of installing <strong>nginx </strong>and getting it running not being needed — all this was done before</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*HU6AI1QtOxNUYH_rwaq9dw.png" /></figure><p>And curling the website from my homelab workstation’s command prompt, I got this:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/505/1*N1cxutPwWRnx5ZBSAhlFqg.png" /></figure><p><strong>Playbook #2: Deploy Keepalived<br></strong>When starting to work on this playbook, it was important to keep in mind that the configurations for Keepalived between both <strong>haproxy1 </strong>and <strong>haproxy2 </strong>are basically the same — except for the state information (MASTER versus BACKUP — there can be only one master and potentially multiple backup servers) and the priority number (the BACKUP server with the largest priority number will take over if the MASTER fails).</p><p>Because the playbook had to be extendible and flexible, I went with <a href="https://docs.ansible.com/projects/ansible/latest/playbook_guide/playbooks_templating.html">a template-based approach</a>. The first thing that I needed to do was to create a <strong>host_vars</strong> directory. <a href="https://developers.redhat.com/learning/learn:ansible:get-started-ansible-playbooks/resource/resources:ansible-playbook-variables-and-facts#:~:text=Variables%20in%20playbooks%20are%20defined,play%20they&#39;re%20declared%20in.&amp;text=Ansible%20playbook%20variable%20example.&amp;text=The%20package_name%20variable%20is%20set%20to%20httpd%20.&amp;text=Instead%20of%20using%20httpd%20in,using%20%7B%7B%20%7D%7D%20Jinja2%20brackets.">Per Red Hat’s explanation</a>, “Ansible automatically loads variable files into playbooks in the host_vars and group_vars folders based on the host or group name and the associated file name.” Inside the <strong>host_vars </strong>folder, I then created two YAML files titled <strong>haproxy1.yml </strong>and <strong>haproxy2.yml </strong>with the desired values</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/339/1*2Sdzeilm4x0yTxA66xGaZQ.png" /></figure><p>Notice that the files were deliberately named <strong>haproxy1.yml </strong>and <strong>haproxy2.yml </strong>— Ansible would automatically parse the file names out and map them to the hosts of the same name.</p><p>Then I needed to make my configuration template that the playbook would read and work with. <a href="https://docs.ansible.com/projects/ansible/latest/collections/ansible/builtin/template_module.html">Ansible’s <strong>ansible.builtin.template </strong>module</a> uses and processes files structured in the Jinja2 templating language (with the <strong>.j2 </strong>extension). To get started, I quickly made the <strong>templates </strong>subdirectory folder (<a href="https://www.redhat.com/en/blog/ansible-templates-configuration#:~:text=Define%20a%20template,name%20and%20the%20.j2%20extension.">which Ansible automatically searches for by default</a>) and then created the <strong>keepalived.conf.j2 </strong>file.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/382/1*23hibrs8jKgm_VEDQY5_ZQ.png" /></figure><p>Then I created the <strong>keepalived.yml </strong>Ansible playbook</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/501/1*H_loN9mZHkvkv3rpeRym-g.png" /></figure><p>And executed it successfully</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/851/1*orxH1p1Mlikp8kCF3DUqqw.png" /></figure><p>Beautiful.</p><p><strong>Playbook #3: Configure and Deploy HAProxy</strong></p><p>This playbook would leverage the same template-based approach, explained in details in the section for <strong>Playbook #2</strong>. I quickly took a look at the <strong>/etc/haproxy/haproxy.cfg</strong> files on both the <strong>haproxy1 </strong>and <strong>haproxy2 </strong>servers and confirmed that they were the same. Therefore, the template that I needed to make would not require variables to be injected — for this time.</p><p>Off to work. I went into the <strong>templates </strong>subdirectory folder and created an <strong>haproxy.cfg.j2</strong> file</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*NbShxT8RDf4limo22Q-Qmg.png" /></figure><p>Then I created the <strong>haproxy.yml </strong>Ansible playbook</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/616/1*dHKfAnaJLO8ghhmt2FyufQ.png" /></figure><p>And executed it successfully</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*FUD_jBP16ZqS7Sd-t1Sh1A.png" /></figure><p>What started as a straightforward goal — eliminate the single point of failure from a load balancer setup — turned into a comprehensive exercise in building, breaking, and systematically debugging a production-like high-availability cluster from the ground up. The troubleshooting gauntlet across Section IV was the real education: every issue with Keepalived’s script execution, permission model, and process checking was a lesson that no tutorial would have taught as effectively as hitting it firsthand. The last section with the Ansible playbooks built on Ansible experience from past homelabs and taught me how templating with variables works — which allows for flexible deployment among multiple systems in one swoop.</p><p>Comprehensive screenshots detailing the configuration settings and Ansible playbooks can be found on my <a href="https://github.com/secnate/HomelabSeriesGithubRepo">Homelab GitHub repository</a>.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=b94feff6864c" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Homelab #16: ExternalSecrets, DevSecOps, and GitOps]]></title>
            <link>https://secnate.medium.com/homelab-16-externalsecrets-devsecops-and-gitops-0018c48eaa8c?source=rss-f95ccfcb96af------2</link>
            <guid isPermaLink="false">https://medium.com/p/0018c48eaa8c</guid>
            <dc:creator><![CDATA[Nathan Pavlovsky]]></dc:creator>
            <pubDate>Thu, 19 Feb 2026 02:38:45 GMT</pubDate>
            <atom:updated>2026-02-19T03:08:07.430Z</atom:updated>
            <content:encoded><![CDATA[<p>I’ve always wondered how modern DevOps teams deploy applications securely dozens of times per day without breaking production, and I finally decided to find out by building my own GitOps pipeline with DevSecOps integrations. The answer lies in three powerful paradigms: secrets management, DevSecOps, and GitOps. In this homelab project, I brought enterprise-grade deployment practices into my lightweight home environment using ArgoCD and HashiCorp Vault — as well as practicing the skills that I developed in past homelabs in a “comprehensively whole” exercise.</p><p><strong>DevSecOps</strong> is the practice of integrating security checks directly into the development and deployment pipeline rather than treating it as a separate gate at the end. Think of it as “shifting security left” — catching vulnerabilities, misconfigurations, and compliance issues early in the development process when they’re cheap and easy to fix, not after code reaches production. This automated approach ensures that every change is vetted for security issues before it can be deployed, turning security from a manual bottleneck into an automatic quality gate that runs at machine speed.</p><p><strong>GitOps </strong>is a declarative approach to infrastructure and application deployment where Git becomes your single source of truth. Instead of running manual kubectl commands or clicking through dashboards to deploy changes, one simply pushes code to a Git repository. My GitOps tool (ArgoCD) continuously watched that repository and automatically synced any changes to my Kubernetes cluster. This gives powerful benefits: automatic rollbacks, complete audit trails (every change was a Git commit), and disaster recovery became as simple as pointing a new cluster at my repository.</p><p><strong>ArgoCD </strong>[<em>Argo Continuous Delivery</em>] is the GitOps controller that makes magic happen. It’s a Kubernetes-native continuous delivery tool that monitored my Git repositories and ensured my cluster matched what I defined there. When a change is committed — say, bumping an application from 2 to 3 replicas — ArgoCD detects it within minutes and applies them automatically. It provides a beautiful web UI where one can visualize the applications, see what is in sync or out of sync, and manually trigger syncs when needed. ArgoCD is particularly elegant due to its “pull” model: it pulls updates from Git.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/768/1*n2vu4fZIOP0ncWNfrw_y3Q.png" /><figcaption><em>The Following Image Is A Sample Illustration of how GitOps Works </em>[Source: <a href="https://www.devopsschool.com/blog/how-to-do-gitops-deployment/]">https://www.devopsschool.com/blog/how-to-do-gitops-deployment/]</a></figcaption></figure><p><strong>HashiCorp Vault</strong> is the industry-standard secrets management solution that securely stores, accesses, and manages sensitive data. Think of it as a hardened database specifically designed for secrets — it encrypts everything at rest, provides fine-grained access controls, maintains detailed audit logs of who accessed what and when, and even supports dynamic secrets that expire automatically.</p><p><strong>Kubernetes External Secrets Operator (ESO) </strong>is a Kubernetes operator that synchronizes secrets from external secret management systems into your cluster. Think of it as a bridge between your secrets provider and Kubernetes — it automatically fetches secrets from external APIs like HashiCorp Vault or AWS Secrets Manager, creates native Kubernetes Secret objects from them, and continuously reconciles to keep everything in sync when the upstream secrets change.</p><p>At its core, the <strong><em>External Secrets</em></strong><em> Operator</em> consists of three key components: the <strong><em>ExternalSecret</em> </strong>custom resource (where you define which external secrets to sync and how), the <strong><em>SecretStore </em></strong>or <strong><em>ClusterSecretStore</em> </strong>(which configures the connection to your external provider like Vault, including authentication and path details), and the <strong><em>controller </em></strong>(which runs in the cluster, watches for changes in these custom resources, fetches the actual secrets from the provider, and creates/updates the corresponding Kubernetes Secrets automatically).</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*z41OsUnzfWvK6GTWyqjJ6g.png" /><figcaption><em>The Following Image Is A Sample Diagram Regarding What The Kubernetes External Secrets Operator Does </em>[Source: <a href="https://www.linkedin.com/pulse/better-way-manage-kubernetes-secrets-hashicorp-vault-external-can-hpqzc/]">https://www.linkedin.com/pulse/better-way-manage-kubernetes-secrets-hashicorp-vault-external-can-hpqzc/]</a></figcaption></figure><p>Having it properly work and configured is also important for another reason. We need to be able to ensure the “separation of concerns” in DevOps — where one can be able to migrate between different secrets managers — such as AWS Parameter Store, Azure Secrets, Google Cloud Secrets, Hashicorp Vault, etc — and not need to go and modify 50+ projects.</p><p>That was a lot of theory. Now for the action!</p><p><strong>Section I: Kubernetes (K3s) Setup<br></strong>I needed a Kubernetes cluster to run my stuff, but spinning up a full production-grade Kubernetes installation would have consumed massive resources on my homelab workstation. That’s where K3s came in — it’s a lightweight, certified Kubernetes distribution designed specifically for resource-constrained environments like edge devices, IoT, and yes, homelabs.</p><p>This is different from the Minikube that I used in the <a href="https://secnate.medium.com/week-9-kubernetes-basics-with-multi-cloud-integration-ad0d53643c30">Kubernetes homelab</a>. K3s runs as a native Linux service with a stripped-down architecture perfect for edge computing, IoT environments, and other resource-constrained environments, while Minikube is designed for local development and testing with VM-based isolation that adds overhead and is typically torn down after each session.</p><p>K3s are designed to run on Linux platforms. Given that my homelab workstation is a Windows machine, I started by getting the <em>Windows Subsystem for Linux </em>installed and configured. For those <a href="https://learn.microsoft.com/en-us/windows/wsl/about">who don’t know what it is</a>, it is a Windows feature that allows you to run Linux on my box without installing a separate VM or doing dual booting. This was done per the following screenshot:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/648/1*y2fk9G_Ky4NzxLWMIszjig.png" /></figure><p>Then, I needed to get K3s installed. I did this by launching the installation script from the <a href="https://docs.k3s.io/quick-start"><em>Quick Start</em> guide</a> with the following command: <strong>curl -sfL </strong><a href="https://get.k3s.io/"><strong>https://get.k3s.io</strong></a><strong> | sh -</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/947/1*PxuWLeF9uuTtKBrfI6ixlg.png" /></figure><p>Next up was to make <strong>kubectl </strong>work easily from my regular user account (without needing sudo every time) by copying the admin kubeconfig file from <strong>/etc/rancher/k3s/k3s.yaml</strong> (root-protected) to the standard Kubernetes location at <strong>~/.kube/config </strong>in my home directory, with proper ownership.</p><p>First, the <strong>mkdir -p ~/.kube </strong>command created the hidden <strong>.kube </strong>directory in my home folder. Then, the <strong>sudo cp /etc/rancher/k3s/k3s.yaml ~/.kube/config </strong>command copied the powerful original <a href="https://docs.k3s.io/cluster-access">admin kubeconfig file</a> into the <strong>~/.kube/config</strong> directory. Then, the <strong>sudo chown $USER:$USER ~/.kube/config </strong>command changed the ownership of the file from root to my username — ensuring that I could read the file and run Kubernetes commands seamlessly without the <strong>sudo </strong>keyword.</p><p>This allowed me to then execute the <strong>kubectl get nodes </strong>command (without the sudo keyword) — to verify that everything was working. Not needing to invoke the <strong>sudo</strong> keyword aligns with the principle of least privilege (kubectl doesn’t need root on your host OS), and honestly, typing sudo every time gets old fast.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/730/1*to-OH60hqekqqD6XGan1fA.png" /></figure><p><strong>Section II: Relevant Theory</strong></p><p>In my previous homelabs, I deployed applications to Kubernetes by hand-writing YAML files for each resource. That worked fine for simple setups — a Deployment here, a Service there. But real-world applications like Vault are far more complex. They require dozens of interconnected resources: Deployments, Services, ConfigMaps, Secrets, RBAC roles, and more — all carefully configured to work together. Writing and maintaining all of that by hand would be error-prone and time-consuming.</p><p>That’s where <strong>Helm </strong>comes in. <strong>Helm </strong>is essentially a package manager for Kubernetes, similar to how <strong>apt </strong>manages software on Ubuntu. <strong>Helm Charts </strong>bundle all of those resources into a single, pre-configured, reusable package. Instead of writing dozens of YAML files myself, a Helm Chart allows me to deploy Vault with one command.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/927/1*yveAeN_-irT_-a7foDpkGg.png" /><figcaption><em>Explanatory Diagram Is Taken From </em><a href="https://circleci.com/blog/what-is-helm/#:~:text=Helm%20charts,an%20application%20with%20different%20configurations."><em>A CircleCI Blog</em></a></figcaption></figure><p>I dug around for some additional explanations regarding what Helm Charts are, and found this <a href="https://medium.com/@ksaquib/what-the-heck-are-helm-charts-a-deep-dive-into-the-kubernetes-package-manager-0512644eb3ed">fantastic Medium writeup</a>.</p><p>I then took advantage of an opportunity to refresh myself on how YAML manifests work in Kubernetes after finding a <a href="https://medium.com/@ayoubajdour20/from-chaos-to-code-why-yaml-manifests-are-your-kubernetes-superpower-even-if-you-hate-yaml-ae634ab5b289">fantastic Medium blog post</a> that broke down the structure of Kubernetes YAML files in a refreshing and intuitive way. I need to give a big big thank you to Ayoub Adjour for taking the time to do this — I previously used to hate Kubernetes and now feel a lot better about how it works. <strong>If you ever work with Kubernetes, please save this article. It will help you.</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/602/1*SMcfEJ0sQNdcmgn31bqfHA.png" /><figcaption><em>This Visual Breakdown Is From The Blog Post — And It Is Quite Useful</em></figcaption></figure><p><strong>Section III: Helm Chart Installation</strong><br>I then installed Helm in my WSL Ubuntu instance using the steps described in a DevOpsCube tutorial (specifically <a href="https://devopscube.com/install-configure-helm-kubernetes/">those outlined in Method #1</a>)</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*zcgTYq_Ba46fHTeLXn38Kw.png" /></figure><p><strong>Section IV: Approach For Setups &amp; Kubernetes Secrets Operator<br></strong>The goal of any Cloud or DevOps engineer is to set up infrastructure in such a way that it is (1) simple to do, (2) reduces cognitive load, and (3) is idempotent [i.e. is repeatable and unchanged in final result state each time we got things working].</p><p>To do this, I created a folder in my homelab GitHub repository, and created a Makefile. This Makefile would first confirm that the required dependencies exist, and then kick off a series of shell scripts for getting everything set up.</p><p>The goal is to just have the <strong>make init</strong> command kicked off and successfully run everything, so that 6+ months down the line, someone can open up the repository, read the <strong>README.md</strong> explanation text, and then get everything set up in one fell swoop.</p><p>I then started with the initial steps for the Kubernetes Secrets operator. A friend of mine pointed me in the direction of the following documentation, so that’s what <a href="https://external-secrets.io/latest/introduction/getting-started/">I referenced for the following steps</a>.</p><p>First, for my initial Makefile. It looked like this:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/746/1*YvIjaG64reniMh-spTHgdA.png" /></figure><p>This Makefile starts with <strong>.PHONY</strong> to declare that <strong>init </strong>and<strong> check-deps</strong> are command targets rather than actual files, preventing conflicts if files with those names exist. It then automates the setup of External Secrets Operator by first verifying that all required tools (helm, kubectl) are installed and that the K3s cluster is accessible, then running the installation script. The <strong>check-deps</strong> target acts as a safety gate — if any dependency is missing or the cluster is unreachable, it fails with a clear error message before attempting the actual setup of the external secrets operator in <strong>setup_external_secrets.sh</strong>.</p><p><strong>Preview For The Future: </strong>The approach is to have separate <strong>.sh</strong> script files for each aspect of the initialization/configuration of the infrastructure: one for setting up the Kubernetes external secrets operator, one for setting up the Hashicorp Vault, one for setting up the ArgoCD application, etc and etc. Additionally, there would be separate <strong>.sh </strong>script files to be able to tear everything down completely that was previously created and installed — with nothing left over.</p><p>With the reference to the <strong>./setup_external_secrets.sh</strong> script ready, I then got to work by creating the file for setting up the External Secrets Operator. This script was based on the steps enumerated in <a href="https://external-secrets.io/latest/introduction/getting-started/">the corresponding documentation</a>. It looks like this</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/873/1*hgvEFhtpamcbuV9oIf-yXg.png" /></figure><p>Notice the two lines commented out at the very bottom. Those would be addressed at a later time in the script once there is a Vault up and running with the appropriate secrets for the External Secrets Operator to pull information from. I then quickly did a little test of what I had by executing <strong>make init</strong> in the command-line and confirming that things are running with the <strong>kubectl get pods -n external-secrets </strong>instruction</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*xDueXElRHsXjnbpkwfsfMw.png" /></figure><p>I then took advantage of the opportunity to get an uninstallation capability added into the Makefile and made a separate <strong>uinstall_external_secrets.sh</strong> shell script file — since I wanted my Makefile to be able to both create and destroy in one fell swoop my entire GitOps environment. Here is what both looked like now</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/745/1*uiYM4eXe4wkIbXhdLaEnCg.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/677/1*oQAkWirF5IF1ZbP82U_3zw.png" /></figure><p>Wonderful. The scripts’ instructions seemed pretty self-explanatory to me, so I won’t bother expanding on them with explanations further. Let’s test it</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*THwPPPsRwmOjOxqQpNQKWg.png" /></figure><p>Looks delicious. Notice that I deliberately did not finalize everything in this step. This is because I needed to get some other pieces finalized properly before returning back to this one.</p><p><strong>Section V: Setting Up The HashiCorp Vault</strong></p><p>Next up was to make a separate shell script for installing and uninstalling the HashiCorp Vault, and then to integrate it into the Makefile. Notice that for this round, I did not introduce any secrets into the Vault at this time. My goal in this section was to just be able to spin it up and spin it down — and ensure that it could be interacted with in the UI.</p><p>I did this by first specifying the following YAML file for the configuration of the HashiCorp Vault. Notice that it was heavily based off a <a href="https://github.com/hashicorp/vault-helm/blob/main/values.yaml">documentation YAML file on the HashiCorp website</a><strong>.</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/826/1*cs9KzXz876t-uyvJPSZa9g.png" /></figure><p>Then I configured the <strong>setup_vault.sh</strong> and <strong>uinstall_vault.sh</strong> scripts to be able to spin it up and down whenever needed.</p><p><strong>setup_vault.sh</strong>:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ogt0M4Zl8HX9p2O5XqF3zw.png" /></figure><p><strong>uinstall_vault.sh:</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/720/1*LrL3q6s7nCG9oStGDCfigg.png" /></figure><p>And fed them into the Makefile appropriately — see why I had separate scripts for configuring things?</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/837/1*_XuoLafVAqnCely9j541rA.png" /></figure><p>Notice that the screenshots of the files are what was done after a pile of troubleshooting steps and tweaks that I had to take to get the scripts and the Makefile polished off and working. Finally, after I got things working, I used the <strong>make init</strong> command to spin up the infrastructure — and got the following</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*iJwuHG6zyA8OKfuOBCAOcw.png" /></figure><p>And was able to open Vault at <a href="http://localhost:8200/"><strong>http://localhost:8200</strong></a><strong> </strong>— and then logged in using the default token of value “root” (which was specified in the YAML file)</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*SfXcb3dbHeK7zwmmczGbqg.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*DPWYkfpiQRjTqEfcBxctkw.png" /></figure><p>Epic. Then I quickly used the <strong>make uninstall </strong>command to spin everything down</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*rdrIDpsBR1zW7JS9_k1zaw.png" /></figure><p>And a quick browser check confirmed that I could not find anything about the <a href="http://localhost:8200/"><strong>http://localhost:8200</strong></a><strong> </strong>address.</p><p><strong>Side Note<br></strong>When I first set up HashiCorp Vault using the official Helm chart in my GitOps homelab cluster (running via Docker Desktop’s Kubernetes on WSL2), the Vault UI service was correctly exposed as a NodePort on 8200 — but nothing loaded at <a href="http://localhost:8200/"><strong>http://localhost:8200</strong></a> in the browser. This turned out to be a classic WSL2 networking quirk: NodePort bindings don’t automatically forward to the Windows host’s localhost loopback interface, even though curl worked fine from inside WSL.</p><p>Incorporating the<strong> kubectl port-forward svc/vault-ui 8200:8200 &amp;</strong> command into my <strong>setup_vault.sh</strong> script bridged the gap instantly, letting me access the UI reliably at <a href="http://localhost:8200/"><strong>http://localhost:8200</strong></a><strong> </strong>from my Chrome browser on the Windows homelab workstation. This was a lesson learned the hard way about WSL2 + Docker Desktop Kubernetes networking gotchas!</p><p><strong>Section VI: Sample “Hello World” — Style Application</strong></p><p>I needed to get a sample “Hello World” — style application spun up in this section of the lab and successfully deployed and decommissioned with the Makefile commands. The reason for this step is that I needed a sample application for usage in this lab’s next section, which would involve setting up and testing the secret injection using the Kubernetes External Secrets Operator.</p><p>I fortunately had prior experience <a href="https://secnate.medium.com/homelab-12-ci-cd-pipeline-with-jenkins-terraform-and-multi-cloud-39746b1b7ba0">in a past CI/CD homelab</a> with making a sample Flask application, so I quickly opened up its <strong>Section II</strong> and followed the steps to make an application. This is why I like writing my work up on Medium — these writeups are a good go-to resource in times of need!</p><p>The final <strong>index.html </strong>file looked like this:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/560/1*sw3mx7AmyOqQKKKIP5zybA.png" /></figure><p>Great. Now I needed to create a setup script that builds the Docker container for usage and launches the application in Kubernetes, as well as an uninstall script that would take the Kubernetes-run application down when needed. This took a lot of time to get working and debugged, but I finally did it.</p><p>The install script built and pushed the Flask container image to Docker Hub, then deployed the application to Kubernetes in the app namespace using a <strong>Deployment </strong>and <strong>NodePort Service</strong>. It waited for the pods to become ready and started a local port-forward so the app was immediately accessible at <a href="http://localhost:5000/"><strong>http://localhost:5000</strong></a> (remember the WSL networking quirk we discussed earlier?). Two YAML files handled the Kubernetes side: the Deployment kept three replicated pods running with the container image (pulling from Docker Hub and exposing port 5000 internally), while the Service provided a stable external entry point via NodePort so the app could be reached from outside the cluster.</p><p>The companion uninstall script reversed everything: it removed the image and repository from Docker Hub, uninstalled Docker Engine, deleted the Kubernetes Deployment and Service, terminated any lingering port-forward processes, and force-deleted the <strong>app </strong>namespace — even cleaning up hidden Kubernetes objects like events or EndpointSlices that sometimes prevent normal deletion.</p><p>Once everything was up and running, the application could be opened in the browser at <a href="http://localhost:5000/"><strong>http://localhost:5000</strong></a> — and looked like this:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*-2UnXF1nF-BgJGa-9azCJw.jpeg" /></figure><p>Having the ability to cleanly and completely create and tear down this application is very important both for this lab and in general when working with DevOps. Feel free to check out the <a href="https://github.com/secnate/HomelabSeriesGithubRepo/tree/main">GitHub repository</a> for this lab to see exactly how the code and Kubernetes manifests work.</p><p><strong>Section VII: Getting Secrets Manually Entered Into The Vault</strong></p><p>With the sample Hello World application now cleanly deployable and removable via Makefile commands, the next logical step was to prepare for testing real secret injection into it using the External Secrets Operator — the core focus of this homelab.</p><p>To do this, I modified the Vault setup script to pause after Vault was running and prompt the user (in colorful, step-by-step console messages) to manually log into the Vault UI at <a href="http://localhost:8200,"><strong>http://localhost:8200</strong>,</a> enable a KV secrets engine, and enter a small set of realistic demo secrets: an API key, database password, Stripe secret key, app environment flag, and a feature toggle boolean. These secrets — stored under paths like <strong>secret/apps/hello-world</strong> — would later be read by the External Secrets Operator and injected into the application.</p><p>Here is what I got in the console now after running the <strong>make init </strong>command:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*WWqYt8ES1muyyLxuoYk7Lw.png" /></figure><p>I then went into the Vault and tested the process of manually entering them per the prompt. Here is what I got:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*iR4N0mmAR-AlXNxwLIlH-Q.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*-8gjuQqjolf6YHlyW4TMiA.png" /></figure><p>Now the stage has been set for doing what this lab is <strong>really about</strong>.</p><p><strong>Section VIII: Setting Up Secret Injection</strong></p><p>This is where I started to glue all the fun together. In the previous sections, I made a sample application, a prompt to the user creating the environment to go into the Vault and manually enter a predefined series of secrets, and the initial steps for getting the <a href="https://external-secrets.io/latest/introduction/overview/#architecture">Kubernetes External Secrets Operator</a>. Now, I needed to get the setup and usage of the Kubernetes <em>External Secrets Operator</em> over the finish line.</p><p>The following diagram shows how data flows from external providers like Vault through the <em>External Secrets Operator</em> and on into the final application:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/859/1*0Fi45Xi4yCmzBZZjmCaeEA.png" /><figcaption><em>Image Is From </em><a href="https://oneuptime.com/blog/post/2026-01-19-kubernetes-external-secrets-vault-integration/view"><em>https://oneuptime.com/blog/post/2026-01-19-kubernetes-external-secrets-vault-integration/view</em></a><em> — Many Thanks To The Author</em></figcaption></figure><p>To copy the wise words of a fantastic <a href="https://github.com/digitalocean/Kubernetes-Starter-Kit-Developers/blob/main/06-kubernetes-secrets/external-secrets-operator.md">GitHub-based writeup</a>: “The <em>ExternalSecret </em>specifies what to fetch, [and] the <em>SecretStore </em>specifies how to access [it].”</p><p>The first thing I did get moving in this direction was to add in and test some “guardrail” code to the <strong>setup_external_secrets.sh</strong> shell script file to verify and ensure that Vault was installed, running, and ready before continuing to install and finalize the External Secrets. This is because properly configuring and connecting the External Secrets Operator to the Vault naturally requires a Vault instance to be up and running beforehand.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/778/1*Mn6qfC5VFYVbxdAvD6XYpw.png" /></figure><p>And it is precisely for this reason why my Makefile for the <strong>make init</strong> command had <strong>setup_vault.sh </strong>being invoked before the <strong>setup_external_secrets.sh</strong> script. Order matters.</p><p>With Vault confirmed healthy, the script then creates the <strong>app </strong>namespace, installs the <em>External Secrets Operator </em>(ESO) via Helm with the crucial <strong>— set installCRDs=true</strong> flag, and waits patiently for everything to settle. After that, it proceeded to configure Vault authentication, writing a read-only <strong>demo-policy</strong>, creating the <strong>demo-role</strong> bound to the <strong>default </strong>service account in the <strong>app </strong>namespace, and finally applied the <em>SecretStore </em>(connection details) and <em>ExternalSecret </em>(sync instructions).</p><p>And then my automated shell script did some final checks to confirm that the <em>Secret Store </em>and <em>ExternalSecret </em>were successfully set up</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*T_q1TT5-B5IIMhGMPU8R4Q.jpeg" /></figure><p><strong>Side Note: </strong>It is important to briefly touch upon CRDs (Custom Resource Definitions). They are Kubernetes extensions that teach the cluster about entirely new resource types — in this case <em>SecretStore </em>and <em>ExternalSecret </em>— that don’t exist in vanilla Kubernetes. Enabling them during installation is essential because without those definitions the API server wouldn’t recognize or let you create the YAML files that tell ESO how to connect to Vault and which secrets to sync. Skipping or delaying the CRDs would cause immediate kubectl apply failures with messages like “no matches for kind <strong>SecretStore</strong>,” so the script explicitly waits for them to be fully established.</p><p>The next big task was to get these secrets fed into the Flask application’s container as environment variables per the <a href="https://external-secrets.io/latest/guides/all-keys-one-secret/">appropriate documentation page</a>. To lay the groundwork for this step, I first went into the <strong>helloworld-deployment.yaml</strong> page which specified the <strong>helloworld-app </strong>container’s deployment and per a Kubernetes documentation page first created five sample environment variables for the container with some hard-coded text values.</p><p>The goal for this step was to ensure that my tools are working inside the container, so that I am not forced later to chase my tail debugging toolset-side issues later on when I am working on polishing off the final secret injection. This is what my <strong>helloworld-deployment.yaml</strong> file looked like:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/699/1*Kv1EHmN06y-CZtdlnORufA.png" /></figure><p>Delicious. I then finalized the Makefile as having all the scripts for setting up the vault, external secrets, and the Hello World app — in that exact order — and the scripts for tearing them down in the reverse uncommented. Then, I used the <strong>make init </strong>command to get everything set up. The <strong>kubectl get pods -A</strong> command then successfully showed all the pods that Kubernetes was running</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*wobWEhKmipNxom-9vE23hQ.png" /></figure><p>I then took the names for all the three pods that Kubernetes ran for this app and used the <strong>kubectl describe pod -n app &lt;POD_NAME&gt;</strong> to see the environment variables inside them. While I successfully did this over all three pods, the following screenshot will show the output for only one <strong>kubectl describe pod -n app hello-world-app-deployment-5df7d46d89-pqmkw</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*hV9nKLqNU7RCyvZUcAHg5A.png" /></figure><p>Notice that it was very important to have the <strong>-n app</strong> flag in the command to get the desired results. This is because Kubernetes namespaces are like separate workspaces, and <strong>kubectl </strong>commands default to searching in the <strong>default </strong>namespace unless you tell it otherwise. With no application pod in the <strong>default </strong>namespace, I would get error or a non-existent result for the command attempts.</p><p>Excellent. We cleared the first hurdle. Now it was time to get the secrets fed into the container from the <em>ExternalSecret</em> resource. To do this, I spun down the infrastructure that I previously created with the <strong>make uninstall </strong>command and <strong>carefully </strong>and <strong><em>slowly</em> </strong>read the <a href="https://external-secrets.io/latest/guides/all-keys-one-secret/">documentation page</a> to see how the secrets are to be passed into a Kubernetes-managed container, and got to work.</p><p>I made a mild tweak to the <em>ExternalSecret </em>resource YAML file to make its name be <em>hello-world-external-secret </em>— different from the name of the <strong>target/name: hello-world-secrets</strong> — since I didn’t want the two names to be exactly the same. The <em>ExternalSecret </em>resource YAML now looked like this:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/552/1*tfkhlNRwW5f5U6musewbzw.png" /></figure><p>And then I updated the <strong>helloworld-deployment.yaml </strong>file in accordance with the documentation. The secrets were to be piped into the container’s environment variables, so I modified the <strong>containers/env </strong>section — as seen per the following screenshot</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/781/1*fkvjZRs7uimm4YXnL-T21Q.png" /></figure><p>Notice that I explicitly referred to five secrets being fed in from the Kubernetes-run <strong>hello-world-secrets</strong> by name. The documentation had an interesting note at the bottom of <a href="https://external-secrets.io/latest/guides/all-keys-one-secret/">the page</a> that if “you have a large number of secrets and you want to pass all of them as environment variables” you can basically import them in one fell swoop. Because I only had five secrets and not a significantly larger number — and that the bulk of the documentation relied on the “name the secret explicitly” approach — I went with the approach of explicitly naming each one that I wanted to feed into the container.</p><p>Then I used my Makefile with the <strong>make init </strong>command-invoked scripts to launch the infrastructure setup, entered the script-specified five secrets into the Vault, and had it spin up the application pods. After everything was finalized, I used the <strong>kubectl get pods -A </strong>command to see what pods were up and running</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*wvNfJUQcaDRfa7rCyYRl6A.png" /></figure><p>Three application pods. Just as expected. I then used the <strong>kubectl exec -n app &lt;POD_NAME&gt; — env </strong>command on each of the three to see the environment variables inside them. While the following screenshot is for one specific pod, all three had the same result of my five secrets being successfully piped in:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*P1RJumVsqgHydIdaJVTaMw.png" /></figure><p>The last step was to have my application reference the environment variables that I fed into the container — and to present them onto the screen. To do that, I quickly added some application version and secret-presenting HTML code into my <strong>index.html</strong> file</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/793/1*26oG-PC2PdcWcsY2gMn0Gw.png" /></figure><p>And then opening up the browser, I got the final application presented</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/611/1*Ob7saRHTjERM5o5FKAsI2g.png" /></figure><p>Epic. It should be noted that in the real world, we would <em>never even consider</em> injecting secrets into a Web server since those are publicly accessible and liable to be pwned. <strong>This was only done here for homelab demonstration purposes.</strong></p><p>This was the most involved section of any homelab of mine to date, but at the same time, it was the most beneficial. Secrets need to be handled properly and I’m told many shops do not encapsulate and modularize the complexity with secrets management well. While this text-writeup of what I did was relatively brief, the code itself was significantly longer. For those interested, you can find it <a href="https://github.com/secnate/HomelabSeriesGithubRepo/tree/main">on my GitHub</a>.</p><p><strong>Section IX: CI Pipeline with DevSecOps Quality Gates</strong></p><p>Up until now I had been building Docker images of the application and pushing them to Docker Hub in a section of my Makefile-invoked <strong>setup_hello_world_app.sh </strong>shell script. This approach works in the context of a 1-man homelab but definitely isn’t how real teams ship dozens of times a day.</p><p>That’s why the next step was to build a proper <strong>CI Pipeline</strong>. The big idea of <strong>Continuous Integration</strong> is that every single code change gets automatically tested, built, scanned for syntax and security issues, and validated before it has any chance of reaching production. The goal is to answer the question each time code is pushed: <em>“Is this change safe and good enough to move forward?”</em></p><p>For actually running this CI pipeline I chose <strong>GitHub Actions</strong>. It’s the built-in CI/CD system that lives directly inside every GitHub repository — which made it the fastest and most natural choice. Because everything (code + manifests + pipeline definition) already lives in the same GitHub repo, I get zero extra accounts, no new webhooks to configure, and the pipeline can easily read my code, build the image, push it to Docker Hub, and even commit specific updates back to the same repo as needed!</p><p>To start, I needed to securely connect my CI pipeline to both Docker Hub and GitHub. To do this, I opened up GitHub → Settings → Secrets and variables → Actions</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*MRkuHEf4iXOyrqPFSQNiQg.png" /></figure><p>In a separate Window, I followed the steps outlined in the GitHub page for managing <a href="https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens">personal access tokens</a> to create a personal access token scoped on the <strong>HomelabSeriesGithubRepo </strong>with read and write permissions, so that a workflow could safely commit specific updates back into the repository.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*YxYaxeL5AOTtRtayqqSDqA.png" /></figure><p>After that, I quickly made the following Repository Secrets: <strong>DOCKER_USERNAME</strong> with my Docker Hub username, <strong>DOCKER_PASSWORD </strong>using a Docker Hub access token (instead of my account password for better security), and <strong>PERSONAL_ACCESS_TOKEN</strong> — the text string of that personal access token that I just made.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*DYhcwBDgOMi85dqeEFXXGQ.png" /></figure><p>Then I created a <strong>.github</strong> folder for the repository. It is a specialized directory used to store project-specific configurations, such as automation workflows (GitHub Actions), issue templates, and community health files.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/277/1*KugExgXlNHBrS16n68dRNA.png" /></figure><p>Epic. I then created inside it the <strong>workflows </strong>sub-folder. This sub-folder is <strong>**strictly required** </strong>for Workflow definitions. GitHub Workflows are the YAML configuration files that define automated CI and CD pipeline jobs. In this case, this is where I needed to place instructions for how my CI pipeline would work and operate.</p><p>With the groundwork laid, I created the <strong>ci.yml</strong> file inside the <strong>.github/workflows/</strong> folder to define my entire CI pipeline as code. This workflow automatically triggers on code changes, runs a series of quality and security gates (specifically YAML linting, CodeQL security scanning, Docker container building, and Trivy scanning the container for vulnerabilities). Then, if everything passes — it updates the Kubernetes manifest with the new image SHA and commits it back to the repo, creating a fully automated path from code push to deployment-ready artifact. <strong>This is DevSecOps in practice:</strong> <strong>automated security checks are baked into the pipeline</strong>, not bolted on afterward. Code changes deliberately should not be able to bypass them.</p><p>You can see the <a href="https://github.com/secnate/HomelabSeriesGithubRepo/blob/main/.github/workflows/ci.yaml">CI Yaml file on the GitHub page</a> with the actual steps. Notice that it was carefully configured to trigger only upon changes to the Homelab16 — GitOps and Secrets Management sub-folder, since this GitHub repository contains multiple homelab projects and I only wanted CI to trigger for changes specific to this project.</p><p>Excellent. After getting the CI YAML file pushed into the GitHub repository, I then committed and pushed a small test change to the <strong>app.py </strong>Flask application file to test it. As expected, the CI pipeline got activated — and I got the following for the first round of CI pipeline execution:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*_v1u2BBd78TqeWUKiUy3mQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*P3N8iIvUFLckpeax8yhZRQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*lWBFKexVBms02EjS5oIE0A.png" /></figure><p>The YamlLint did its job and provided 10 errors and 9 warnings regarding the way the YAML files were written and formatted. That is the entire point of having a linter! Notice that the CI job halted execution since this stage of the pipeline failed.</p><p>After going in through the findings and fixing them, I pushed the changes to the GitHub repository again — which triggered the pipeline. This time, I got through all but the third-from-last stage of the CI pipeline, with <a href="https://github.com/aquasecurity/trivy">AquaSecurity’s Trivy Container scanner</a> catching 3 High Severity vulnerabilities in Python packages.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*M_3u9QUacQX3L_Y4xrIo4A.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/750/1*Y1WrxpQX0kD1rQy_Sq-T-w.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*s8Jp6AMfHj2MS0NL1fdwow.png" /></figure><p>Houston, we have a big problem. Per the red boxes of the middle screenshot, we see that the three vulnerabilities were in the Python packages within the <strong>usr/local/lib/python3.10/site-packages/</strong> folder. Looks like the Docker container’s Python needed to be updated to a non-vulnerable version. We currently had the following at the container’s top:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/634/1*srysDn2xhieJhMLbHUEwBA.png" /></figure><p>I needed to get a more recent (and hopefully non-vulnerable) Python image for my container, so that it would have newer versions of these packages by default. I went onto the <a href="https://hub.docker.com/_/python">Docker Hub’s page for Python images</a>, found that the latest version is <strong>3.14-slim</strong>, rewrote the Dockerfile’s first line to be <strong>FROM python:3.14-slim</strong>, and then pushed the changes into the repository. This naturally triggered the CI pipeline again — and now we got this</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Ie6a1TW20fJshr7d-0TVPg.png" /></figure><p>Mixture of good news and bad news. The good news is that the fixing of the Dockerfile allowed us to get through the Trivy container-scanning quality gate. The bad news is that there was a permissions error that did not allow us to get through the final stage of this CI pipeline. The permissions error occurred because my Personal Access Token was missing the<strong> “Contents: Read and write”</strong> permission required for the workflow to push commits back to the repository. I regenerated the token through GitHub’s Developer Settings, ensuring it had both Contents (Read and write) and Workflows permissions, then updated the <strong>PERSONAL_ACCESS_TOKEN </strong>secret in the <strong>HomelabSeriesGithubRepo</strong> repository settings.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*_GW4NrD02CRbohHnm4agPA.png" /></figure><p>After pushing another test change, the CI pipeline executed successfully end-to-end — validating the code, building and scanning the container, updating the manifest with the new image SHA, deploying the container into DockerHub, and committing the change back to the repository automatically. However, this triggered a secondary issue: the bot’s commit created a line in the deployment manifest that exceeded yamllint’s default 80-character limit (due to the long commit SHA), causing subsequent CI runs to fail. I resolved this by configuring YamlLint to allow up to 120 characters per line (via a .yamllint config file placed in the <a href="https://github.com/secnate/HomelabSeriesGithubRepo/tree/main">HomelabSeriesGithubRepo</a> root folder), which accommodated the naturally long Docker image references while still catching genuinely problematic formatting.</p><p><strong>Screenshots of Successful CI Pipeline Execution:</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*WXWByoj-gdnol5Uds8VLZQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Zi9UzytIBvr0j4c0vFjotQ.png" /></figure><p><strong>Screenshot of the Github Action’s Commit </strong>— It automatically updated the <strong>helloworld-deployment.yaml </strong>file to ensure that the container image the application would pull in was of the right version — represented by the hash</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*3OIIPhFGPu_u8jxlAtNgRw.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*30soOTDfkMGeiBXiRNtuxg.png" /></figure><p><strong>Screenshot of the Image In DockerHub</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*dKUKVOPYGwVtqqjs6G---Q.png" /></figure><p><strong>One More Note Regarding What Was Done:</strong> Originally, when I started on the project, my <strong>helloworld-deployment.yaml </strong>file had the line <strong>“image: nathanpavl/sample_hello_world_flask_app:latest” </strong>to tell Kubernetes to pull the latest image. I ensured that Kubernetes would pull in a specific image represented by a hash, after it got built, passed the CI pipeline, and uploaded to DockerHub successfully. This takes advantage of the idea that everything in DevOps should be a hashable artifact — and is important from a security perspective: if an attacker got into Docker Hub and put in a new image, they would not be able to tamper with the application to make it execute a non-approved version (unlike the case of having the <strong>:latest</strong> tag in the Kubernetes manifest).</p><p>We went through a lot here. In hashing we trust — except MD5!</p><p><strong>Section X: Decommissioning Manual Application Deployment Scripts</strong></p><p>Remember I in the previous section created bash scripts for setting up the vault, secrets, and setting up the application — in that order? I also created their idempotent counterparts for cleanly and completely undoing the changes, and had them executed in the reverse order.</p><p>Due to the fact that I made the Continuous Integration (CI) pipeline, there was no further need for the <strong>setup_hello_world_app.sh </strong>and <strong>uinstall_hello_world_app.sh </strong>scripts anymore. The CI pipeline already cleanly [and far more securely] built the container image and pushed it into Docker Hub than anything the <strong>setup_hello_world_app.sh </strong>script could have done. Additionally, it is ArgoCD which will handle the Continuous Deployment and the actual execution and deployment of the CI-pipeline-built container image.</p><p>I put in a lot of effort and care into these two scripts, so I didn’t want to delete them. I instead created a <strong>scripts/old_scripts</strong> folder, moved those two into it, and removed the references to those in the Makefile.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/254/1*CC0ZdpjYTSvbjnD44FfPrw.png" /></figure><p><strong>Section XI: Continuous Deployment Pipeline With ArgoCD</strong></p><p>In the previous sections, I was able to build the application with the Continuous Integration pipeline running on GitHub Actions. The last part remaining was building a Continuous Deployment (CD) pipeline with ArgoCD. A Continuous Deployment pipeline automates the deployment of validated code changes to production environments — ensuring that every commit that passes CI automatically flows into the live system without manual intervention. I chose ArgoCD because it implements GitOps, where Git becomes the single source of truth: ArgoCD continuously monitors my repository for manifest changes and automatically syncs them to the Kubernetes cluster, giving me declarative infrastructure management with built-in rollback capabilities and a complete audit trail of every deployment.</p><p>First, I read the ArgoCD website’s “<a href="https://argo-cd.readthedocs.io/en/stable/getting_started/">Getting Started</a>” documentation article about how ArgoCD is created and deployed, and wrote a <strong>setup_argocd.sh </strong>and <strong>uninstall_argocd.sh</strong> bash scripts that were completely and fully idempotent. [And yes, I heavily tested the idempotency — that the <strong>uninstall_argocd.sh</strong> script completely reversed everything that the <strong>setup_argocd.sh</strong> script did.] The <strong>setup_argocd.sh</strong> script installs ArgoCD into the Kubernetes cluster via kubectl, waits for all components to be ready, sets up port forwarding to expose the UI at <a href="https://localhost:8080,"><strong>https://localhost:8080</strong>,</a> and retrieves the auto-generated admin password for first-time login.</p><p>Launching it, we get:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*7U_veoXs_NvKM4Xa5JBw2g.png" /></figure><p>And opening the website and entering the script-outputted credentials, we get:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*L4Vap_tE1GC0Nm0VXa6sPg.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*yBcPs-1bDSrPzRWj_1s4dA.png" /></figure><p>Great. I then needed to create an ArgoCD application manifest using a — you guessed it — another YAML file! To this end, I first made an <strong>argocd </strong>sub-folder, and then used the <a href="https://argo-cd.readthedocs.io/en/stable/operator-manual/declarative-setup/"><em>Declarative Setup</em></a>, the <a href="https://argo-cd.readthedocs.io/en/stable/operator-manual/argocd-cm-yaml/"><em>argocd-cm.yaml</em></a>, and <a href="https://argo-cd.readthedocs.io/en/latest/user-guide/application-specification/"><em>Application Specification</em></a><em> </em>documentation pages to make it. It looked like this:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/810/1*4vc1z1oIsaLN-CRfyiKs9A.png" /></figure><p>Notice that it keeps track of changes made to the Kubernetes manifests in the <strong>Homelab16 — GitOps and Secrets Management/app/kubernetes_manifests </strong>folder. When the CI pipeline finishes executing and updates the tag in the <strong>helloworld_deployment.yaml </strong>file, ArgoCD will detect that and automatically deploy the newly-generated container fresh into production!</p><p>I then needed to create another (yes, another) shell script that would apply the ArgoCD application manifest once the ArgoCD application was spun up. The reason why I needed to do it separately was an enterprise best practice of separating concerns: first we spin up ArgoCD, then we can deploy any application of our choosing into it, and can swap the specific application in and out in a modular way. I thus created the <strong>setup_app_in_argocd.sh</strong> and <strong>uninstall_app_in_argocd.sh</strong> scripts and added them to the Makefile.</p><p>Then, I used the <strong>make init </strong>command to launch the application and see if I could get everything working. As per the previous tests, I was able to get ArgoCD opened — but the application was not successfully deployed.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/791/1*OsrluVaMhYa0KHuUiPsF6g.png" /></figure><p>I then used the <strong>kubectl describe application hello-world-app -n argocd</strong> command to get information about what was going on — and got the following</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*lnC-KWGDGiPjRm2wTld6VQ.png" /></figure><p>The key message was “Application referencing project Homelab16 which does not exist” — gotcha. I needed to create a project-specific YAML file since I wanted to create a project called “Homelab16” — not a project of the default name <strong>default</strong>. I then created a <strong>homelab16-project.yaml </strong>file in the <strong>argocd/ </strong>folder to define the ArgoCD Project, which provides logical grouping and security boundaries for applications. However, after resolving this and re-running the pipeline, I encountered another issue: the pods were failing with <strong>CreateContainerConfigError</strong>.</p><p>The root cause became clear after examining what ArgoCD was deploying — it was only syncing the <strong>helloworld-deployment.yaml</strong> and <strong>helloworld-service.yaml</strong> files, but not the <strong>secretstore.yaml</strong> and <strong>externalsecret.yaml</strong> configurations that External Secrets Operator needed to inject secrets into the containers from the Vault. These ESO configuration files were sitting in <strong>scripts/setup_scripts/configuration_yamls/</strong>, which my shell scripts manually applied via<strong> kubectl apply </strong>— but ArgoCD was watching <strong>app/kubernetes_manifests/ </strong>and had no visibility into that separate directory.</p><p>This violated the core GitOps principle: Git should be the single source of truth,<strong> with ArgoCD deploying everything declaratively from a single location. </strong>To fix this, I moved <strong>secretstore.yaml </strong>and <strong>externalsecret.yaml i</strong>nto <strong>app/kubernetes_manifests/ </strong>alongside the application manifests, refactored <strong>setup_external_secrets.sh </strong>and<strong> uninstall_external_secrets.sh</strong> to remove the manual <strong>kubectl apply</strong> commands (archiving the old versions in an<strong> old_scripts/</strong> folder for reference), and let ArgoCD handle deploying all four manifest files automatically. I also moved the <strong>vault-config.yml </strong>file into a dedicated vault-related folder, given that the vault is not the app and more of a “cluster service.”</p><p>This approach aligned the entire stack with proper GitOps architecture: infrastructure scripts (<strong>setup_vault.sh</strong>, <strong>setup_external_secrets.sh</strong>, <strong>setup_argocd.sh</strong>) install the platforms, while ArgoCD manages application deployment from Git.</p><p>I then ran the pipeline for getting my infrastructure spun up using the <strong>make init </strong>command and opened up ArgoCD to this</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*qtIKAVqUbxG5mLfZDGoW6g.png" /></figure><p>That looked good. What about the application at <a href="http://localhost:5000/?"><strong>http://localhost:5000</strong>?</a></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/612/1*XZ15593p49IMjQZKFpMV5Q.png" /></figure><p>It was up and running. Yay!</p><p><strong>Section XII: Testing The Pipeline End-to-End</strong></p><p>The last thing that I wanted to do is to test the CI/CD pipeline end-to-end. I did this by opening up the <strong>app.py </strong>file and changing the version number from 1.0 to 2.0</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1017/1*3xGC2e96vtbPeMsZntmo9w.png" /></figure><p>I pushed to the repository, and the changes successfully passed the CI’s checks</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*1ZlU9z7O-U_ll4sjei_xZA.png" /></figure><p>And then GitHub Actions successfully updated the <strong>helloworld-deployment.yaml</strong> file for the right container image’s hash</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*hR3ROrOGvODNZL5uupA-mA.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*COzkX4j6QnoKdgMiRT4x7w.png" /></figure><p>Then looking into ArgoCD, I saw that it saw the changes that the GitHub Actions’ commit did to the <strong>helloworld-deployment.yaml </strong>file — and automatically triggered an update</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*5VmTjyckSz-HKL9xM9UggA.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*vmAJAHhWUP6Ab_rF_MouYg.png" /></figure><p>Opening up the website-shown application and refreshing the browser window, I got the expected change displayed</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/763/1*Pave06CjQgMwBQHz05MmMg.png" /></figure><p>Wonderful!</p><p><strong>Section XIII: Spinning Down The Infrastructure</strong></p><p>With this exercise being over, I needed to spin down the entire infrastructure that was created. I did this by typing the <strong>make uninstall </strong>command, which per the Makefile invoked the following scripts in order:</p><blockquote>./scripts/uninstall_scripts/uninstall_app_in_argocd.sh</blockquote><blockquote>./scripts/uninstall_scripts/uninstall_argocd.sh</blockquote><blockquote>./scripts/uninstall_scripts/uninstall_external_secrets.sh</blockquote><blockquote>./scripts/uninstall_scripts/uninstall_vault.sh</blockquote><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*0yLOhMndnhVRy0nSRfsobQ.png" /></figure><p>It is important for me to re-iterate — each script was designed to idempotently “undo” every single action/installation that its installation counterpart did.</p><p>Then I used the <strong>kubectl get pods -A </strong>and <strong>kubectl get namespaces</strong> command to confirm that there were no pods or namespaces left over</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*-xpswEtejC-ijZpAOVOYpg.png" /></figure><p>All the code and files can be found on <a href="https://github.com/secnate/HomelabSeriesGithubRepo/tree/main">my homelab GitHub page</a>.</p><p>What started as curiosity about how enterprise teams deploy securely dozens of times per day ended with me building exactly that — a fully automated GitOps pipeline where code changes flow through security gates, become immutable artifacts, and deploy to production with a simple git push and zero manual intervention. This homelab proved that enterprise-grade DevSecOps practices are accessible and implementable. Once you experience declarative infrastructure with security baked in at every step, manual deployments truly feel like ancient history. Thanks for reading!</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=0018c48eaa8c" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Homelab #15: AWS Multi-Account Landing Zone]]></title>
            <link>https://secnate.medium.com/homelab-15-aws-multi-account-landing-zone-bd79e6e17411?source=rss-f95ccfcb96af------2</link>
            <guid isPermaLink="false">https://medium.com/p/bd79e6e17411</guid>
            <dc:creator><![CDATA[Nathan Pavlovsky]]></dc:creator>
            <pubDate>Thu, 29 Jan 2026 00:57:40 GMT</pubDate>
            <atom:updated>2026-01-29T00:57:40.307Z</atom:updated>
            <content:encoded><![CDATA[<p>Welcome back! After fourteen labs that took me from basic Linux/Windows servers and networking fundamentals all the way to advanced routing, containers, Kubernetes, CI/CD pipelines, and observability, I hit a roadblock with tenant permissions on my existing Microsoft accounts, Microsoft Azure account signups, and registrations for Microsoft’s developer program that blocked the original plan for an Azure Landing Zone homelab. Therefore, I pivoted to AWS to build a similar multi-account foundation — called an <strong>AWS landing zone</strong> (or multi-account environment) — using Terraform to set up a secure, governed cloud setup that’s ready for real workloads.</p><p>Think of a landing zone as building a well-planned neighborhood before anyone moves in: it has organized streets (hub-and-spoke networking for centralized connectivity and security), building rules everyone follows (policies and guardrails like IAM roles and SCPs to enforce standards), shared utilities (logging with CloudTrail, monitoring via CloudWatch, and cost alerts), and separate lots for different houses (multiple accounts or VPCs to isolate workloads). This acts as a strong foundation that ensures new projects can “land” quickly and safely without chaos, security holes, or surprise bills. <strong>It is very important to get this right.</strong></p><p>Microsoft’s Azure pioneered the detailed “landing zone” concept, but AWS has its own mature equivalent, which provides the same core ideas: multi-account isolation, governance, and scalability.</p><p>I chose AWS here because it was a good alternative cloud provider — without the account signups and eligibility issues that I experienced when attempting the initial lab using the Microsoft accounts. Onwards!</p><p><strong>Section I: Initial Preparations</strong></p><p>I first opened up the PowerShell console with Administrative privileges on my Windows homelab machine and confirmed with the <strong>aws sts get-caller-identity </strong>command that my AWS account credentials from past homelabs were still configured and on the machine</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/467/1*RZnR_AdeCsizsg6ope_ZvA.png" /></figure><p>I also had Terraform installed on my workstation from past homelabs. I also made a folder dedicated to this homelab in my <a href="https://github.com/secnate/HomelabSeriesGithubRepo">HomelabSeriesGithubRepository</a><strong> </strong>for the code and all the related files to go in.</p><p><strong>Section II: Terraform</strong></p><p>I initially took some sample code from my <a href="https://github.com/secnate/HomelabSeriesGithubRepo/tree/main/Homelab12%20-%20CI%20CD%20Pipeline/terraform/aws">Homelab #12</a> and made the following initial Terraform files and their related configurations:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/394/1*ibSduQVG8dN8mYLG4AtvVw.png" /></figure><p>After that, I read a little bit about how these landing zones are made and found that I need to make <a href="https://awsfundamentals.com/blog/aws-landing-zone">an AWS Organization</a>. AWS Organizations lets you manage multiple AWS accounts from one main account. It groups accounts into organizational units (OUs) and applies shared rules like security policies (SCPs), consolidated billing, and features such as CloudTrail logging or GuardDuty across all accounts.This keeps everything organized, secure, and cost-controlled as your cloud setup grows. I then found an <a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/organizations_organization"><strong>aws_organizations_organization</strong></a> Terraform Registry page, and copied the appropriate code snippet into the <strong>main.tf </strong>file:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/349/1*HqdKX6a7orGtj469PbNaXg.png" /></figure><p>And then inserted the following code of lines into the <strong>output.tf </strong>file:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/377/1*foothKahrd5u59cFrsRGlA.png" /></figure><p>Next, I added Organizational Units (OUs) under the root of the AWS Organization.</p><p>OUs act like folders that group related AWS accounts together — specifically one for Security and one for Sandbox testing. They allow you to apply different security rules or policies to each section instead of treating everything the same. This grouping is essential for scalable governance and is required before you can properly deploy Control Tower or enforce targeted controls.</p><p>This was done with the following lines in the <strong>main.tf </strong>file:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/473/1*-5V6NOAT2tTkoweCGutKew.png" /></figure><p>Having Organizational Units (OUs) is going to be essential for the later usage of an AWS Control Tower instance. Per the <a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/controltower_control#argument-reference">involved documentation</a>, an AWS Control Tower resource instance requires the Amazon Resource Number of organizational unit(s) as the <strong>target_identifier</strong> parameter when created. They automatically organize and place the accounts that are in these OUs into the right groups, so that consistent security rules, logging, and guardrails can be applied and enforced.</p><p>I deployed an AWS Control Tower landing zone using Terraform’s <a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/controltower_landing_zone"><strong>aws_controltower_landing_zone</strong></a> resource to create a governed, multi-account foundation aligned with NIST SP 800–53 Rev. 5 principles. The <strong>manifest_json</strong> configuration field defined a Security and Sandbox OU structure, mandatory accounts (<em>Management</em>, <em>Log Archive</em>, and <em>Audit</em>), and enabled centralized logging with 365-day retention in dedicated buckets — supporting key NIST controls like AU-2 (audit events) and AU-11 (retention) by automatically provisioning organization-wide CloudTrail trails and isolating audit data.</p><p>To add deeper NIST compliance checks, I then found and decided to use the official AWS Config conformance pack <a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/config_conformance_pack">(<strong>aws_config_conformance_pack</strong></a>) using the public “Operational Best Practices for NIST 800–53 rev 5” template, which I found on a <a href="https://github.com/awslabs/aws-config-rules/blob/master/aws-config-conformance-packs/Operational-Best-Practices-for-NIST-800-53-rev-5.yaml">GitHub page</a>. This pack activates dozens of automated Config rules that are to continuously evaluate resources against NIST standards (e.g., MFA on root accounts, encrypted volumes, restricted security groups, and proper IAM policies), providing ongoing monitoring for critical families like Access Control (AC), Configuration Management (CM), and System &amp; Information Integrity (SI). Think of it as an automated compliance auditor running 24/7.</p><p>That same webpage has plenty of other packs for other compliance standards, including CMMC, FedRamp, PCI-DSS, and HIPAA as well. It’s a very useful repository.</p><p>The Terraform code that I added into the <strong>main.tf </strong>file was as follows:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/497/1*DTZcEovmcikZ0gg8Yorg2g.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/591/1*h8TCLpce0rGT7Nc1cIKbfQ.png" /></figure><p>Then I used the <strong>terraform plan -out tfplan </strong>command and got it to work successfully, with the <strong>tfplan </strong>file being generated</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*fCBA-Xlco45lV1hPjMxkRw.png" /></figure><p>Then I used the <strong>terraform apply tfplan </strong>command and got the following two errors</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*6x_dkgMM0V0kK-ljGIPh8Q.png" /></figure><p>Notice the first one related to the creation of the ControlTower Landing Zone and not having the <em>AWSControlTowerAdmin</em> permissions.</p><p>I spent an entire afternoon troubleshooting — creating IAM roles, adjusting trust policies, and adding wait times for IAM propagation. However, after consulting AWS documentation and best practices, it became clear that AWS Control Tower’s bootstrap process is simply too complex for full automation from scratch. The IAM Identity Center prerequisites, service-linked role dependencies, and circular service integrations are intentionally designed to be handled through a one-time manual setup. This wasn’t a failure of Infrastructure-as-Code; it’s an intentional design choice by AWS.</p><p>During this process, I also attempted to use Terraform to deploy a NIST 800–53 conformance pack, and wrangled IAM role assumption errors, duplicate OU conflicts, import workflows, and permission denials — and decided that Terraform was also not worth it.</p><p>When Plan A fails, one shifts to Plan B.</p><p><strong>Section III: Manual Landing Zone Creation</strong></p><p>Rather than continue fighting against the platform’s intended workflow, I opted for the pragmatic approach of doing everything manually. Switching to manual configuration allowed me to focus on the core concepts of landing zones, NIST alignment, and security measures without getting bogged down in automation hurdles. This pragmatic choice reinforced an important lesson: sometimes the fastest way to learn and demonstrate the value of a governed cloud foundation is to work directly with the platform’s intended workflow.</p><p>So, I got to work to make the Landing Zone manually. I opened up my AWS account, and went through the steps of creating it</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*5rDkaQV2nABH0PVZt0j9pg.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*71GQvBrK4o8RwIXrDDB4wg.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*HtW9ac_EvM08poacO9AgDw.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*7LU9PFzuyngKPvDWgXzT_A.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*dMpeP52-wG5EdokcM8EqVQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*mWoUM-A6KJsNw4gaCbgZbg.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*4J61WqDBdvIBTtS_EE9rxA.png" /></figure><p>And here is what I got:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*HifGe-0C9V4Plp6XDUsRrg.png" /></figure><p><strong>Section IV: Automating NIST 800–53 With AWS Config</strong></p><p>Next up was to get the NIST 800–53 conformance pack deployed. I opened up the <strong>AWS Config → left menu → Conformance Packs </strong>page, and went through the deployment steps</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*44ap4-L8Dh3k286mpwezdg.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*v-614aVasneL8oP5hbfcYQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Qbzcw3PEwBqDrzLPry-J7w.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*VD0k0hMbbCPfopriicKj7w.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*VSqdSxSbnftGAiU7FKFX3A.png" /></figure><p>After waiting a bit, the deployment was completed</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*GGZJSiMD7CfVvzDeegNSyw.png" /></figure><p>Opening up the panel, one could see both the compliance score and the rules down below, as well as the status as to whether we were compliant or not</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*McMRhpLiB7m9NPn7vE-XMg.png" /></figure><p>For example, we saw that the root account did not have MFA enabled. Enabling it would allow for the rule’s requirements to be met and the compliance score to appropriately go up.</p><p>Then, I noticed that I initially forgot to enable the <strong>AWS Config </strong>recorder (required for the conformance pack to actually evaluate resources), so I quickly fixed it manually in the console to activate continuous monitoring and see the NIST rules in action.</p><p>The<em> </em><strong>AWS Config</strong><em> </em>recorder is the engine that continuously captures and tracks the configuration state of all your AWS resources — such as EC2 instances, S3 buckets, IAM policies, and security groups — creating a historical timeline of changes so you can audit, troubleshoot, detect drift, and run compliance checks like the NIST conformance pack.</p><p>Without it, <strong>AWS Config </strong>has nothing to evaluate, so the conformance pack rules stay silent; enabling the recorder turns on the “always-on monitoring” that makes a governed landing zone truly effective for ongoing compliance and security visibility.</p><p><strong>Section V: GuardDuty For Security Measures (Threat Detection — SI-4 Compliance)</strong></p><p>I took a look at some of the non-compliant rules, and saw that there was a rule checking if Amazon GuardDuty was enabled in my AWS account and region.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*EfAThRSz6YNRua9nWEkUug.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*fBuBh-YD5TwJ-zgK2Pf_pw.png" /></figure><p>For those who don’t know, AWS GuardDuty is a continuous security monitoring and threat detection service that automatically analyzes your AWS account logs, network traffic, and resource activity — to catch malicious IP calls, unauthorized access attempts, or unusual behavior. It uses machine learning and threat intelligence to identify potential security issues in real time, alerting you so you can respond before damage occurs — making it a key layer of protection in a governed landing zone.</p><p>Time to get it enabled. I did this by opening the Amazon GuardDuty page and clicking on the <em>Get Started</em> button:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*UVweIV4-CCG82bhPFnzs1Q.png" /></figure><p>And the <em>Enable GuardDuty</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*sAdzHVzkpWMi2V_29HIXgg.png" /></figure><p>And it currently is enabled and didn’t show anything abnormal or malicious due to the fact that my landing zone had no resources or instances deployed in it</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*dN1JvjG8WnFQDfPqWO9Z3A.png" /></figure><p>Then I needed to get the GuardDuty delegated administrator configured. The Aggregator account is one of the mandatory AWS Control Tower automatically created during landing zone setup, specifically designated to centralize and store security-related data like audit logs and findings from across the organization.</p><p>I had to designate it as the GuardDuty delegated administrator so that GuardDuty could collect threat detection data from every account in the organization into this single, secure location — ensuring centralized monitoring and compliance with NIST SI-4 requirements for continuous system monitoring and incident response.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*hci9DhMWfYTxoDXpRQHvMA.png" /></figure><p>And then reopening the rule pack up, I got the following change in compliance status</p><p>It is compliant now!</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*8Tl7lLC5bTP05vbfI95G_g.png" /></figure><p><strong>Section VI: Creating and Attaching SCPs for Governance</strong></p><p>Next, I worked on adding some Service Control Policies (SCP) to enforce governance across the organization.</p><p>SCPs are powerful guardrails in AWS Organizations that act like organization-wide rules. They define what actions users and roles are allowed (or denied) to perform in any account, no matter who is logged in or what permissions they have. They are used to enforce security boundaries, prevent risky actions (like root user operations), restrict regions, or require tagging — making them essential for maintaining least-privilege access and compliance in a multi-account landing zone like this one.</p><p>The Service Control Policies page can be found by opening the <em>AWS Organizations &gt;&gt; Policies &gt;&gt; Service Control Policies</em> page</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*74vVXLuvb9Awk1iuT5S8rQ.png" /></figure><p>I went ahead to create four SCPs with the repeated usage of the <strong>Create Policy </strong>button:</p><ul><li><strong>SCP #1: Block Accounts From Leaving the Organization (CM-2)</strong></li></ul><p>We want to be in full control of accounts in this enterprise, so we want to block them from having the ability to leave the organization. The AWS documentation has some nice pages regarding the <a href="https://docs.aws.amazon.com/organizations/latest/APIReference/API_LeaveOrganization.html">LeaveOrganization action</a>, which is performed by an account that wants to leave.</p><p>I did this with the following step. If there are any questions regarding the SCP policy syntax, they can be addressed by looking at <a href="https://docs.aws.amazon.com/organizations/latest/userguide/orgs_manage_policies_scps_syntax.html">the pertinent documentation page</a>.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*bJEM4o48xK_6b2XkbBnwmw.png" /></figure><p>This prevents accidental or malicious removal of accounts from the Landing-Zone maintained environment and adheres to the <strong>CM-2</strong> — baseline configuration compliance standard</p><ul><li><strong>SCP #2: Deny All Regions Except For The One Currently Used:</strong></li></ul><p>In this manual deployment of an AWS landing zone, my work has been entirely confined to the <strong>us-east-2 </strong>region. I needed to prevent the accidental use of all other regions — and wanted to ensure that nothing would slip under the nose. (There was a nice <a href="https://medium.com/@tams67680/from-scp-to-guardrails-building-a-region-safe-aws-environment-81388e923ffa">Medium page</a> that had a rule that I used and tweaked)</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/889/1*gT_FRrP_nx4o9jPnL995kg.png" /></figure><p>This is in good adherence with the NIST 800–53’s<strong> SC-7 Boundary Protection</strong> and <strong>CM-6 Configuration Baseline</strong> compliance standards</p><ul><li><strong>SCP #3: Require Tags on All Resources:</strong></li></ul><p>Remember tags from Terraform, with code that looks like this?</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/337/1*2F99gfVb4jCFvZ9lOfXpwQ.png" /></figure><p>That was the first time we worked with tags over the course of this homelab series. It should be noted that such tags can also be made manually as well.</p><p>I wanted to ensure that resources are <strong>always</strong> created with tags — which are great for ensuring inventory enumeration and cost tracking. Notice that per documentation, we can use the <a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/access_iam-tags.html"><strong>aws:ResourceTag/&lt;KEY_NAME&gt;</strong></a><strong> </strong>field to specify what resource keys/tags we needed.</p><p>Here is what I did:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/866/1*GtmxEZWVKmWsrlPrsmtJzw.png" /></figure><p>Notice the usage of the following condition statement:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/311/1*e_pHQHgWl58_ZsNz5_QOwA.png" /></figure><p>It denies resource creation if <strong>either </strong>the environment or owner tag is missing</p><p>This SCP helps us meet the NIST CM-8 (System Component Inventory) compliance standard</p><ul><li><strong>SCP #4: Cost Optimization: </strong>I came across this SCP when going through the <a href="https://github.com/aws-samples/service-control-policy-examples/tree/main">AWS GitHub page of Service Control Policy Examples</a> and found<a href="https://github.com/aws-samples/service-control-policy-examples/blob/main/Service-specific-controls/Amazon-EC2/Require-Amazon-EC2-instances-to-use-a-specific-type.json"> this one</a> rather cool. It is less driven by the need to comply with the NIST 800–53 standard and more to ensure that we minimize the risk of cost overruns.</li></ul><p>It ensures only free-tier eligible instances (t2.micro, t3.micro) can be launched, preventing costly instance types</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/851/1*cqQWWj9wgkCTs7P9ucSZ7A.png" /></figure><p>This was fun. I learned a lot about what SCPs are and what they do. Now I see why having such landing zones are very important — they ensure that anything that gets spun up later is only done in a proper way.</p><p>Here is what I had in terms of SCP policies at the end of this section</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/892/1*OpavqCwo1bPVNl4gmLv-Dw.png" /></figure><p><strong>Section VII: Enabling and Configuring AWS Security Hub</strong></p><p>Next, I enabled AWS Security Hub to bring together all security-related findings from GuardDuty, the NIST conformance pack, and other services into one centralized dashboard. This step was essential for gaining a unified view of the landing zone’s security posture, including an overall security score and prioritized alerts, and to be able to turn scattered data into actionable insights for ongoing monitoring and compliance.</p><p>I did this by opening up the <strong>Security Hub </strong>page in the web console</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*GjYgiamdn4PmZCGC_tvf0Q.png" /></figure><p>Then I clicked the <strong>Get Started</strong> button and accepted the default configurations</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*H3-c4x4WJ2BJsc_MiIvPcg.png" /></figure><p>When enabling AWS Security Hub, I designated a delegated administrator account (the Aggregator account from my Control Tower setup) to centralize security findings across the entire organization. This delegation allows Security Hub to collect, aggregate, and prioritize alerts from GuardDuty, AWS Config conformance rules, and other services into one unified view within the designated admin account, ensuring consistent monitoring and faster incident response across all accounts in the landing zone.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*YIf-84v1l2wvoKJqTseViQ.png" /></figure><p>Then, I selected the checkbox for <strong>Enable Security Hub For My Account </strong>to kick things off</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*6t-mXCk1Apv43GuNbKho0g.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*GtOWS1oQ4RlJ9gp5fwdN-Q.png" /></figure><p>Nice. The AWS Security Hub was activated. It should be noted that this is actually the older name of the AWS service — the updated name is <strong>AWS Security Hub Cloud Security Posture Management (AWS Security Hub CSPM)</strong>. <br>While the AWS Config conformance pack that I worked with in this writeup’s Section IV provided detailed, resource-level rule checks against NIST standards, enabling the NIST standard in AWS Security Hub CSPM was also necessary because it aggregated those results (along with GuardDuty findings and other sources) into a broader, organization-wide perspective with scoring and prioritization — ensuring both granular compliance validation and high-level security oversight in one tool.</p><p>I got this quickly enabled by opening up the <em>Security Hub CSPM &gt;&gt; Security Standards </em>page, finding the <strong>NIST Special Publication 800–53 Revision 5</strong> page and clicking the <strong>Enable Standard </strong>button</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*F_YCmnpYO9WrnC2LbPgwlA.png" /></figure><p>I also popped into the left-side <em>Integrations</em> menu and confirmed that the Amazon GuardDuty and AWS Config integrations have their findings being fed into/accepted by the Security Hub CSPM</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*k0BeasfkA5mw8S_npYRqxQ.png" /></figure><p>Everything is good now. Notice that I could open the <strong>Findings </strong>tab to see what was up:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*0-DFUELaJkzPzgnx7HKu4g.png" /></figure><p>This page was definitely interesting to read. I was very happy with what I had done in this section so far, so I decided to move on to the next round.</p><p><strong>Section VIII: Implement Cost Controls</strong></p><p>Now, for some cost controls! And no, we can’t abbreviate it as “cost co” — that’s a retail chain (see what I did there?)</p><p>Cloud spend is finicky and sensitive — and all those credits that AWS gave me for learning aren’t unlimited. So off I went to get the appropriate budgets created. I opened up the <strong>AWS Budgets </strong>page and clicked the <strong>Create a Budget </strong>button</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*SQR2QLXX0Th9741oKAmkJw.png" /></figure><p>And then decided to create a ten-dollar budget because you know, <a href="https://www.reddit.com/r/Jokes/comments/92wy7h/fifty_dollars_is_fifty_dollars/">ten dollars is ten dollars</a>, and pushed that over the finish line</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*v54TCem8XjHH9jEaNTRDuw.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Kg3G0wvW7TOTNNhPLUseUA.png" /></figure><p><strong>Section IX: Testing WIth A Sample EC2 Resource</strong></p><p>The very last action that I needed to take with regards to this landing zone was to create a sample EC2 instance to test it. I did this by opening the <strong>EC2 </strong>page in the AWS website console, and clicking the <em>Launch Instance</em> button.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*uHwcp0qQOisF39oOOzDHGQ.png" /></figure><p>Then, I selected the configurations, which are shown per the following screenshot</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*u9J9F3unP7AEE_NBnRaVyw.png" /></figure><p>Notice that I had the Amazon Machine Image (AMI) be of the t3.micro free tier — and included the tags that were required by the SCP rule that I previously made. Specifically, the tags’ key / value pairs were <strong>Environment / Testing </strong>and <strong>Owner / Nathan</strong>. I also selected the option to have the AMI be configured without a key pair. I didn’t care too much about keys for this lab.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*sKosnxGzHbhu8TRZZnNZjQ.png" /></figure><p>I then got the instance made and waited about 2–3 minutes for it to be initialized — and then opened it up.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*3DUZ5n9qIyHKwYM_bEJp6w.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*TvooQxX3yBZhS8hUW8aa1g.png" /></figure><p>Cool. I then opened the <em>AWS Config &gt;&gt; Rules</em> page to see what whether it would be flagged per the NIST 800–53 conformance pack, and got the following</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*sCQb3C6sZYgRoXjjJp9Lqg.png" /></figure><p>I didn’t see it appear at all in the AWS Config rules list — instead, the dashboard showed only a handful of non-compliant items like unencrypted CloudTrail logs, missing S3 data events, EBS encryption by default disabled, IAM password policy gaps, and root account MFA not enabled.</p><p>I then also checked GuardDuty. Per the screenshot, the EC2 instance was also not flagged.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Zl5eDIgxFBo3hTw95KzfVA.png" /></figure><p>Launching this test EC2 instance and observing the conformance pack and GuardDuty behaviors — even if no immediate flags appeared for the instance itself — highlighted how AWS Config and security services focus on ongoing, configuration-driven monitoring rather than instant detection of every resource, reinforcing that a true landing zone’s strength lies in continuous compliance and threat visibility over time rather than one-time checks.</p><p><strong>Section X: Teardown</strong></p><p>The main bulk of the homelab has been completed. I now needed to clean everything up so as to leave nothing remaining and minimize the risk of any potential cloud spend costs piling up:</p><ul><li><strong>AWS Control Tower: </strong>I opened up the <em>AWS Control Tower &gt;&gt; Landing Zone Settings</em> page, selected the <em>Decommission </em>tab, and clicked the <em>Decommission Landing Zone </em>button</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*RuRhMpe7NbwcRo-X9N8ANw.png" /></figure><ul><li><strong>SCPs: </strong>I opened up the <em>AWS Organizations &gt;&gt; Policies &gt;&gt; Service Control Policies </em>page, and then selected all the SCPs that I previously made (they were of the “Customer Managed Policy” kind)</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/885/1*W3s_XrL-QtrXBGEE-r08ow.png" /></figure><ul><li><strong>GuardDuty: </strong>I opened up the <em>GuardDuty &gt;&gt; Settings</em> page and clicked the <em>Disable</em> button</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*sTY1T0Um7t-ruUb_u7jsPw.png" /></figure><ul><li><strong>SecurityHub: </strong>I opened up the <em>SecurityHub &gt;&gt; General</em> page and clicked the <em>Disable </em>button</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*mN9xf1b0-BnKMK3XvrXJQw.png" /></figure><ul><li><strong>Conformance Packs: </strong>I opened up the <em>AWS Config &gt;&gt; Conformance Packs</em> page and selected the <em>Delete</em> action for the <em>NIST 800–53 Conformance Pack</em></li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*-s0cdPHskKnW1e9nDxySqw.png" /></figure><ul><li><strong>AWS Config Recorder: </strong>I opened up the <em>AWS Config &gt;&gt; Settings</em> page and turned the recorder off</li><li><strong>Security Hub CSPM: </strong>I opened up the <em>Security Hub CSPM &gt;&gt; Security Standards </em>page and then disabled the NIST 800–53 standard for the sake of being thorough</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*zXN1XNeLhKSyj5EvALsZ-w.png" /></figure><p>I also selected the <em>Integrations</em> tab and clicked the <em>Stop Accepting Findings</em> for AWS Config and GuardDuty</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*AIVpEDpka2-FMDq3XaKumw.png" /></figure><p>All these steps were undertaken to ensure no lingering resources or potential costs remained after the lab. I kept the ten dollar budget in force to ensure that I would get notified in case something *did* happen to remain to continue piling up the costs.</p><p><strong>Section XI: Conclusion</strong></p><p>Looking back on Homelab #15, building this AWS multi-account landing zone — even with its share of Terraform struggles, permission hurdles, and manual pivots — gave me a much deeper appreciation for why companies invest so heavily in governed foundations. Control Tower provided the structure, the NIST conformance pack added automated compliance checks, GuardDuty brought threat detection, SCPs enforced boundaries, Security Hub gave centralized visibility, and cost controls kept things realistic. While I started with full IaC ambitions, the real lesson was learning when to adapt and work with the platform’s intended flow. Most importantly, I now understand that a true landing zone isn’t just about accounts and tools — it’s about creating a secure, observable, scalable environment where teams can build confidently without constant firefighting. That’s knowledge I’ll carry into every future cloud project.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=bd79e6e17411" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Homelab #14: Enterprise Network Administration — VLANs, Security, Routing, & Services]]></title>
            <link>https://secnate.medium.com/homelab-14-enterprise-network-administration-vlans-security-routing-services-e7c8dfb113fb?source=rss-f95ccfcb96af------2</link>
            <guid isPermaLink="false">https://medium.com/p/e7c8dfb113fb</guid>
            <dc:creator><![CDATA[Nathan Pavlovsky]]></dc:creator>
            <pubDate>Tue, 20 Jan 2026 00:56:37 GMT</pubDate>
            <atom:updated>2026-01-20T01:27:35.559Z</atom:updated>
            <content:encoded><![CDATA[<h3><strong>Homelab #14: Enterprise Network Administration — VLANs, Security, and PAT</strong></h3><p>Welcome back to this round of homelabs. In this episode, I wanted to continue learning more about networking in order to continue growing the skills. The thing with Networking is that there is always more to learn, so you can’t be just finished with a quick byte (see what I did there?)</p><p>In this round, I built a small business network in Cisco Packet Tracer from scratch, with the following network architecture:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/718/1*-zN24-GW4r0w6WKaSYjy-A.png" /></figure><p>Onwards!</p><p><strong>Section I: Network Foundations</strong></p><p>To start, I opened up Cisco Packet Tracer and put down the core components of my [simulated] network that I wanted to get fully configured and connected. Specifically, these were 1 router, 1 switch, and 6 PCs (two of each belonging to the Sales, IT, and Guest users — and which will be placed on their own VLANs). The network looked like this:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/465/1*6ffc3qp3R34LvA3UmSyA9w.png" /></figure><p>Next up was to get everything connected. Remember from Homelab #3 that straight-through cables connect systems of different types? This is the same case here — with straight through cables being applied to connect the PCs to the Switch and the Switch to the Router.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/529/1*Froc2sjYyGR3afhdhNgUXg.png" /></figure><p>Next up was to configure the switch. Specifically, I needed to get all the VLANs created on it, the appropriate interfaces configured, and the trunking port for configuring the trunk port that will carry the traffic from the switch to the router. To do this, I opened up the Switch’s CLI and got to work with the <strong>enable </strong>and <strong>configure terminal </strong>commands.</p><p>I first had to create the three VLANs: VLAN 10 (Sales), VLAN 20 (IT), VLAN 30 (Guest) with the <strong>vlan &lt;VLAN_ID&gt; </strong>commands:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/450/1*2_sEnTrErg81oo6ox74sTw.png" /></figure><p>As of now, here is what I had per the <strong>show vlan </strong>command:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/591/1*tbCOQUXYO6Q-Wsf2BDkHuA.png" /></figure><p>Then I needed to get them assigned to the appropriate ports. This was done in the <strong>configure </strong>mode with the following commands.</p><ul><li><strong>VLAN 10 (Sales): </strong>The <strong>interface range f0/1 — f0/2</strong> command was used to get access to the FastEthernet Ports #1 and #2, to which the two PCs are connected. Then I used the <strong>switchport mode access </strong>command to set it to operate in access mode, meaning it carries traffic for only one specific VLAN — and then the <strong>switchport access vlan 10 </strong>command set the two computers attached to them as being part of VLAN #10 (sales)</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/397/1*n0MZkQXUa27thz1TfTSRVg.png" /></figure><ul><li><strong>VLAN 20 (IT)</strong>:</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/394/1*tQ78OwSXAIngMyx13eWBbA.png" /></figure><ul><li><strong>VLAN 30 (Guest)</strong>:</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/396/1*Kp1tEGhxp9drdGHcIVckug.png" /></figure><p>The logic behind the commands used for configuring the ethernet interfaces for the Sales VLAN was exactly the same as for the commands used for the IT and Guest VLANs too, so I didn’t bother to rewrite extensive explanations for the obvious.</p><p>Next up was to configure the Trunk port for communicating from the switch to the central router. For those who don’t remember, a Trunk port carries traffic for multiple VLANs over a single physical link. It uses tags per the 802.1Q protocol to identify each packet’s VLAN.</p><p>I did this by using the <strong>interface gi0/1 </strong>command to enter the appropriate GigabitEthernet 0/1 interface, and then used the <strong>switchport trunk allowed vlan 10,20,30 </strong>instructions to set it to allow traffic from VLANs 10, 20, and 30.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/448/1*UYYeRc91QAdlIOzGROBA-w.png" /></figure><p>Then a quick saving operation with the <strong>end </strong>and <strong>write memory </strong>commands quickly got those changes “locked and loaded.” Then a quick typing of <strong>show vlan brief</strong> showed what we ended up with the final configurations on the core switch on:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/615/1*4uyM9mF_Km98jIJh8GRb5A.png" /></figure><p>I then got to work with configuring the router. First I used the <strong>hostname “Central_Router” </strong>command to set its name</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/292/1*Hni17VTWeJ8b8jziqslGGA.png" /></figure><p>I then needed to configure the switch-facing interface appropriately.</p><p>First, I needed to bring the interface up with the <strong>interface GigabitEthernet0/0/0 </strong>command, and then used the <strong>no shutdown </strong>instruction to bring it up. By default, most router and switch interfaces are disabled, so using this command turns it on for usage.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/492/1*J0f6ZiI0Gh2O9YNNHt7bZQ.png" /></figure><p>Then I needed to get the sub-interfaces configured for each of the three VLANs. This was done for VLAN 10 with the following commands:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/504/1*tSt0FVKAuyZmtt23FZpTFw.png" /></figure><p><strong>interface gi0/0/0.10</strong></p><p><strong>Explanation: </strong>this command sets the sub-interface interface of Gi0/0 (which faces the switch per the network diagram) to VLAN 10</p><p><strong>encapsulation dot1Q 10</strong></p><p><strong>Explanation:</strong> This command configures the router’s interface to use the IEEE 802.1Q standard to tag traffic for VLAN 20. The router does it by adding the 802.1Q tags to Ethernet frames — and these will be set to the value of 10, corresponding to the VLAN 20.</p><p><strong>ip address 192.168.10.1 255.255.255.0</strong></p><p><strong>Explanation: </strong>We set the IP address ranges for this VLAN</p><p><strong>no shutdown</strong></p><p><strong>Explanation: </strong>We ensure that this sub-interface remains up</p><p>Great. I then did similar commands for the other VLANs as well.</p><ul><li><strong>VLAN 20 (IT)</strong>:</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/512/1*CUMn-W5ybBdUabvcV0DYeg.png" /></figure><ul><li><strong>VLAN 30 (Guest)</strong>:</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/477/1*5klMfkk4i_Y-dB_gEqR2SQ.png" /></figure><p>And locked the changes with the <strong>exit</strong> and <strong>write memory </strong>instructions.</p><p>Notice that we now have green triangles appear on the Cisco Packet Tracer application indicating a successful Layer 2 connection has been formed.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/506/1*1O1apZmSPOBGAPoihHXd7w.png" /></figure><p>The next step was to do what is typically done only on home and small office networks: to have the router also function as the DHCP server. Larger enterprises typically have their own separate DHCP servers.</p><p>I entered the configuration mode with the <strong>enable </strong>and <strong>configure terminal </strong>commands and then first entered in the following commands to exclude the first ten IP addresses for the Sales, IT, and Guest departments’ VLANs.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/529/1*Xqh8WVSKy6IAtddaOQOxUQ.png" /></figure><p>This was done to reserve them for static assignments like the default gateway, servers, printers, or network infrastructure devices that require predictable, unchanging IP addresses.</p><p>Next up was to configure DHCP pools. Each VLAN requires its own DHCP pool configured on the router, which provides clients with an IP address, subnet mask, default gateway, and DNS server.</p><p>Notice that the <strong>default-router</strong> parameter is essential because without it, devices can only communicate with other hosts on their immediate subnet — they have no way to reach other VLANs or external networks. When a device needs to send traffic outside its subnet (for example, a Sales PC reaching an IT server or accessing the internet), it forwards that traffic to the default gateway, which is the router’s subinterface IP for that VLAN. This is why the <strong>default-router</strong> value (192.168.10.1, 192.168.20.1, 192.168.30.1) must exactly match the IP address we configured on each corresponding subinterface — the router must be reachable at that address to route traffic.</p><p>The commands used were as follows:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/488/1*jJDjmILOTnlE0BXuiqWoyw.png" /></figure><p>I then opened up each of the PC-related panels, and set the <em>Gateway/DNS IPv4</em> option to DHCP — and all the PCs got the right IP addresses corresponding to the VLANs they were in!</p><p><strong>Sample Screenshot For SalesPC1:</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/695/1*e-T6G_pi7cLOAuT4Kswxsw.png" /></figure><p>Then on the central router, I used the <strong>show ip dhcp binding</strong> command to see what IP addresses were assigned to what systems (identified by their MAC addresses):</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/538/1*ZWKPltOYR9q1fPeYsAL7sg.png" /></figure><p>I then opened the command prompt on the <strong>SalesPC1 </strong>and then pinged all the IP addresses listed from the previous screenshot — except for the 192.168.10.11 IP address, which belonged to the <strong>SalesPC1 </strong>itself. All the pings worked!</p><p>I then also opened up the command prompt on <strong>GuestPC1</strong> and did the same for all the IP addresses corresponding to the hosts on this network.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/683/1*2LXKEFfQPWX-9dRKNh-g-g.png" /></figure><p>They worked!</p><p><strong>Section II: Security With ACLs</strong></p><p>Remember the pings I was able to make from <strong>SalesPC1 </strong>and <strong>GuestPC1 </strong>to all the other computers on the other VLANs? This is indicative of a security problem: Guest users could access the Sales and IT networks, and there was no traffic segmentation beyond simple subnet separation!</p><p>Access Control Lists (ACLs) solve this by providing granular traffic filtering at the network layer — they’re essentially firewall rules applied directly on routers and switches. In enterprise environments, ACLs complement dedicated firewalls rather than replace them: <strong>firewalls typically protect the network perimeter</strong> (blocking external threats), while ACLs handle<strong> internal segmentation</strong> (controlling traffic between VLANs and departments). ACLs are already built into the routing infrastructure, making them ideal for this use case — no additional hardware required, minimal performance impact, and they operate at wire speed on the router’s existing interfaces.</p><p>The next thing that I needed to introduce is the concept of ACL identification. To make a long story short, there are different ACL types, with the two primary and common ones being the Standard and Extended ACL. All ACL types can be identified either by numerical identifiers being within certain numerical ranges — or by name.</p><p>While in this lab I decided to go with names for ease of readability and description, others can and have gone with numerical identifiers. The following table shows which numerical ranges correspond to specific ACL types</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/393/1*Es1rSKIRDPxRNW5XNYBAMg.png" /></figure><p>While ACLs are a universal networking concept (every vendor implements them), the syntax and configuration I used for this section is Cisco-specific, particularly the distinction between standard ACLs (filtering by source IP only) and extended ACLs (filtering by source, destination, protocol, and port).</p><p>Off to work! First, I needed to block the Guest VLAN from accessing internal networks — and to be only able to access the internet. I opened up the Router’s configuration terminal and did the following</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/614/1*531iFROULKhF7zBctV3k8A.png" /></figure><p>Here I blocked ALL IP traffic from the Guest VLAN (192.168.30.0/24) to the Sales and IT VLANs (192.168.10.0/24 and 192.168.20.0/24). The 0.0.0.255 wildcard mask specifies that any host in the 192.168.30.x range is blocked from reaching any hosts in the IP ranges of the two VLANs). Together, these two deny statements ensure that Guest users cannot access any corporate resources on either internal network.</p><p>Notice the <strong>permit ip any any</strong> line at the last screenshot’s bottom. This one is critical and often confuses beginners. Without it, the ACL’s implicit deny-all rule at the end of all statements <strong>would block all traffic from the Guest VLAN</strong>, including broader Internet access. ACLs process rules top-to-bottom and stop at the first match, so this permit statement ensures that after blocking Guest access to Sales and IT networks (lines 1–2), all other traffic from Guest devices (such as internet-bound traffic) is allowed. This is a fundamental ACL principle: be specific about what you deny, then explicitly permit everything else, or the invisible ‘deny ip any any’ at the end will block it.</p><p>Then I did the next set of commands to apply it to the interface</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/476/1*ixedsnJqSwFInwzbY1ZPiQ.png" /></figure><p>Notice that this command applies the ACL named <strong>BLOCK-GUEST-ACCESS</strong> to the <strong>g0/0/0.30 </strong>subinterface on the router for VLAN 30 in the inbound direction. “Inbound” means the ACL examines packets as they arrive at this interface from Guest devices. When a Guest PC tries to send traffic through the router, the ACL checks it against our rules before routing it — if it’s destined for Sales or IT, it’s dropped. Otherwise, it’s forwarded.</p><p>I also forgot to add in a remark line, which is basically the equivalent of a documentation comment for an ACL rule, so I quickly popped open the ACL by name again and added it</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/597/1*Vr5xZjidfL9q1QcrvDNyKg.png" /></figure><p>Then I saved the changes with the <strong>exit </strong>and <strong>write memory</strong> commands.</p><p>I then needed to create another ACL to allow the Sales staff to only access IT web services only. These web services would be hosted by the IT staff on their VLAN on ports 80 and 443 for the HTTP and HTTPS services respectively. See the screenshots below for the commands used:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/715/1*mNf5oIRF2CY-wQa_rIRntw.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/732/1*_8e5HDsaIZHgh71VzQKKyg.png" /></figure><p>Carefully consider the rules that I wrote. Remember that ACL rules are evaluated from top to bottom for any conditions that are true, and that there is an implicit ‘deny ip any any’ rule at the bottom. Here, I:</p><ul><li>Explicitly allowed the ports #80 and #443 for the HTTP and HTTPS services</li><li>Permitted ICMP from any to any address on the entire network, which enables basic network troubleshooting with the <strong>ping </strong>and <strong>traceroute </strong>commands<br><strong>NOTE: </strong>In real-world hardened environments, ICMP is often hardened because of <a href="https://medium.com/@martin.juarez/the-danger-of-icmp-604dbcc7adfc">the threats</a> that having a blanket allowal of ICMP traffic poses. I only allowed myself to do this in the context of a limited homelab exercise</li><li>Denied all other traffic from the Sales to IT VLANs and their respective IP address ranges</li><li>Allowed all other traffic that didn’t match earlier rules, such as Sales communicating with the Guest VLAN, Internet, etc. This was done with the <strong>permit ip any any </strong>line at the end, which was super important to have because otherwise the implicit <strong>deny ip any any </strong>rule would kick in and prevent the Sales team from opening their workstations and being able to access the broader Internet</li></ul><p>Then I tested out the two ACLs that I deployed on the router with the <strong>write memory </strong>commands with pings.</p><p>I opened up the <strong>GuestPC1 </strong>command prompt and pinged the <strong>192.168.10.11</strong>, <strong>192.168.10.12</strong>, <strong>192.168.20.11</strong>, and <strong>192.168.20.12 </strong>addresses — and as expected got the <em>Destination Host Unreachable</em> results. As expected, the <strong>GuestPC1 </strong>could not connect to/communicate with the hosts in the Sales and IT VLANs.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/687/1*OQCMN20214lN7LWwKOuv0A.png" /></figure><p>After that, I opened the command prompt for the <strong>SalesPC1 </strong>computer and pinged the two computers in the IT VLAN:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/690/1*oz594ahaOTsbbKRsilYP4A.png" /></figure><p>That worked as expected. Then I pinged the two computers in the Guest VLAN — and didn’t get a response back</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/686/1*clKv45MyWkzAw572ziqOEA.png" /></figure><p>Don’t worry — this is exactly the behavior we expected. Take a look at the ACL highlighted in the red box below.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/627/1*gaguoPGTBedbgiwWTaQFEQ.png" /></figure><p>This rule explicitly denies all traffic originating from the Guest VLAN (192.168.30.0/24) destined for the Sales VLAN (192.168.10.0/24). Because Cisco ACLs are stateless, this <strong>deny </strong>also applies to return traffic.</p><p>When <strong>SalesPC1 </strong>sent an ICMP Echo Request to a host in the Guest VLAN, the request itself was permitted and left the Sales network successfully. However, when the Guest hosts attempted to send the <strong>ICMP Echo Reply </strong>back to <strong>SalesPC1 </strong>, that reply packet matched the deny rule in the ACL and was dropped.</p><p>As a result, the ping failed — not because the request couldn’t be sent, but because the reply was explicitly blocked by design. This behavior enforces proper isolation of the Guest VLAN and confirms that the ACL is functioning as intended.</p><p><strong>Section III: Security With Port Security<br></strong>I spent this section of the exercise learning port security. Port security addresses a vulnerability that ACLs cannot: physical network access. Even with perfect ACL configurations controlling traffic between VLANs, an attacker who gains physical access to your building could simply unplug an authorized device and connect their own laptop to the network. Port security solves this by allowing network administrators to restrict which devices can connect to each switch port, limit the number of devices per port, and automatically disable ports when violations occur. This is particularly critical in environments with publicly accessible network jacks (conference rooms, lobbies, common areas) or when you need to prevent employees from connecting unauthorized devices like personal routers, wireless access points, or additional switches that could compromise network integrity.</p><p>I got started with the following commands shown in the following screenshot (using the following <a href="https://www.pinglabz.com/securing-your-network-with-cisco-port-security-a-comprehensive-guide/">linked page</a> as a guide)</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/526/1*xzRMNu5jEXcXaSTbwvSleA.png" /></figure><p>To simply explain, I enabled port security on the FastEthernet0 ports #1–6, allowed only one device per port, and made a port shutdown automatically if there was any security violation. While Cisco offers other violation modes like <strong>restrict </strong>(drops packets but keeps port up) or <strong>protect </strong>(silently drops packets), the <strong>shutdown </strong>mode is the most secure because it immediately disables the port and forces manual administrator review before network access is restored, ensuring that security incidents are investigated rather than ignored.</p><p>Additionally, the usage of the <strong>switchport port-security mac-address sticky</strong> command had the switch automatically dynamically/automatically learn the MAC addresses of the connected MAC devices currently connected to it. This ensured that the switch would only allow traffic from devices containing these MAC addresses, and would shut down the ports if an unauthorized device tried to connect.</p><p>After that, I needed to shut down the remaining FastEthernet ports on this switch, #’s 7 through 24 to prevent any unnecessary connections. This was done with the following commands</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/610/1*0zWavKLLkajkGp6a_FzYvQ.png" /></figure><p>To wrap this section up, I took a look at the status of port security on the FastEthernet0/1 interface with the <strong>show port-security interface f0/1 </strong>command and the <strong>show ip interface brief </strong>command</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/575/1*ACnEjszYn4FFpCJ-UF9rkw.png" /></figure><p><strong>Section IV: Internet Access (NAT) &amp; Hardening</strong></p><p>Next up, I took a “Cloud” device in Cisco Packet Tracer. The cloud icon (PT_Cloud) represents a generic connection to an external WAN network.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/517/1*MLRb_xL0M3_lXb_FvyVJnw.png" /></figure><p>Then I used the straight-through cable to connect the router to the cloud device. Remember that straight-through cables connect systems of different types — in this case a router to the WAN “cloud” device. I then needed to bring the interface online for the connection to be formed, and to get the IP address assigned</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/640/1*dnpjk1OQJpvNqSmaoQlsmg.png" /></figure><p>Notice the<strong> ip address dhcp</strong> line. This command on interface G0/1 made the router act as a <strong>DHCP client</strong> on its WAN interface, simulating how a real router connects to an ISP. Instead of manually configuring a public IP address (which we wouldn’t know in advance), the router automatically requests an IP from the Internet cloud’s DHCP server — just like your home router gets its public IP from Comcast or AT&amp;T. This is the same router that’s acting as a DHCP server for our internal VLANs on the LAN side, demonstrating how routers play different roles on different interfaces: DHCP client on the WAN, DHCP server on the LAN.</p><p>I then got to work configuring the Port Address translation (PAT), also known as “NAT overload” — to allow all devices on my private VLANs (Sales, IT, and Guest) to share a single public IP address when accessing the internet. PAT is the most common form of NAT used in homes and small offices because it conserves public IPv4 addresses (which are scarce) by mapping multiple private internal IPs to one external IP, distinguishing connections using unique TCP/UDP port numbers.</p><p>The commands I used were as follows:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/632/1*FfSCcBN8Z8sUD4EigohUWA.png" /></figure><p>That was a very long screenshot. Let’s digest this in detail. I first created a standard access list to identify the internal traffic that should be translated: <strong>access-list 1 permit 192.168.10.0 0.0.0.255</strong> (and similarly for .20.0 and .30.0).</p><p>Then, I enabled PAT with <strong>ip nat inside source list 1 interface GigabitEthernet0/0/1 overload</strong>, which told the router to overload PAT translations using the dynamically assigned IP on the WAN interface (G0/0/1). Ii then marked the interfaces on the “inside” with the <strong>ip nat inside</strong> command for the G0/0/0 and the G0/0/0.10 G0/0/0.20, and G0/0/0.30 and the <strong>ip nat outside </strong>command for the “outside” G0/0/1 interface. I then added a default route with the <strong>ip route 0.0.0.0 0.0.0.0 g0/0/1 </strong>to ensure that there was a default pathway to the g0/0/1 interface and the broader Internet for traffic coming onto the router.</p><p>Next up was to do some basic hardening on the router. First, I used the <strong>enable secret Cisco123!</strong> command to create a password for the router’s privileged configuration mode.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/433/1*T-CD7kyrBoE2Qip7UOXnXA.png" /></figure><p>Then the command <strong>username admin privilege 15 secret Admin123! </strong>created a local admin account named ‘admin’ on the router, giving it full privileged access (privilege level 15, equivalent to enable mode) right after login, while securely storing the password ‘Admin123!’ using strong hashing instead of weak or clear-text storage. In Cisco IOS, privilege levels range from 0 to 15: level 1 is the basic user mode with limited read-only commands, while level 15 grants complete administrative control, allowing configuration changes, debugging, and system management — most admins log in at level 15.</p><p>Then I needed to get SSH set up. This was done with the commands in the following screenshot:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/512/1*PB2xZZMs-fE-HRvjin9sZw.png" /></figure><p>To explain simply, I created a hostname and domain name of the <strong>hostname.domainname </strong>format (this being <strong>lab.local</strong>) because Cisco IOS requires domain-name to be set before SSH keys can be generated. From there, I generated the RSA keys of size 2048 bits that SSH would use, and used the <strong>line vty 0 4</strong>, <strong>login local</strong>, and <strong>transport input ssh</strong> commands to specify that up to five simultaneous virtual (i.e. remote) terminal connections are allowed using the SSH protocol <strong>only </strong>(Telnet is not allowed).</p><p>Next up to configure the console port — that is the physical serial connection you use when directly plugging in with a console cable. The console port has a line number of 0, thus ‘line console 0’. I did the following commands to require password authentication, and then made a banner to have a sample warning message saying “Authorized Access Only”</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/439/1*rzclpWN_1eyfW0GKNGibRQ.png" /></figure><p>These steps were nice and well-explained for the Cisco Router. I then opened up the switch and quickly did the same.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/527/1*9dBHU5ihRXwaKEgdj-ITIg.png" /></figure><h3>Section V: Network Troubleshooting Reference Section</h3><p>This section serves as my personal cheat sheet for troubleshooting Cisco switch/router issues, based on lab experience. Key commands that proved most useful:</p><ul><li><strong>Layer 1: </strong><em>show interfaces </em>(or <em>show interfaces [int]</em>)</li><li><strong>Layer 2</strong>: <em>show vlan brief, show interfaces trunk, show port-security, show mac address-table<br></em><strong>For Layer 2 Loops:</strong> <em>show spanning-tree<br></em><strong>For Neighbor Discovery:</strong> <em>show cdp neighbors</em></li><li><strong>Layer 3</strong>: <em>show ip interface brief, show ip route, show ip dhcp binding, show ip arp</em></li><li><strong>Security</strong>: <em>show access-lists, show ip nat translations</em></li><li><strong>General</strong>: <em>show running-config, ping, traceroute, show ip interface [int], show version</em></li></ul><h3>Troubleshooting Steps</h3><p>Use a <strong>bottom-up</strong> OSI method for most connectivity problems (start at Layer 1)</p><p><strong>Layer 1 — Physical<br></strong>Check cables, power, and link lights first. Use <strong>show ip interface brief </strong>— “administratively down” needs no shutdown; “down/down” indicates physical or Layer 2 issues.</p><p>Run <strong>show interfaces [int] </strong>and look for:</p><ul><li>Input/output errors, CRC errors, giants/runts, collisions, late collisions</li><li>Speed/duplex mismatches (common cause of errors) Swap cables/ports to isolate hardware faults.</li></ul><p><strong>Layer 2 — Data Link / Switching</strong></p><p>Confirm VLAN membership with <strong>show vlan brief.</strong></p><p>Check trunking with <strong>show interfaces trunk </strong>— missing VLANs on trunks are frequent problems.</p><p>Additional checks:</p><ul><li><strong>show mac address-table</strong> — verify MAC learning/port mapping</li><li><strong>show spanning-tree (or summary/vlan) </strong>— look for blocking ports or loops</li><li><strong>show cdp neighbors</strong> — confirm physical connectivity to neighbors. For port security violations: show port-security interface [int] — “err-disabled” ports need shutdown then no shutdown</li></ul><p><strong>Layer 3 — Network / Routing &amp; Addressing</strong></p><p>Verify IP assignment: <strong>show ip dhcp binding </strong>(or verify that the IPs were assigned with static config)</p><p>Test gateway reachability: <strong>ping &lt;gateway IP&gt;</strong></p><p>Check routing table: <strong>show ip route </strong>— ensure a default route exists (gateway of last resort)</p><p>Use <strong>show ip arp</strong> for ARP issues, extended ping (source interface, size for MTU), and traceroute to find drops</p><p>For internet access: <strong>show ip nat translations </strong>— no active translations when testing external sites indicates NAT/ACL misconfiguration</p><p><strong>Higher Layers — DNS, Transport, Application</strong></p><p>Ping external IP (e.g., 8.8.8.8) succeeds but domain names (google.com) fail → DNS problem (check client/relay config).</p><p>For Layer 4/7 issues: test ports (limited telnet in IOS), review app logs, browser errors, or try alternate clients. Higher-layer failures often appear after lower layers test clean.</p><h4>Security &amp; ACLs</h4><p>One-way or service-specific blocks: <strong>show access-lists </strong>and <strong>show ip interface [int]</strong> (check applied direction).</p><p>ACLs match top-down, implicit deny at end — order and placement matter.</p><h4><strong>General Best Practices</strong></h4><ul><li><strong>show logging</strong> for events/errors</li><li><strong>show processes cpu / memory</strong> for resource issues</li><li>Compare with <strong>show running-config</strong> against backups/docs. Work methodically: change one thing at a time, verify after each step, document your logic. Avoid random fixes that create new problems</li></ul><p>That’s all for this round. I’ll see you in the next one!</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=e7c8dfb113fb" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Homelab #13: Advanced Virtual Networking with VyOS]]></title>
            <link>https://secnate.medium.com/homelab-13-advanced-virtual-networking-with-vyos-d03548afc544?source=rss-f95ccfcb96af------2</link>
            <guid isPermaLink="false">https://medium.com/p/d03548afc544</guid>
            <dc:creator><![CDATA[Nathan Pavlovsky]]></dc:creator>
            <pubDate>Thu, 15 Jan 2026 01:39:59 GMT</pubDate>
            <atom:updated>2026-01-15T01:54:11.737Z</atom:updated>
            <content:encoded><![CDATA[<p>Welcome back to the next round of homelabs! In this episode, I worked on building a fully virtualized enterprise network environment to master the core skills that matter most in production networks: dynamic routing protocols (OSPF and BGP), VLAN segmentation with access controls, and site-to-site VPN connectivity.</p><p>I chose VyOS — an open-source Linux-based network operating system that is a unified platform for routing, firewalling, and VPN — as my primary routing platform. It’s a powerful, enterprise-grade network OS that’s completely free and runs efficiently in VMs. This allowed me to get hands-on experience with the same routing concepts used in Cisco and Juniper environments, but without the hardware costs or licensing headaches. By combining VyOS with a fresh pfSense firewall and keeping the setup to just four VMs, I created a realistic multi-site network topology that demonstrates real-world troubleshooting skills while remaining lightweight enough to run on my homelab workstation’s modest hardware.</p><p>The final network looked like this:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/756/1*T42UG2FawHTeqT32KU8OpA.png" /></figure><p>Let’s get started!</p><p><strong>Section I: Preparing the pfSense Firewall</strong></p><p>I downloaded the pfSense firewall image from <a href="https://atxfiles.netgate.com/mirror/downloads/"><strong>https://atxfiles.netgate.com/mirror/downloads/</strong></a><strong> </strong>(version 2.6.0). With regards to my using the older pfSense firewall installer, I went with the legacy pfSense CE 2.6.0 full offline ISO to sidestep potential VMware NAT connectivity glitches that could affect a modern Netgate installer when getting pfSense installed and configured in a homelab virtualized network environment that does not communicate with the broader Internet.</p><p>Then I opened up VMware Workstation as my Virtual Machine editor. I wanted to get some practice with it since I am <strong>**very**</strong> familiar with VirtualBox. First up was to use the Virtual Network Editor by using the <em>Edit &gt;&gt; Virtual Network Editor</em> button. Notice that the following screenshot is what I had from <strong>before </strong>getting started on this homelab. — from other homelabs</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/591/1*1QJdoTbiPaUNdNXcubrRlw.png" /></figure><p>Notice that <strong>VMnet0</strong>, <strong>VMnet1</strong>, <strong>VMnet8</strong> were all taken and <strong>are not</strong> part of this homelab exercise. I then got to work on making the virtual networks.</p><ul><li><strong>LAN Trunk </strong><em>— </em>Trunk network that will carry VLANs 10, 20, 30 between the different VMs</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/591/1*pubZvAuftFTRorRtnw1fIA.png" /></figure><p>Notice the Subnet Address was of value 0.0.0.0. In this Custom configuration, the DHCP service was disabled, no explicit subnet IP or subnet mask was specified, and there was no host connection or NAT functionality enabled. As a result, no IP subnet was configured on the host side, and VMware displays 0.0.0.0 to indicate that there is no assigned subnet. <strong>This is exactly what we want for a pure Layer 2 network</strong> that only forwards VLAN-tagged Ethernet frames without any Layer 3 routing intelligence.</p><ul><li><strong>Core-to-Edge</strong>: This network was the direct transit link between <em>VyOS-Core</em> and <em>VyOS-Edge </em>VMs</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/593/1*ah0z_BbchsugCXmUkcek0Q.png" /></figure><p>Notice that in this case, I used the value of <strong>Subnet Mask = 255.255.255.248 </strong>, which is equivalent to <strong>/29 </strong>in prefix length. This (per <a href="https://www.aelius.com/njh/subnet_sheet.html">a very handy subnet sheet</a><em>) </em>resulted in 8 total addresses on this subnet, and only 6 hosts.</p><p>There is a reason for what I did. I tried to use a <strong>Subnet Mask = 255.255.255.252</strong> with a <strong>/30 </strong>prefix length value, which would result in four total addresses and two hosts, since the first and last addresses are the Network ID and Broadcast addresses respectively. I intended to do such since I only planned to have two hosts connected to this <strong>Core-to-Edge</strong> network. Doing such resulted in the following error, so I had to make do with a slightly larger subnet:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/344/1*lahznzNmOXBis89HJkTXlg.png" /></figure><ul><li><strong>WAN-Link: </strong>This network was to be the “simulated Internet” between the VyOS-Edge, VyOS-Core, and pfSense-New internal nodes</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/589/1*oKilyKzPiyWV8s3QGrqNMw.png" /></figure><ul><li><strong>pfSense-LAN: </strong>This is a management network intended to allow me to access the pfSense web GUI from the host machine during initial setup and configuration. It essentially was my “out-of-band” access path so I could configure pfSense’s interfaces, VLANs, and firewall rules through a web browser before the routing and trunk networks are fully operational.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/590/1*3U9L79fLOQHEVhlsehC5gw.png" /></figure><p>Notice that I enabled the “Connect a host virtual adapter to this network” option for the pfSense-LAN network because it creates a virtual network interface on my Windows host machine, allowing me to directly access the pfSense web GUI from my browser for initial configuration and ongoing management. This would be important later.</p><p>Wonderful. Next up was to make the VMs. I got started by making the <strong>pfSense </strong>Virtual Machine that would act as a firewall and my simulated “Internet gateway” from scratch. The desired specs were to have 2 CPUs, 4 GB of RAM, and a 20 GB disk:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/416/1*p6vQ7M9wlB-c_MpUgf8zGA.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/418/1*pMGgrd5UcRb9bvl4HOPNzw.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/416/1*KQmogb4R01VFPPUtf2K60w.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/419/1*95iRiGCvZZBIss4O60K8sw.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*-4zc4Ao-rtufQOFZJC6bhw.png" /></figure><p>I then needed to create the 3 network interfaces for this VM. Remember the network diagram from the introduction to this homelab? The pfSense VM was to connect to three networks; the <strong>WAN-Link</strong> network, <strong>LAN-Trunk</strong>, and <strong>pfSense-LAN</strong> management networks,. I thus went back into the Virtual Machine network page, added three network interfaces, and connected them to the three networks in order.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/750/1*QwBZWIluHjQ4YL90gR9O-Q.png" /></figure><p>I then quickly fired up the VM and went through the installation walkthrough<strong> </strong>and arrived at this menu screen:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/691/1*iogayggP7gWBIO335E6HNw.png" /></figure><p>Next up was to get the Network Interface configurations down. Below is a screenshot of the presented 3 network interfaces that were seen, along with my annotation of the specific network that they correspond to.</p><p>Then I configured them appropriately</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/815/1*q8f7F3goF3GeLv0r1GMigA.png" /></figure><p>The em2 interface, to which I assigned the pfSense LAN interface, corresponds to the pfSense-LAN virtual network. Because the pfSense firewall serves its web GUI on the LAN interface and my homelab workstation is connected to the pfSense-LAN network, the em2 interface was the right one to configure for management access.</p><p>OK, I then needed to be able to access the pfSense Firewall’s GUI from my homelab workstation’s browser. Remember that my homelab workstation is to be considered a part of the <strong>pfSense-LAN </strong>management/setup network? To do this, I selected option <strong>2) Set Interface(s) IP Address </strong>and decided to configure the <strong>LAN (em2) </strong>interface corresponding to the pfSense LAN Network, and then entered in a temporary 192.168.1.100/24 IP address</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/751/1*5wrTw-QAzomcK0cEByk98w.png" /></figure><p>And then configured the DHCP server and the starting and ending IP address range to be <strong>192.168.1.100–192.168.1.200</strong>, entered <strong>n</strong> for the setting of whether we wanted to revert to HTTP as the webConfigurator protocol, and got the changes pushed through</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/743/1*H9sDB-WWaqD_fU_kwMNKDQ.png" /></figure><p>Wonderful. Next up was to configure some settings on my Homelab workstation VM. Remember I created the <strong>Homelab13_PFSENSE_LAN</strong> virtual network, whose information I am re-showing below?</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/590/1*3U9L79fLOQHEVhlsehC5gw.png" /></figure><p>Remember that I enabled the “Connect a host virtual adapter to this network” option for the pfSense-LAN network because it creates a virtual network interface on my Windows host machine? Now came the time to take advantage of it. This virtual network was originally named <strong>VMNet12 </strong>and renamed to <strong>Homelab13_PFSENSE_LAN</strong>.</p><p>Opening up my Windows homelab workstation’s Control Panel, I found the adapter that was created for this network.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*_WC1S9n1Vl8lJdKn_2WsgQ.png" /></figure><p>Great. This allowed me to successfully open up the pfSense Firewall’s login page.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/960/1*nwVzxryxUeMcZv9huAvUtQ.png" /></figure><p>And then logged in with the default <strong>admin</strong> username and <strong>pfsense</strong> password.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*YeL0X1_nDRSRbXgN2vYOQA.png" /></figure><p>Just as a reminder, I already configured the pfSense’s firewall’s LAN interface for the <strong>pfSense-LAN </strong>network as being of the static 192.168.1.100/24 IP address.</p><p>I then needed to configure the other two interfaces: the <strong>WAN </strong>interface for the <strong>WAN-Link </strong>simulated Internet network and the OPT1 interface for the <strong>LAN-Trunk </strong>network. [See the network topology image at the top of this writeup’s page for a visual understanding of how the final planned network topology was to look]</p><p>Before getting started with configuring these two, I quickly renamed my LAN Interface in pfSense as being <strong>pfSense-LAN-Interface</strong>. I did this in order to prevent any potential confusion with the originally-named OPT1 interface which I was to use for the <strong>LAN-Trunk Network</strong>. I then clicked the <strong>Save</strong> and <strong>Apply Changes</strong> button to make things go through.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/986/1*XxtQ8Nbp3kkltgvh3UGbwA.png" /></figure><p>Perfect. I started with configuring the <strong>WAN </strong>interface for the <strong>WAN-Link </strong>simulated Internet network. I named it <strong>WAN-Link Interface</strong>, set the <strong>IPv4 Configuration Type</strong> to <em>DHCP</em>, and set the <strong>Alias IPv4 Address</strong> field to 203.0.113.0/24.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/989/1*AISEasZKjUn-8dQQlrFYAQ.png" /></figure><p>I clicked the <strong>Save</strong> and <strong>Apply Changes</strong> button to make things go through.</p><p>Next up was to prepare the VLANs that were to be in the <strong>LAN-Trunk</strong> network so that I could go and configure the <strong>LAN-Trunk Network </strong>interface appropriately. Per the network concept diagram at the top of this Homelab writeup, there were supposed to be three VLANs made: <strong>VLAN 10 </strong>(Admin Network), <strong>VLAN 20 </strong>(Guest Network), <strong>VLAN 30 </strong>(IoT Network). I quickly went into the <em>Interfaces &gt;&gt; Interface Assignments </em>page</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/984/1*cunGLK4Oyxz8m0F9hnVQqw.png" /></figure><p>Then selected the <em>VLANs</em> button and arrived at the <strong>VLAN Interfaces</strong> menu</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/976/1*42qszWq1UYJ42SEnap2umQ.png" /></figure><p>I then clicked the <strong>+Add</strong> button three times and proceeded to add the three VLANs in order, with OPT1 being the Parent Interface. See the screenshots for the configuration information</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/981/1*oUmxomnv8ait3PXmaLZAgQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/980/1*idWgdUKjY5ytRuorvjS-6Q.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/984/1*0u70T_6nsq99xlEoT-JO4g.png" /></figure><p>Great. I then needed to go into the <em>Interface Assignments Page</em> where we had the information regarding the three network interfaces on this pfSense VM.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/981/1*xPb-YygNf9koHZWb5egG8Q.png" /></figure><p>And added in the interfaces for those three VLANs:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/983/1*SD49ibKnCuBHhR4r-HARWQ.png" /></figure><p>Then I went into each one and quickly configured them by checking the <strong>Enable Interface </strong>button, and setting the appropriate IP address ranges for each:</p><ul><li><strong>VLAN 10 (Admin Network): </strong>10.0.10.0/24</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/973/1*hplRUGgb6HR8SqK2BqM4oQ.png" /></figure><ul><li><strong>VLAN 20 (Guest Network): </strong>10.0.20.0/24</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/976/1*CAV9dN-CtDGJZf7GAqj1GA.png" /></figure><ul><li><strong>VLAN 30 (IoT Network): </strong>10.0.30.0/24</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/973/1*o-u34ASiEAYx6tRfg3DLYQ.png" /></figure><p>Excellent. Next up was to create some firewall rules for each interface. Per <a href="https://docs.netgate.com/pfsense/en/latest/firewall/rule-methodology.html">documentation</a>, pfSense applies firewall rules to traffic <strong>entering </strong>a specific interface (e.g. VLAN 30 LAN Trunk Network Interface rules filter traffic flowing from that interface <strong>into </strong>pfSense). Additionally, pfSense has an implicit deny rule on any traffic that is not explicitly allowed.</p><p>Due to the fact that I had six interfaces to deal with from the three initial VM ones and the three additional ones for the VLANs #10, 20, and 30, I am going to put a brief description of the rules that I made, along with the respective screenshots.</p><ul><li><strong>pfSense-LAN Network Interface:</strong> I added in three rules at the bottom of the pfSense-provided-by-default ones, which allowed any host on the <strong>pfSense-LAN</strong> network to reach out to any of the hosts on the VLAN 10, 20, and 30 networks</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*34K3pfOA2LNsWhbmDR61Mg.png" /></figure><ul><li><strong>WAN-Link Network Interface:</strong> I left the WAN interface with default deny rules, blocking all unsolicited inbound traffic from the simulated internet — following the security principle of allowing outbound traffic while blocking unsolicited inbound connections. The outbound allow rules were (as you will see) later configured on each VLAN interface</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Gh2EfsMPf3_3qi7q7_OWHw.png" /></figure><ul><li><strong>LAN-Trunk Network Interface: </strong>I left it as is — with no rules. This is because the LAN-Trunk interface operates purely at Layer 2, carrying VLAN-tagged frames between pfSense and the VyOS routers. All routing logic and firewall rule enforcement occurs at the Layer 3 VLAN subinterfaces (VLAN 10, 20, 30), which is why no firewall rules are configured directly on the trunk interface itself.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*eT0yNfh8y1ObCUYx86YMkQ.png" /></figure><ul><li><strong>VLAN 10 (Admin Network): </strong>I configured VLAN 10 (Admin network) with permissive firewall rules allowing administrators full access to all networks, including unrestricted internet access via the WAN-Link interface and web GUI management access to pfSense on both HTTP and HTTPS ports. This reflects the privileged nature of the admin network, where trusted users require broad connectivity for system management and troubleshooting across the entire lab environment.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*wEH4ouZxys0_WQcNOpmmmQ.png" /></figure><ul><li><strong>VLAN 20 (Guest Network): </strong>I made rules allowing the Guest VLAN access to the broader internet only and blocked it from connecting to the Admin and IoT VLANs. I also blocked it from connecting to the pfSense firewall’s GUI at the HTTP and HTTPS ports of 80 and 443 respectively.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*8rLAGzV2ir7k3wxf7kwWKA.png" /></figure><ul><li><strong>VLAN 30 (IoT Network):</strong> Because IoT devices generally rely on DNS, HTTP/HTTPS, and NTP, I made rules allowing devices on the IoT network to reach out to the broader Internet using those ports. All other traffic was implicitly denied.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*pIPF7SPXPijNGJGPfyEVVA.png" /></figure><p>After configuring all these firewall rules, I can confidently say my network segmentation skills are on fire… wall. 🔥🧱</p><p><strong>Section II: Deploying VyOS VMs</strong></p><p>With pfSense configured and my VLANs properly segmented, it was time to tackle the heart of this homelab: dynamic routing with VyOS. While pfSense excels at firewalling and security, VyOS is purpose-built for enterprise-grade routing protocols — and that’s exactly what I needed to simulate a realistic multi-site network topology.</p><p>I chose VyOS for several compelling reasons. First, it’s a fully-featured, open-source network operating system that supports the same routing protocols you’d find in Cisco, Juniper, and Arista equipment — OSPF, BGP, EIGRP, static routing, and more — without the licensing costs or hardware requirements. Second, VyOS runs efficiently as a virtual machine, making it perfect for homelab environments where you want enterprise capabilities without enterprise budgets. Finally, the skills I’d gain configuring VyOS translate directly to other network platforms, since the underlying routing concepts (LSAs, AS numbers, neighbor relationships, route advertisements) are universal across vendors.</p><p>This lab involves my deploying two VyOS routers: VyOS-Core as my central hub running OSPF Area 0 and BGP AS 65000, and VyOS-Edge as my branch office router running OSPF Area 1 and BGP AS 65001. Together, they would form the backbone of my multi-site network, exchanging routes dynamically and providing the foundation for site-to-site VPN connectivity. This is where the real networking magic happens — moving beyond static routes and firewall rules into the world of dynamic routing protocols that power the internet itself.</p><p>Introduction is over. Time to get into action. I got started by going to the<strong> </strong><a href="https://vyos.net/get/nightly-builds/"><strong>https://vyos.net/get/nightly-builds/</strong></a> website to get VyOS’s latest rolling release. For VyOS, a rolling release is a continuously updated version of the open-source network OS, incorporating the latest code from developers and contributors daily.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*WjppT7lbuFH0IwMXGczJ5A.png" /></figure><p>I then got to work on creating my first VM: the <strong>VyOS-Core</strong> VM. Since I have previously demonstrated in this and other past homelab writeups how I create virtual machines, I will refrain from showing on a screenshot-by-screenshot basis the individual steps Instead, I will say that I created the new <strong>VyOS-Core </strong>VM with the following specs: 2 vCPUs, 2 GB RAM, 10 GB memory disk, and three network interface adapters connected to the <em>Core-to-Edge, LAN-Trunk,</em> and <em>LAN-Trunk </em>networks. <strong>VyOS-Core</strong> will serve as the central router in this lab, handling traffic between all three VLANs (Admin, Guest, and IoT) while learning and sharing routes dynamically with VyOS-Edge using OSPF and BGP — essentially acting as the main site router that connects the internal networks and exchanges routing information with the edge router that sits closer to the simulated internet. [See the network diagram towards the top of this writeup for a visual illustration of how it fits into the overall network that I have been working to make]</p><p>I then also went and created the <strong>VyOS-Edge </strong>VM with the following specs: 2 vCPUs, 2 GB RAM, 10 GB memory disk, and two network interface adapters connected to the <em>WAN-Link</em> and <em>Core-to-Edge</em> networks.</p><p>Then, I got to work, booted up the <strong>VyOS-Core </strong>VM, and arrived at this screen</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/784/1*Bjz5AKEqbhdhQDtUt91qaA.png" /></figure><p>I quickly logged in with the <em>vyos / vyos </em>username and password combination. Then, I used the <strong>install image </strong>command to deploy VyOS to my permanent storage.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/728/1*sLXw6PMfQbAp1qXB46IzvA.png" /></figure><p>When I first booted the VyOS VM from the ISO, it was running entirely in RAM as a live environment — meaning any configuration changes would be lost on reboot. Running the <strong>install image</strong> command permanently installs VyOS to the virtual hard disk, ensuring the operating system and any future configurations persist across reboots, making it a proper permanent router rather than a temporary live session.</p><p>There was a series of presented installation configuration options, I just blitzed through those and selected the default options. After installation, I got the prompt to reboot now</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/632/1*BEnytCUTBD4esJtDTBdiaw.png" /></figure><p>I initiated the reboot with the <strong>reboot</strong> command. Then I powered off the VM, removed the VyOS ISO disk from the configuration, turned the VM back on again, and logged in. I then used the <strong>show system image</strong> command to confirm that my VyOS-Core VM had the permanent image installed and running</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/734/1*hm2qUzf9fW_J7pGH12BsRA.png" /></figure><p>Cool. I then repeated the same series of steps on the <strong>VyOS-Edge</strong> VM. I ran the <strong>install image </strong>command, rebooted the VM, powered it off, removed the VyOS ISO disk from the configuration, turned it back on again, logged in, and used the <strong>show system image </strong>command to confirm that it also had the permanent image installed and running.</p><p><strong>Section III: Configuring VyOS VMs’ Interfaces</strong></p><p>I then pulled out the <a href="https://docs.vyos.io/en/latest/configuration/interfaces/ethernet.html">VyOS documentation page for ethernet interfaces</a> and used the <strong>show interfaces </strong>command to get a sense of the <strong>VyOS-Core </strong>and <strong>VyOS-Edge VM’s </strong>interfaces</p><p><strong>Interfaces on VyOS-Core VM:</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/787/1*Ts9ZFxqpcuCL5_89SR8_qA.png" /></figure><p><strong>Interfaces on VyOS-Edge VM:</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/758/1*NqwqWF1KGCNu9-FnGkJceA.png" /></figure><p>All this was pretty informative. I then saw the <em>Description</em> field and after looking at the documentation page decided to fill that field in with the <strong>set interfaces ethernet &lt;interface&gt; description &lt;description&gt;</strong> command. I first needed to get into the configuration mode with the <strong>configure </strong>command:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/278/1*aqfNNKbVk4HVOhm4y-mbuA.png" /></figure><p>This I did in short order, then I used the <strong>commit </strong>and <strong>save</strong> commands to make the changes active in the current session and permanently persistent across all sessions respectively</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/177/1*E-Vb_rDtx-UxBjZckZ9ZjA.png" /></figure><p>Now I had the following. I used the <strong>show interfaces </strong>command to see what was done</p><p><strong>Updated Interface Descriptions on VyOS-Core VM:</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/750/1*BeUt_l-z79EqUHDIipy4kg.png" /></figure><p><strong>Updated Interface Descriptions on VyOS-Edge VM:</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/749/1*qSNd_7UHRk1lJIPy9bJDUw.png" /></figure><p>These descriptions were then useful when working on the next steps to configure them. The next step was to assign IP address values to each interface. In each VM, I went into the configuration mode using the <strong>configure </strong>command, and then used the <strong>set interfaces ethernet &lt;interface&gt; address &lt;address&gt;</strong> command to assign the IP addresses before “locking” the changes in with the <strong>commit </strong>and <strong>save</strong> commands.</p><p>Notice that for the VLANs 10, 20, and 30 connecting to the <strong>VyOS Core</strong> VM, I also used the <strong>set interfaces ethernet &lt;interface&gt; vif &lt;vlan-id&gt; address &lt;address&gt;</strong> command. Per the <a href="https://docs.vyos.io/en/latest/configuration/interfaces/ethernet.html#cfgcmd-set-interfaces-ethernet-interface-vif-vlan-id">VyOS documentation</a>, 802.1q VLAN interfaces are represented as virtual subinterfaces using the <strong>vif</strong> <strong>&lt;vlan-id&gt; </strong>command.</p><p><strong>On the VyOS-Core VM:</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/646/1*bMeVs4y0q37Cwo4Droey8A.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/757/1*TGDjQygrsVlBplmY1G_xGg.png" /></figure><p><strong>On the VyOS-Edge VM:</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/744/1*1k8SG4u7Vq4n6DXbeH_HxQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/746/1*BkGcrPL_ZnNyzKWDsOXODA.png" /></figure><p><strong>Section IV: Configuring Dynamic Routing (OSP &amp; BGP)</strong></p><p>This section is the real heart of this homelab exercise. In this section, I configured the two critical enterprise routing protocols on the virtual network that I have created: OSPF and BGP.</p><p><strong>OSPF (Open Shortest Path First):</strong> OSPF is a link-state routing protocol used within organizations to automatically discover and share routes between routers by having each router build a complete map of the network topology. When a router learns about a network change (like a link going down), it floods that information to all other routers, which then independently calculate the best path to every destination using Dijkstra’s shortest-path algorithm. OSPF is widely deployed in enterprise networks because it converges quickly during failures, supports intelligent load balancing across multiple paths, and scales efficiently by dividing large networks into hierarchical areas.</p><p><strong>BGP (Border Gateway Protocol):</strong> BGP is the routing protocol that powers the internet, designed to exchange routes between different autonomous systems (AS) — independent networks operated by different organizations, ISPs, or companies, each identified by a unique AS number. Unlike OSPF which focuses on finding the fastest path within a network, BGP makes routing decisions based on complex policies like business relationships, network performance, and path attributes, allowing organizations to control how traffic enters and exits their networks. BGP is considered the “glue” of the internet because it enables thousands of independent networks worldwide to interconnect and share reachability information, ensuring that when you visit a website, packets can navigate across multiple organizations’ networks to reach their destination.</p><p>In this exercise, I configured VyOS-Core to run OSPF Area 0 (the backbone area) and advertise the three internal VLAN networks along with the Core-to-Edge transit link that connects it to VyOS-Edge. VyOS-Edge runs OSPF Area 1, which represents a separate site or location that connects back to the backbone through the Core-to-Edge link. <strong>OSPF uses “areas” to divide large networks into smaller, more manageable sections — and here, Area 0 always serves as the central backbone that all other areas must connect through. </strong>This OSPF configuration ensures that both routers automatically learn about all internal networks and can route traffic between them dynamically.</p><p>For BGP, I configured VyOS-Core as AS 65000 and VyOS-Edge as AS 65001 to simulate two independent organizations or network domains peering over the WAN-Link network. <strong>An autonomous system (AS) is essentially a collection of networks under a single administrative control with its own routing policies — think of it like a company’s entire network infrastructure identified by a unique number. </strong>This eBGP (external BGP) peering allows the two “autonomous systems” to exchange routing information across what we’re treating as the simulated internet, demonstrating how separate networks establish trust relationships and share reachability information.</p><p>By running both OSPF internally within each site and BGP externally between sites, I simulated a realistic enterprise architecture where internal networks use fast-converging link-state protocols while external connections use policy-based BGP for inter-organizational routing.</p><p>That was a <strong>lot </strong>of words. Now for the actual steps.</p><p>I started by opening up the VyOS Core router’s configurations option using the <strong>configure</strong> command and (<a href="https://docs.vyos.io/en/latest/configuration/protocols/ospf.html#ospf">per the appropriate documentation</a>) configured the VLAN 10, 20, and 30 address spaces to be for OSPF with the <strong>set protocols ospf area 0 network &lt;VLAN_CIDR&gt; </strong>command. Then I saved the changes with the <strong>commit and save </strong>commands.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/560/1*iI8b7hZMcKa6Lrla9c2WgA.png" /></figure><p>Then I opened up the VyOS-Edge router using the <strong>configure </strong>command and used the <strong>set protocols ospf area 1 network 172.16.0.0/30 </strong>command to set the Core-to-Edge network into Area 1</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/539/1*ycscRyuiD-eHybr8CH0PoA.png" /></figure><p>Now it was time to work on BGP. VyOS-Core (AS 65000) represents the internal enterprise network, while VyOS-Edge (AS 65001) represents an ISP-style edge that <strong><em>owns </em></strong><em>and advertises the WAN-Link “Simulated Ineternet” network.</em></p><p>Internal point-to-point and transit links (such as the Core-to-Edge) are handled by <strong>OSPF and intentionally not advertised via BGP</strong>, since they are infrastructure-only and have no meaning outside the AS. Instead, BGP is used solely to exchange <strong>meaningful destination prefixes</strong> across the AS boundary, keeping internal plumbing abstracted and clean.</p><p>Now, for the commands. I first went onto the VyOS-Core VM and entered the configuration mode with the <strong>configuration </strong>command. First I needed to have the VyOS-Core VM say that it is of the AS 65000 and to establish a formal peering session with the VyOS-Edge router. I used the <strong>set protocols bgp system-as 65000 </strong>command to set the Autonomous System number on the VyOS-Core router to 65000 and then the <strong>set protocols bgp 65000 neighbor 203.0.113.2 remote-as 65001</strong> command for this.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/602/1*WswV9xoIQqsebs1wyJB2HQ.png" /></figure><p>The IP address 203.0.113.2 served as the specific ‘digital front door’ for the VyOS-Edge router on the simulated WAN. By pointing VyOS-Core to this exact host address rather than the broader network ID, I enabled a targeted TCP connection that allowed the two routers to securely exchange routing information.</p><p>Then I needed to advertise the LAN-Trunk VLAN 10, 20, and 30 networks, so that they could be accessible to the WAN-Link “Simulated Internet” network. I did this using the <strong>set protocols bpg address-family ipv4-unicast network &lt;IP_CIDR&gt; </strong>command</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/719/1*rNI35UtKPqJAv3Pw8uwQtg.png" /></figure><p>I tried to use the <strong>commit</strong> message and got an error “WARNING: BGP neighbor ‘203.0.113.2’ requires address-family!” I quickly fixed this by executing the <strong>set protocols bgp neighbor 203.0.113.2 address-family ipv4-unicast</strong> command. Then I entered <strong>commit </strong>and <strong>save</strong> to lock the changes in.</p><p>Then I exited the configuration console and used the <strong>show ip bgp neighbors </strong>command to see what the configuration looked like.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/734/1*oi3TNgtYfEmhDFYrdfCUyg.png" /></figure><p>Nice. The next step was to go onto the VyOS-Edge VM, enter the configure mode with the <strong>configure</strong> command. Then I used the <strong>set protocols bgp system-as 65001 </strong>command to set its Autonomous System number and the <strong>set protocols bgp neighbor 203.0.113.1 remote-as 65000 </strong>to establish a formal peering session with the VyOS-Core router.</p><p>Then I needed to advertise the “Simulated Internet’s” WAN address space. I did this with the<strong> set protocols bgp address-family ipv4-unicast network 203.0.113.0/24 </strong>command. Then I Iset up the address family, just like with the previous WARNING message with the <strong>set protocols bgp neighbor 203.0.113.0/24 address-family ipv4-unicast</strong> command.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/740/1*oAy669k4dCCbYRji9MxULw.png" /></figure><p>After entering <strong>commit </strong>and <strong>save </strong>to lock the changes in, I exited the configuration console and used the <strong>show ip bgp neighbors</strong> to see what it looked like.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/775/1*49bUbIFPeSxCDETUyv-a3Q.png" /></figure><p>Looked right. Astute readers may have noticed that I did not perform any advertisements of the <em>Core-to-Edge </em>transit link (172.16.0.0/30 IP addresses). This is because the 172.16.0.0/30 subnet exists purely as infrastructure glue — it’s <strong>a point-to-point link</strong> that lets Core and Edge talk to each other directly. Both routers already had this as a connected route in their local tables and could already reach each other’s peering IP without any help from BGP.</p><p><strong>Bottom Line: </strong>doing BGP routing on point-to-point links is inefficient and would just clutter the routing table with unnecessary paths that provide no value to actual end-user traffic.</p><p><strong>Section V: Site-to-Site WireGuard VPN</strong></p><p>Next up was to configure a site-to-site WireGuard VPN tunnel between VyOS-Core and VyOS-Edge to simulate secure connectivity between two geographically separated locations <strong>over the untrusted WAN-Link “Internet”</strong>. While OSPF and BGP handle the routing, the VPN ensures that sensitive data traversing between sites remains confidential and tamper-proof.</p><p>It is important to note that VyOS images come with Wireguard installed and that <a href="https://docs.vyos.io/en/latest/configuration/interfaces/wireguard.html">good documentation also exists</a> for it. I also found a good WireGuard <a href="https://www.wireguard.com/quickstart/">Quick Start</a> installation tutorial and an <a href="https://jasonschaefer.com/wireguard-vpn-on-vyos/#:~:text=and%20begin%20with%20generating%20keys,+5qjq0hZVfoYOfgdmLp584oJ8UZFGRBMHQjPrqyA=%20%7D%20port%2051820%20%7D">additional blog post</a> that was useful for this section. Onwards!</p><p>First up was to generate the required base64-encoded public and private keys. Per the WireGuard <a href="https://www.wireguard.com/quickstart/">Quick Start</a> installation tutorial, this was done on both the VyOS-Core and VyOS-Edge<strong> </strong>VMs with the <strong>wg genkey | tee privatekey | wg pubkey &gt; publickey</strong> command</p><p><strong>On the VyOS-Core VM:</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/576/1*LYrItcvbMAO_nYHN5IxBIQ.png" /></figure><p><strong>On the VyOS-Edge VM:</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/589/1*g8dx3trLzhAmmPlhBNJ55Q.png" /></figure><p>After that, it was time to create the actual tunnel. I am going to attach the screenshots of the steps performed on each VM and then will provide the appropriate explanation</p><p><strong>On the VyOS-Core VM:</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/755/1*p-lfS30dZ5UwqnxylSzC2w.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/745/1*KPc3KesMOyv3HWnZjBSlHw.png" /></figure><p><strong>On the VyOS-Edge VM:</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/771/1*17cV4I9WPDtlK6c7Wf0R6w.png" /></figure><p>These steps are mostly straight forward. On each of the VyOS VMs, I created a special interface for the VPN, assigned an IP address to it (either 10.99.99.1 or 10.99.99.2), added the private key for decrypting incoming traffic and encrypting outgoing traffic, and set the port where it will listen for incoming WireGuard VPN packets. Then, I created a reference to the peer VyOS node’s interface for the Wireguard VPN, provided its public key and specified the physical IP address and port where the peer could be found.</p><p>After that, I used the quick <strong>show interfaces wireguard &lt;wg01 or wg02&gt; summary </strong>command on the core and edge VMs (with the corresponding wg01 and wg02 interfaces to see if the connection was fully formed.</p><p><strong>On the VyOS-Core VM:</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/597/1*f046SlV2p6v4lDICIUJ2RQ.png" /></figure><p><strong>On the VyOS-Edge VM:</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/598/1*JS8Htx3Y2riXrvTwHHHJFw.png" /></figure><p>[<strong>Side Note:</strong> the <strong>sudo wg show &lt;wg01 or wg02&gt; </strong>command is also similar in terms of the nature of its output — it can be useful in the future]</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/545/1*x7iS-8dQIxW24OujHlw_qg.png" /></figure><p>Notice the fact that they both sent out 3 KB but did not receive anything. This means that a handshake connection was not fully finished and no connection was made. Time to troubleshoot.</p><p><strong>Troubleshooting Log: WireGuard Tunnel Establishment</strong></p><ul><li><strong>The Issue:</strong> Initial tunnel status showed <strong>Sent </strong>data but <strong>0 Received</strong> bytes, indicating a one-way handshake failure.</li><li><strong>The Investigation:</strong> I performed a deep dive into the configuration of both <strong>VyOS-Core</strong> and <strong>VyOS-Edge</strong>. By comparing the generated public keys against the peer configurations, I identified a public key mismatch on the Edge router.</li><li><strong>The Fix:</strong> I entered configuration mode on <strong>VyOS-Edge</strong> and corrected the peer key using the following command: <strong>set interfaces wireguard wg01 peer to-wg01 public-key ‘&lt;CORRECT-Core-public-key&gt;</strong> <br>I then performed a clean reboot of both VMs to ensure all interface states were reset.</li><li><strong>The Validation:</strong> To “wake up” the tunnel (since WireGuard stays silent until traffic is present), I initiated cross-pings:</li></ul><p><strong>** From VyOS-Core:</strong> ping 10.99.99.2 count 5<br><strong>** From VyOS-Edge:</strong> ping 10.99.99.1 count 5</p><ul><li><strong>Result:</strong> The handshake was successfully completed. Running the <strong>show interfaces wireguard &lt;wg01 / wg02&gt; summary </strong>command on both VMs confirmed bidirectional traffic with bytes being sent and received. See the screenshots below:</li></ul><p><strong>On the VyOS-Core VM:</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/575/1*XfCUhy3zm2K8ohP0hQLtTg.png" /></figure><p><strong>On the VyOS-Edge VM:</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/553/1*ASSxl7H6VT41T6dP-U8Nng.png" /></figure><p><strong>To Recap: </strong>The WireGuard VPN was necessary because it acted as the secure ‘virtual bridge’ across my simulated internet (the WAN-Link). Without it, the Core and Edge routers would be completely isolated, unable to exchange private traffic or routing information securely across an untrusted network. By establishing this tunnel, I created a protected path that allows me to simulate a real-world multi-site corporate network where data stays encrypted as it travels between physical locations.</p><p><strong>Section VI: Inter-Site Reachability &amp; Post-Tunnel Routing</strong></p><p>A common pitfall in VPN setups is assuming that a successful handshake equals full connectivity. In reality, VyOS-Core knows its local VLANs, but VyOS-Edge is ‘blind’ to them until they are explicitly routed. This is why I had to advertise the internal subnets across the newly established WireGuard tunnel.</p><p>The tunnel itself was a mandatory first step. Because the Admin VLAN uses private IP space (specifically the <strong>Private IP Class A CIDR </strong>of 10.0.0.0/8), it cannot traverse the ‘public’ WAN-Link without being encapsulated and encrypted. The VPN acts as a secure escort for this sensitive management traffic, protecting it from exposure. However, even with a secure pipe in place, without a routing entry — either static or dynamic — the Edge router would receive traffic from the internal VLANs and have no routing entry to send an appropriate response back, effectively killing the connection. Connections are a two-way-street. By adding such a static routing entry, I transformed a simple peer-to-peer connection into a fully reachable site-to-site network.</p><p>While I could have advertised all three VLANs (Admin, Guest, and IoT), I chose to focus on the Admin VLAN first to practice the principle of Least Privilege. In a production environment, you don’t always want your IoT or Guest traffic traversing a secure site-to-site VPN. By selectively routing only the management network, I demonstrated the ability to control traffic flow and preserve tunnel resources for the most critical data.</p><p>Now for the actual work. On the VyOS-Edge VM, I entered the configuration mode with the <strong>configure </strong>command and used the <strong>set protocols static route 10.0.10.0/24 next-hop 10.99.99.1 </strong>command to add the route in</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/710/1*Zd4Xhj9AIMOB0BP_KqBR6Q.png" /></figure><p>Afterwards, I used the <strong>commit </strong>and <strong>save </strong>commands to lock the changes in, and used the <strong>show ip route</strong> command on the VyOS-Edge VM to see what the routing table looked like now</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/730/1*Ys6pbs3YClCGKsoAMHCZ3w.png" /></figure><p>Looks good.</p><p><strong>Section VII: Zone-Based Firewall Security Policies</strong></p><p>Because VyOS-Core is directly connected to both the WireGuard tunnel and the LAN trunk, it functions as the immediate gateway between these two worlds. While this direct connection simplifies routing, it also creates a significant security risk: without a policy in place, any traffic exiting the tunnel has an unrestricted ‘all-access pass’ to my private VLANs.</p><p>Since this internal traffic flows directly between the VyOS interfaces, it never touches the pfSense firewall; consequently, pfSense remains ‘blind’ to these movements and cannot enforce any rules. Implementing VyOS Zone-Policy firewalls was therefore a mandatory step to establish a checkpoint at this internal junction, ensuring that my network segmentation remains intact even for traffic already inside the perimeter.</p><p>A Zone-Based Firewall (ZBF) replaces traditional interface-based rules with a more scalable model where security policies are applied to traffic moving <strong>between </strong>defined Zones. By grouping my WireGuard tunnel into a ‘VPN Zone’ and my management network into an ‘Admin Zone,’ I could easily enforce granular security boundaries based on the direction of traffic flow rather than just its entry point.</p><p>Now for the actual steps on the <strong>VyOS-Core </strong>VM. First, I created the zones with the <strong>set firewall zone &lt;ZONE_NAME&gt; member interface &lt;INTERFACE&gt; </strong>command:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/563/1*2z6xDHfr89DPhSsJxsmLbg.png" /></figure><p>Then I needed to create the appropriate Firewall rulesets to strictly permit only established sessions, pings, and SSH management traffic while dropping everything else by default. By enforcing a ‘Least Privilege’ approach, the configuration ensures that even if the VPN is accessed, lateral movement into the sensitive Admin VLAN is restricted to <strong>only </strong>the specific protocols I’ve explicitly authorized.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/740/1*KQgXGFDDMWF-Rym8M5RA1w.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/741/1*gKCeuw0Qpik-nYKPjvrxNA.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/731/1*TiJJMqmIvqelFbm_2Y__IQ.png" /></figure><p>Notice the last line of the first screenshot with the command <strong>set firewall ipv4 name VPN_TO_ADMIN rule 10 state related</strong>. In the context of this Firewall, the <strong>Related </strong>state allows the policy to be “context-aware” by permitting new connections that are technically separate but logically tied to an already authorized session, such as the signaling or error messages required for complex protocols to function correctly. Essentially, this rule ensures that while I am explicitly allowing primary traffic like SSH or ICMP, the firewall is smart enough <strong>to also permit</strong> the ‘helper’ packets — like network error notifications or secondary data channels — that these protocols need to successfully complete their tasks.</p><p>Then I applied the final “glue” to tell VyOS “When traffic moves from the VPN zone to the ADMIN zone, follow the rules I just wrote.”</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/668/1*-IsSOinUZtMGUtVaIXiocw.png" /></figure><p>Then I used the <strong>show firewall ipv4 name VPN_TO_ADMIN </strong>command to see what the final rules looked like</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/504/1*ow4YUMqU_rqvFGq6YkIfIw.png" /></figure><p>And the <strong>show firewall zone</strong> command provided a nice summary of what I did for this section</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/318/1*6MncUOPLa4QwW0di1RpXFw.png" /></figure><p><strong>Section VIII: Deploying Test-Client and Appropriate Network Interfaces</strong></p><p>In this section, I deployed an Ubuntu Desktop VM and performed the appropriate testing. I got the image for Ubuntu 22.04.5 LTS (Jammy Jellyfish) from <a href="https://releases.ubuntu.com/jammy/">https://releases.ubuntu.com/jammy/</a>, and then created a new VM with the 1 vCPU, 2 GB RAM, a 20 GB hard drive, and the network interface card being temporarily NAT [after finalizing setup and configurations, it would be connected to the virtual <em>Homelab13_LAN_TRUNK</em> network]. When going through the initial installer configuration, I also selected the <em>Minimal Installation</em> option so as to minimize the unnecessary bloat installed.</p><p>After creating the Ubuntu Test-Client VM with a NAT network adapter, I encountered connectivity issues where <strong>ping 8.8.8.8</strong> failed and DHCP requests timed out. Diagnosing the problem revealed that while the VM had an IP address, all routes showed linkdown status, indicating VMware’s virtual network infrastructure wasn’t functioning. The root cause was that the <strong>VMware NAT Service</strong> on my Windows host wasn’t running. After manually starting it in the Windows Services Manager Console (also known as <strong>services.msc</strong>), the VM immediately obtained proper network connectivity and could successfully ping external hosts. This experience reinforced that VMware’s virtual networking depends on host-side Windows services, and verifying these services are running should be the first troubleshooting step for VM network issues.</p><p>After this, I got to work preparing the VM for the final tests. I needed the VM to be able to support the 802.1Q for ethernet frames to be tagged with vlan information. I took some of the information from an <a href="https://askubuntu.com/questions/660506/how-to-setup-and-save-vlans-on-ethernet">Ubuntu help page</a> to do this — with the following commands:</p><p><strong>sudo apt-get install vlan</strong> — This command installs the user-space VLAN configuration package on Ubuntu, which provides the necessary utilities and ensures all dependencies are installed for managing VLAN interfaces from the command line.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/882/1*HzsdDwRvhknpHIasNTBJlQ.png" /></figure><p><strong>sudo modprobe 8021q</strong> — This command loads the **<strong>kernel module** </strong>that provides the low-level driver support for processing 802.1Q VLAN-tagged Ethernet frames at the network stack level.</p><p>Because it is a kernel level module (unlike <strong>vlan </strong>being a permanently installed user-space package), I needed to also explicitly add it to the <strong>/etc/modules</strong> file. This would ensure that this setup is permanent by telling the system to load this kernel module each time Ubuntu boots.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/669/1*GkB-qm1XhtC9dbHcXzcRYg.png" /></figure><p><strong>Why Both vlan and 8021q Are Needed:</strong> The 8021q kernel module handles the actual VLAN tagging/untagging at the driver level (how the operating system processes tagged frames), while the <strong>vlan </strong>package provides the user-space tools and ensures proper system integration so you can create and manage VLAN interfaces using commands like <strong>ip link add</strong> with proper VLAN support.</p><p>Up until now, I had my Ubuntu VM being on a single NAT interface. That was great up to this point because I could run the <strong>sudo apt-get install vlan</strong> command and get the appropriate installation packages from the broader Internet and finalize VM setup. Now I needed to move it onto the LAN-Trunk network so that it could become a part of the virtualized network that I built with the pfSense and VyOS VMs. I did this by powering the VM off, changing the network adapter settings from <strong>NAT </strong>to the <strong>Homelab13_LAN_TRUNK</strong>, and powering it back on.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/749/1*TTzRid6xY2I5zKTOlqjw-Q.png" /></figure><p>The following screenshots show the commands I used for the three VLANs:</p><ul><li><strong>VLAN 10 (Admin Network):</strong></li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*hIn9iWDe-0XnLhq167xURw.png" /></figure><ul><li><strong>VLAN 20 (Guest Network):</strong></li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/833/1*fkqz4ZOG6C5WyT1ow-Onrw.png" /></figure><ul><li><strong>VLAN 30 (IoT Network):</strong></li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/833/1*ODZgAQRAkHmWE1Ok0ZUS4w.png" /></figure><p>Notice that I didn’t add default routes for VLAN 20 and 30 yet — only one default route can be active at a time as a routing “catch-all” case. Finally, a quick invocation of the <strong>ip addr show</strong> command confirmed that the VLAN subinterfaces were fully created and running.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1000/1*54bkg0LQc01R-vSq69KSpw.png" /></figure><p><strong>Section IX: Project Conclusion</strong></p><p>On the Ubuntu machine, I had the default route for VLAN 10 to go out to the pfSense interface on 10.0.10.1/24 interface.</p><p>To wrap up the lab, I performed a connectivity test from the Ubuntu Test-Client. I successfully established a ping to the pfSense gateway (10.0.10.1). This successfully demonstrated the previous section’s primary technical objective: the correct implementation of 802.1Q VLAN trunking and sub-interface configuration.</p><p>With all this work done over the course of this homelab exercise and burnout setting in, I chose to conclude this exercise. While there could have been additional troubleshootings that could have been done to verify and refine the connectivity further and steps taken to improve the network architecture (the main one would be to remove the connection from the VyOS-Core to the LAN-Trunk so that the traffic would flow from the Ubuntu VM through <strong>only</strong> through the pfSense firewall to the simulated Internet), I achieved my core goals of learning VyOS, BGP, OSPF, VLANs subinterfaces, and other networking related concepts — and needed to move on to future homelabs.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=d03548afc544" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Homelab #12: CI/CD Pipeline with Jenkins, Terraform, and Multi-Cloud]]></title>
            <link>https://secnate.medium.com/homelab-12-ci-cd-pipeline-with-jenkins-terraform-and-multi-cloud-39746b1b7ba0?source=rss-f95ccfcb96af------2</link>
            <guid isPermaLink="false">https://medium.com/p/39746b1b7ba0</guid>
            <dc:creator><![CDATA[Nathan Pavlovsky]]></dc:creator>
            <pubDate>Mon, 05 Jan 2026 00:53:36 GMT</pubDate>
            <atom:updated>2026-01-05T00:53:36.955Z</atom:updated>
            <content:encoded><![CDATA[<p>Welcome to this round of the homelabs. In this round, I built a complete CI/CD pipeline on my Windows workstation that deploys a Flask app to both AWS and Azure using Jenkins, Docker, and Terraform.</p><p>A CI/CD pipeline — which is in short for Continuous Integration/Continuous Delivery (or Deployment) pipeline — is an automated workflow that streamlines how code changes are built, tested, and released. It starts with Continuous Integration, where developers regularly merge code into a shared repository, triggering automatic builds and tests to catch issues early, followed by Continuous Delivery, which prepares passing code for seamless deployment to staging or production environments, with the option for fully automatic deployments. CI/CD minimizes manual errors, accelerates feedback loops, ensures consistent quality, and enables rapid, reliable iterations — essential for modern cloud-native applications and infrastructure management.</p><p>Enter Jenkins: a powerful, open-source automation server that’s perfect for orchestrating these pipelines, offering extensible plugins to integrate with Git, Docker for image builds, Terraform for IaC (Infrastructure as Code), and cloud providers like AWS or Azure — all self-hostable and highly customizable. Let’s get started!</p><p><strong>Section I: Setup</strong></p><p>The first thing that I did was go onto Oracle’s website, downloaded the appropriate Java installer for the architecture of my Windows homelab workstation, executed it, and got Java installed</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*lEPpCbiFsSsgrMh-U_9-tg.png" /></figure><p>I then went onto the Jenkins website, downloaded the installer, and went through the appropriate steps to get Jenkins installed</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*sY91Wy-jDxHIdVDQPLx-RA.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/484/1*ojeAvQue7KMebUtmzM322A.png" /></figure><p>When asked what the logon type is, I selected the <em>Run service as local or domain user</em> option. This is because (<a href="https://www.jenkins.io/doc/book/installing/windows/">per Jenkins’ documentation</a>), “it is recommended to install and run Jenkins as an independent Windows service using a local or domain user,<strong> as it is much safer than running Jenkins using LocalSystem(Windows equivalent of root)</strong> which will grant Jenkins <strong>full access</strong> to your machine and services.”</p><p>And I selected the default port of 8080.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/487/1*l0y7snAnTOkK4vM9hdFKIA.png" /></figure><p>I then hit a message saying that my originally installed version of Java (25-something) did not match the Jenkins version — since “Only Java 17 and 21 are supported by Jenkins.” I quickly uninstalled the Java package that I had installed using the <strong>Get-Package ‘Java*” | Uninstall-Package </strong>command in PowerShell and then downloaded the installer for version 21 off Oracle’s website. Then I continued with the installation and finished it.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/489/1*eVLwN78pLsT20ozyvoABRw.png" /></figure><p>Jenkins then auto-started, and I opened it up in my browser at the <strong>http://localhost:8080</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1001/1*8wDOLvasmm_8P5v7OcpbtQ.png" /></figure><p>I then opened up the appropriate file, got the administrator password, put it in, and arrived at the <em>Customize Jenkins</em> window</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1008/1*_GINsZ18KCCy3PaCuxLU8g.png" /></figure><p>I then clicked the <em>Install Suggested Plugins</em> button and got everything under way</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/999/1*uAPxtYIBRyrupQw8uMOo1Q.png" /></figure><p>Next up was to create my first admin user</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/932/1*Aj6Msua3oIOLtesWMPk0Fw.png" /></figure><p>I kept the Jenkins URL as <strong>http://localhost:8080</strong> and the setup was complete! I then opened it and logged in to confirm access</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/994/1*3iEdrPVPHHcNdnKxSAabLA.png" /></figure><p>The next thing that I needed to install was <a href="https://github.com/infracost/infracost">Infracost</a>. It is an open-source tool that provides cloud cost estimates for infrastructure-as-code changes, primarily in Terraform, by integrating into CI/CD workflows and pull requests to show the financial impact before deployment. It is important because it shifts FinOps left, enabling engineers and teams to proactively prevent cost overruns, enforce best practices, and optimize resources early in the development process — avoiding expensive surprises and rework after infrastructure is provisioned.</p><p>I manually downloaded the .zip file of the latest release from Infracost’s <a href="https://www.infracost.io/docs/#quick-start">Get Started website</a><strong>, </strong>created a sub-folder called “Infracost” at the <strong>C:\Program Files\</strong> overall folder, and then updated my Windows environment <strong>PATH</strong> variable to include that.</p><p>Opening up a new PowerShell window and entering <strong>infracost</strong> into the command line confirmed that it was installed.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/830/1*dGn5w0H4IiF3GcYRm8qk0Q.png" /></figure><p>Wonderful. I then needed to use the <strong>infracost auth login </strong>command to open up a browser window that would authenticate my CLI for usage.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/961/1*jaItHtf-_jlBek7wOLxfmw.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/999/1*NJzTxfGGMJuMBW6qXiDcrg.png" /></figure><p>Wonderful. This resulted in the API key being appropriately saved to my computer automatically</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/965/1*G3kr5xxVjEXoptYxrhsvhg.png" /></figure><p>I already had Docker Desktop, Terraform, git, AWS CLI, and Azure CLI installed from past homelabs, so we were good on the installations. The last thing that I needed to do as part of this setup process was to create a main project directory, and to create the subfolders.</p><p>The folders made were as follows:</p><ul><li><strong>app </strong>and <strong>app/templates</strong></li><li><strong>keys</strong></li><li><strong>scripts</strong></li><li><strong>terraform</strong>, with the <strong>aws </strong>and <strong>azure </strong>sub-folders</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/873/1*wtPrrJn4eWd-OzFEOssC_A.png" /></figure><p><strong>Section II: Creating a Flask Application</strong></p><p>I got to work by creating a simple Flask application in <strong>app\app.py</strong>:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/860/1*0zVWqlKBTUxkCHfBpxoLPA.png" /></figure><p>Next up was to create a Python virtual environment. It is an isolated directory that contains its own Python interpreter and packages, letting me install libraries specific to just this project without impacting other projects or the overall Python setup on my computer. I needed everything to stay project-specific, in order to avoid version conflicts from my system’s global packages and to keep my setups reproducible and portable.</p><p>To create it, I went into the folder in which I had the files for this project and used the <strong>python -m venv . </strong>command, followed by the <strong>.\Scripts\activate</strong> command to activate the virtual environment</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/869/1*qI7x67TOPvos255XnRkJEg.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/878/1*wtfY9AICAFOFnQACSuWoKA.png" /></figure><p>Then, I checked with the <strong>pip list </strong>command to see what packages the Python package manager currently had installed. As expected for this virtual environment, it was just pip</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*7YeaiyCq13ytONZK6kJlvg.png" /></figure><p>Then I quickly installed flask using the <strong>pip install flask </strong>command, and then afterwards saved the information regarding the required packages using the <strong>pip freeze &gt; requirements.txt </strong>file</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*aTrdsRDVuNPsUcuvBVQG7w.png" /></figure><p>The <strong>requirements.txt</strong> file is a Python convention that lists all the Python packages and their specific versions needed to run your application, allowing anyone (or any automated system like Jenkins) to recreate the exact same environment with a single pip install -r requirements.txt command. Looking at it, we see the following, which is <strong>**exactly** </strong>what was expected for this virtual environment:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/239/1*qk8XH6WGxMk0cbHEhWk3Ew.png" /></figure><p>Next up was to create the <strong>index.html </strong>file (in this project’s <strong>app\templates</strong> sub-folder) as the to-be-presented by the Flask app HTML code. I also installed the <a href="https://marketplace.visualstudio.com/items?itemName=ms-vscode.live-server">Live Preview</a> extension for my Visual Code Studio code editor that allowed me to take a look at how the final HTML code I was making would look like:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*4JWDrX2xnqnH8qqRF0YkPw.png" /></figure><p>Next up was to make the Dockerfile. It looked like this:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/632/1*2Uh_C00Q6WWJYF7fknuDUA.png" /></figure><p>I need to now explain two points about what this Dockerfile is doing. The first is regarding the usage of the <strong>–no-cache-dir</strong> flag for the <strong>RUN pip install </strong>command. The Python package manager (pip) downloads packages to speed up future installations on your local machine, but in Docker containers this cache is useless (since each build starts fresh) and just bloats the image size. Accordingly, it is best practice to have <strong>–no-cache-dir </strong>in a Dockerfile, to prevent unnecessarily cached data from bloating the image from megabytes to potentially <em>hundreds</em> of megabytes larger.</p><p>The second is the initiation of the <strong>FLASK_APP </strong>environmental variable inside the container. It tells Flask which Python file contains the application and needs to be executed (<strong>app.py</strong>). In this case the <strong>FLASK_APP </strong>environment variable is technically optional since <strong>python app.py</strong> is an explicit command in the Dockerfile’s CMD instruction, instead of using <strong>flask run </strong>— which does rely on it.</p><p>The last step was to create the <strong>.dockerignore</strong> file, to tell Docker which files and folders to ignore when building the container image. A well-crafted <strong>.dockerignore </strong>file is important to keep the container image smaller, help it build faster, and avoid accidentally including sensitive data or files that do not belong in production.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/285/1*UXzE5ne8ff_xIGuH6hj9Cw.png" /></figure><p>And while I was at it, I made the <strong>.gitignore</strong> file too</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/575/1*WE_sArr4MkABczrEBy3nEA.png" /></figure><p><strong>Section III: AWS Infrastructure With Terraform</strong></p><p>Next up was preparing the AWS infrastructure. The goal of this effort is to create an EC2 instance using Terraform that Jenkins can later connect to and manage. Jenkins acts as an automation orchestrator — it will SSH into this VM to deploy, update, and manage the Dockerized Flask application throughout the CI/CD pipeline.</p><p>First up was to generate an appropriate SSH key pair for AWS on my local machine. I used the <strong>chdir keys </strong>command to go into the project repository’s <strong>keys </strong>directory and then got to work on generating the appropriate SSH key pair. This was done using the <strong>ssh-keygen -t rsa -b 4096 -f “aws-key” </strong>command</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*W4S2aAFVi_BB5XRrw9VPAg.png" /></figure><p>This generated both the public and private key pairs that I wanted to use. Then I needed to get the public key fed into the AWS console. To do this, I logged into the AWS Management Console, then went into the <em>EC2 &gt;&gt; Key Pairs</em> page</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*oLjv0wIyQxo-4CbR7fBlsQ.png" /></figure><p>Then after that, I clicked the <em>Actions &gt;&gt; Import Key Pair</em> button and using the <em>Browse</em> button selected the generated <strong>aws-key.pub </strong>file.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*-tnGlApMcXd0IxDrVLpTnA.png" /></figure><p>And then imported the key successfully.</p><p>Next up was to create the Terraform for the EC2 instance. Remember how in a <a href="https://secnate.medium.com/homelab-7-terraform-for-infrastructure-as-code-aws-azure-639d535d0b50">previous homelab</a>, I typed every single line of Terraform manually for my first AWS EC2 instance and took hours? Now I got the opportunity to atone for this mistake!</p><p>I went online and found <a href="https://dev.to/sre_panchanan/terraform-in-actiondeploying-an-ubuntu-ec2-instance-with-nginx-on-aws-2fdp">a tutorial for creating an EC2 instance</a> whose code I could copy, paste and tweak. I heavily based my approach off of it and tweaked the code appropriately. The biggest tweak was finding a Red Hat Enterprise Linux 10 (HVM) AMI in a <strong>t3.micro</strong> tier that I wanted to use.</p><p>For those who want to see what I wrote, the files are located in the appropriate folder in my <a href="https://github.com/secnate/HomelabSeriesGithubRepo">Homelab Series GitHub repository</a>. Don’t forget that there is an AWS-specific sub-folder underneath the folder for this current project.</p><p>It was then time to test the Terraform code appropriately. I did this by entering the directory for the AWS component of this project, and then got started with the <strong>terraform init</strong> command</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*qVd9YUgiROaO9VteTZYvdw.png" /></figure><p>Then I used the <strong>terraform validate </strong>command to see that the configuration works and is valid.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*w1QDoc_tTmMsA6WN5IxfCA.png" /></figure><p>It was. Then I used the <strong>terraform plan</strong> command to see what will be created</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*RwGwYCF8z8PnwB4v_Yn4yg.png" /></figure><p>Cool. This Terraform plan looked suitable for making what we needed it to make. Notice that at this point in time, I did not take any further steps. Jenkins will perform the <strong>terraform apply</strong> command appropriately to provision the instance when time comes.</p><p><strong>Section IV: Azure Infrastructure With Terraform</strong></p><p>We did this with AWS, the next step was to do the same with Azure!</p><p>I started by using the <strong>ssh-keygen -t rsa -b 4069 -f ..\..\keys\azure-key</strong> command to generate an SSH key pair for Azure</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*FDw0D8Z99jZdcNEau-Kceg.png" /></figure><p>Then I used the <strong>az login </strong>command in my console to quickly log in — and it also got me my subscription ID</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*NR10bffmD0bq4wf1exE3ag.png" /></figure><p>The next step was to create a service principal in Azure — essentially a dedicated, non-human identity (like a service account) to allow Jenkins to securely authenticate and interact with Azure resources without using my personal user credentials. Since Jenkins runs outside of Azure (like on my homelab workstation), it couldn’t use a managed identity. Creating a service principal provided a secure way for automated pipelines to log in via the Azure CLI (using the client ID, secret, and tenant ID), perform actions like deploying resources, and follow least-privilege principles, while avoiding risks like credential expiration or sharing personal accounts.</p><p>I did this with the following command: <strong>az ad sp create-for-rbac — name “jenkins-sp” — role contributor — scopes /subscriptions/&lt;REDACTED&gt;</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*NarqB-ZAHvbGo84jNewc_Q.png" /></figure><p>I then saved the presented output for later.</p><p>Next up was to create the Terraform for the VM that was to be spun up in Azure. I took a page out of the <a href="https://secnate.medium.com/homelab-7-terraform-for-infrastructure-as-code-aws-azure-639d535d0b50">Homelab #7 writeup</a> and reused the same <a href="https://learn.microsoft.com/en-us/azure/virtual-machines/linux/quick-create-terraform?tabs=azure-cli">tutorial</a> as a starting point from which to work with. Notice that the tutorial’s code was only a starting point. I had to tweak some things, and refactor some of the lines of code. For example, I did not want the name of a resource group to be generated using the <a href="https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/pet.html">random_pet</a> resource, and I moved some “magic string” configuration variables out into the <strong>variables.tf </strong>file. I also wanted to use the SSH key that I previously made by hand with the <strong>ssh-keygen</strong> function and appropriately removed the Microsoft-tutorial provided lines that randomly generated it on my behalf.</p><p>The next step that I had to take was to set up the credentials for authenticating. Per the <a href="https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/guides/service_principal_client_secret">appropriate documentation</a>, I needed to set up the credentials as environment variables, so I did exactly that. This is where the saved information from my creation of the service principal name came in!</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*4zoJa_l2hevllF0IvdqF2w.png" /></figure><p>With all these preparations done, the next step was to test the Terraform directly. This step is exactly just like the previously-done testing for AWS. First up was <strong>terraform init</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*H6xjx2pjLcDvbDG974ftUA.png" /></figure><p>Then I used the <strong>terraform validate </strong>command to see that the configuration works and is valid.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ljLt0403lwC8NlUsp1d6Iw.png" /></figure><p>OK. The configuration is valid. The last command was <strong>terraform plan</strong> — and I got errors</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*E7EGHF_XbxRr_FsIMrg55Q.png" /></figure><p>There were two minor issues that had to be fixed. I made a mistake and wrote in the <strong>variables.tf</strong> file the file containing the path to the public key for working with Azure as being <strong>azure_key.pub</strong> instead of <strong>azure-key.pub </strong>(using an underscore instead of a dash), and in the <strong>outputs.tf </strong>file, I needed to add in <strong>.ip_address</strong> in two lines referencing the <strong>azurerm_public_ip.my_terraform_public_ip</strong> so I could get its IP address value, out of the ~21 available object properties. Minor fixes.</p><p>Rerunning <strong>terraform plan </strong>resulted in no further errors</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ZFImmrdBJ6AwmS92UdTodQ.png" /></figure><p>We were finished with Azure, for now.</p><p><strong>Section IV: Configuring Jenkins</strong></p><p>Next up was to get Jenkins configured. I opened it up at <strong>http://localhost:8080</strong>, and clicked the gear-looking <em>Manage Jenkins</em> button in the top right-hand corner, and then selected the plugins menu option.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Gdz6rv0Yj3_szZYad1vOEw.png" /></figure><p>I then clicked the <em>Available Plugins</em> and <em>Installed Plugins</em> settings and ensured that I got all of the following plugins fully installed:</p><ul><li>Docker Pipeline</li><li>Docker plugin</li><li>Terraform</li><li>Git plugin (should already be installed)</li><li>Pipeline</li><li>SSH Agent</li><li>Credentials Binding</li><li>PowerShell plugin</li></ul><p>These plugins provide the essential capabilities for the CI/CD pipeline: <strong>Docker Pipeline</strong> and <strong>Docker plugin</strong> enable building and pushing container images, <strong>Terraform plugin</strong> allows infrastructure provisioning, <strong>Git plugin</strong> pulls code from GitHub, <strong>Pipeline plugin</strong> enables the Jenkinsfile scripted workflow, <strong>SSH Agent</strong> facilitates secure connections to cloud VMs for deployment, <strong>Credentials Binding</strong> securely injects stored secrets (AWS keys, Azure credentials, Docker Hub passwords) into the pipeline without exposing them, and <strong>PowerShell plugin</strong> allows Jenkins to execute Windows-native commands on the workstation.</p><p>I then restarted Jenkins appropriately to have the final changes locked in.</p><p>After the post-restart reboot, the next step was to configure credentials. I clicked the <em>Manage Jenkins &gt;&gt; Credentials</em> button and arrived at this page.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*4so4EfDAWa9rH9JO2jXm3g.png" /></figure><p>I then clicked the <strong>(global)</strong> button and arrived at this page</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*rJNIdVydodcVz1jT_2hgtw.png" /></figure><p>Wonderful. It was now time to go on a credential-addition spree. To not bore you with the details, I listed the accounts whose credentials I added below:</p><ul><li><strong>Docker Hub: </strong>The username and password for my Docker Hub account</li><li><strong>SSH Keys: </strong>I added the the private key for SSHing into the AWS EC2 instance (with the username being <strong>ec2-user</strong>) and into the Azure Virtual Machine (with the username being <strong>azureadmin</strong>)</li><li><strong>AWS Credentials: </strong>I used the <strong>notepad $env:USERPROFILE\.aws\credentials</strong> command in my PowerShell window to quickly open up the file where I had the API keys that Terraform needs to authenticate and provision infrastructure in my AWS account. Notice that these are <em>different</em> from the SSH key used to authenticate and connect to EC2 instances after they are created</li><li><strong>Azure Credentials: </strong>These include the IDs of the specific subscription and tenant that I am working in and the service principal’s username (a.k.a. as the <strong>appId</strong>) and password</li><li><strong>GitHub Credentials: </strong>This was a username + password pair, where I used my GitHub username and generated a token on my GitHub account<strong> </strong>as a password</li></ul><p>The final state of the fed-in-credentials looked like the following:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*pdooN-J7fpv8bFP0HWw9CQ.png" /></figure><p>Great. Next up was to start configuring Jenkins’ integrations with external tools.</p><p>First up was to configure Git, which is used for version control. I went to the <em>Manage Jenkins &gt;&gt; Tools </em>panels and then scrolled down to the <strong>Git Installations </strong>section and set the path to the Git executable as being <strong>C:\Program Files\Git\bin\git.exe</strong>. I kept the original value of the <em>Name </em>field (literally <strong>Default</strong>) as is — and then clicked the <em>Save </em>button</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*6Zbv4wtgpJ2lEPei8fWnnw.png" /></figure><p>Next up was Docker. I configured Jenkins to use my existing Docker Desktop installation by leaving the installation root empty and unchecking “Install automatically,” which tells Jenkins to simply use the <strong>docker </strong>command already available in the Windows PATH that Docker Desktop configured during its installation. This approach avoids redundant Docker installations and allows Jenkins to seamlessly execute Docker commands — building and pushing images — through the Docker engine already running on my workstation.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*K2BJa3RKUKQ1b2xYa-i4kQ.png" /></figure><p>I then also clicked the Save button to get the changes applied.</p><p><strong>Section V: Preparing The GitHub Repository</strong></p><p>In this section, I quickly finalized my configurations for the GitHub repository for this homelab. Remember that I have been working with <a href="https://github.com/secnate/HomelabSeriesGithubRepo">an overall GitHub repository</a> for <strong>all </strong>the homelabs of this series, with specific folders dedicated to each specific one. <br>I went into my file explorer window on my homelab workstation, and reviewed the contents to confirm that I was happy with the project structure — specifically the sub-folders and files stored in it.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/892/1*kC8gx4NqDbSyPq0vNJGmhw.png" /></figure><p>It looked good to me. I also verified that the <strong>.gitignore</strong> file excluded everything that I <strong>did not</strong> <strong>want </strong>there — especially the keys for authenticating to the AWS and Azure-hosted EC2 and VM instances.</p><p>I then used the <strong>git status</strong>, <strong>git add . </strong>, and <strong>git commit</strong> commands to push the sub-folder that I created for this homelab, along with all the relevant files into the repository.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*k1FuKj8-Z6KF2qmY_S2ygA.png" /></figure><p>I then took a look at all the files that got added to my GitHub repository, and confirmed that everything that I wanted to be there was there — as expected.</p><p><strong>Section VI: Create Jenkins Pipeline</strong></p><p>The next step was to build the Jenkins pipeline. A Jenkins pipeline is essentially a scripted sequence of automated steps that guide your code from commit to deployment — like building, testing, and releasing software — all defined in a file called a <strong>Jenkinsfile</strong>. For those wanting to learn more, the Jenkins project has <a href="https://www.jenkins.io/doc/book/pipeline/jenkinsfile/">good documentation</a> on its website.</p><p>Think of a pipeline as an assembly line for software projects, where developed and committed updates get automatically tested and deployed into the appropriate environments!</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/878/1*tumlKjxovwsO2bOD21ofhQ.png" /></figure><p>The Jenkinsfile itself follows the following general format (this screenshot is lifted from the Jenkins documentation on its <a href="https://www.jenkins.io/doc/book/pipeline/">website</a>)</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/992/1*8acYBQms5qoiH4i7HoZuNQ.png" /></figure><p>I then got to work with creating separate Jenkinsfiles (Jenkinsfile.aws and Jenkinsfile.azure). The reason why I created two separate ones rather than a single combined pipeline was to maintain independent deployment control over each cloud provider. Such allowed me to deploy to AWS without affecting Azure (and vice versa), enabled parallel pipeline execution for faster deployments, allowed for simplified debugging when issues are cloud-specific, and demonstrated a professional multi-cloud architecture pattern. Each Jenkinsfile was to handle its cloud provider’s specific authentication requirements and deployment steps, making the code more maintainable. Such an approach also had the added benefit of intending to make the Jenkins dashboard more informative — I could see at a glance which cloud deployments succeeded or failed and their individual execution times.</p><p>First up was to create the <strong>Jenkinsfile.aws</strong> file. I typed everything out by hand, and you can access it in the following file, along with the copious comments that I have made</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Ooi21fc9dOL44miaiJfk8w.png" /></figure><p>You can open it and read the comments to learn more about how things were done.</p><p>Then I created another Jenksinfile, this time titled <strong>Jenkinsfile.azure</strong> for the planned usage of the Azure cloud provider. It was heavily based on the previous <strong>Jenkinsfile.aws </strong>file, but appropriate tweaks were made. You can see its text here:</p><p><a href="https://github.com/secnate/HomelabSeriesGithubRepo/tree/main/Homelab12%20-%20CI%20CD%20Pipeline">HomelabSeriesGithubRepo/Homelab12 - CI CD Pipeline at main · secnate/HomelabSeriesGithubRepo</a></p><p>I then quickly committed and pushed the two Jenkins files into the homelab GitHub repository</p><p><strong>Section V: Create AWS Jenkins Pipeline &amp; Job</strong></p><p>After creating the Jenkinsfiles that define the pipeline logic, I needed to create a Jenkins job, which is Jenkins’ way of executing those pipelines. A Jenkins job acts as a configured instance that tells Jenkins where to find the code (GitHub repository), which branch to monitor, when to trigger builds (on code changes), and which Jenkinsfile to execute, essentially bridging the gap between the automation instructions I wrote and Jenkins’ ability to run them.</p><p>First up was to create the AWS pipeline. I did this by opening up Jenkins at the <strong>http://localhost:8080 </strong>URL, then clicked the <em>+ New Item</em> button and proceeded with configuring the pipeline, and then clicking the <em>Save </em>button appropriately. All the configuration details will be available in the screenshots shown below</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*TgyS-Kz101RCwlgZEKK_1g.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*uYyJat6fK6Hajg0AepvuvQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*fHqgxlT4SQl4jDL9UDothg.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/996/1*7K071LfUAg2MIXUhT6L8Kg.png" /></figure><p>Notice the configuration of the schedule to <strong>Poll SCM</strong>. The schedule<strong> H/5 * * * *</strong> tells Jenkins to check GitHub for changes every 5 minutes, with the<strong> H</strong> (hash) symbol being a special Jenkins feature that distributes the polling time across the 5-minute window to prevent all jobs from checking simultaneously. For example, instead of every job polling at exactly :00, :05, :10, etc., Jenkins uses a hash of the job name to spread checks across the interval (like :02, :07, :12 for one job and :03, :08, :13 for another), which prevents server overload when you have many jobs and ensures more efficient resource usage.</p><p>Next was to test it. I went to the Jenkins dashboard</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*suB6fRi89JRjsS0VX9shGw.png" /></figure><p>And clicked on the <strong>Build Now</strong> green play button. The build failed, so I went into the <em>Build History </em>information panel page</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*J8m9XkbyenMqmpG_ObCxwQ.png" /></figure><p>And then clicked the Terminal-looking icon on the right side of the page to the console output and error messages:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*vwtc0G82N2vsRCv7NiWfYQ.png" /></figure><p>And started to work on fixing the Jenkinsfile and the issues raised so that the pipeline could execute smoothly &amp; without a hitch. This was a beast to get working — with multiple errors, starting with the usage of a misplaced <strong>} </strong>curly bracket. Other issues that I fixed included: removing an <strong>-e </strong>flag that I unnecessarily included in the <strong>echo</strong> statements of the Jenkinsfile’s last section, replacing the <strong>sh</strong> commands with the <strong>bat</strong> commands since my homelab machine is a Windows one, replacing the <strong>${ &lt;VARIABLE&gt; } </strong>syntax for environmental variable interpolation with the Windows <strong>%VAR%</strong> syntax, needing to regenerate the AWS RSA keys to fix a mismatch error, and a race condition going on in the created EC2 instance involving docker getting spun up and created.</p><p>There was also an issue regarding the Docker, Terraform, and Infracost commands being under my local user account and not being seen by Jenkins, which ran under the Windows SYSTEM account. For those who don’t know, the Windows SYSTEM account is a powerful “super” account that does not have visibility into user instances by default. I also on my Windows workstation had the SSH, SCP, and other related utilities in the <strong>C:\Windows\System32\OpenSSH</strong> folder, which were not seen by the executing pipeline, nor in my CMD and PowerShell Windows when debugging.</p><p>To remediate this, I quickly updated the <strong>Path</strong> variable on my Windows computer too to have those seen for my own personal needs for the SSH and SCP-related utilities, and then ensured my Jenkinsfile also had them referenced</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*DUcOAnvoY1MoBGZDktc0nA.png" /></figure><p>These workarounds were suitable for my Homelab needs as they derived from the fact that I needed to get Jenkins running on my Windows machine. It is my understanding that Jenkins is typically run and used on Linux, and there are <strong>many </strong>pain points that people in the industry typically experience when trying to get it to run <strong>on Windows</strong> (per a conversation I had with some people on a tech Slack chat). Due to time constraints, and the need to continue the momentum on the homelab series, I did not dig around into the nuances regarding getting a Jenkins pipeline deployed on a Linux machine or another alternative option.</p><p>The hardest part wasn’t provisioning cloud infrastructure — it was convincing Windows OpenSSH that Jenkins was allowed to read its SSH keys. On Windows, OpenSSH will silently reject private keys unless both the key file and its parent directory have tightly restricted ACLs. Because Jenkins typically runs as the SYSTEM account on Windows, the SSH private key must be readable by SYSTEM (and only SYSTEM), or OpenSSH will refuse to load it before any network authentication occurs. I did this using the following lines in the <strong>stage(‘Deploy Application to EC2’)</strong> stage of the Jenkinsfile:</p><blockquote>bat ”””</blockquote><blockquote>icacls “%SSH_KEY_FILE%” /reset</blockquote><blockquote>icacls “%SSH_KEY_FILE%” /grant:r “NT AUTHORITY\\SYSTEM:(F)”</blockquote><blockquote>icacls “%SSH_KEY_FILE%” /inheritance:r</blockquote><blockquote>”””</blockquote><p>As a final note: all the testing for getting the pipelines in the AWS Jenkinsfile working was done on a stage-by-stage basis, starting by commenting out all the stages but the first one, and then adding each successive stage as its predecessor was tested, clicking the green play button to launch a build, and then looking at the console output to confirm it was working.</p><p>If I could redo this lab all over again, I’d have installed and run Jenkins on a Linux machine, not my Windows homelab workstation. Lesson painfully learned.</p><p>After pulling teeth for quite a few evenings, I finally got the Jenkinsfile working, clicked the <strong>build </strong>button</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*wJPUJVOt7_Q1RgQPCy0X3Q.png" /></figure><p>And opened up the console output to keep track of what I was executing in Jenkins</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*GbIspa5Rh-jPGQ8dMLjcHA.png" /></figure><p>Took a look at the <strong>**very** </strong>interesting Infracost estimate</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/795/1*29pfTRcq9um2rQuZHiYb7Q.png" /></figure><p>And scrolled down to the bottom to confirm successful pipeline completion</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*XErXxm68mH70VhipDPtR5g.png" /></figure><p>Looked nice. I then opened up the <a href="http://3.21.44.173:5000/"><strong>http://3.21.44.173:5000/</strong></a> path in the browser, and look at what I got!</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/738/1*9Ejz53AAfjQERnWi9GbG2A.png" /></figure><p>Whew. Again, learn from my pain, do not run Jenkins on Windows. I could have pulled this off in half the time if it was running on a Linux system or container — and this is industry best practice too!</p><p><strong>Section VI: Create Azure Jenkins Pipeline &amp; Job</strong></p><p>Next up was to get another Jenkins pipeline and job working for Azure. As a first step to try and save me a lot of the sweat and tears in the AWS pipeline-related work, I opened up the <strong>jenkinsfile.aws</strong> and<strong> jenkinsfile.azure </strong>files and copied and pasted the updates/fixes for the previous issues.</p><p>Then I got to work with making my new pipeline. First I clicked the <strong>+ New Item</strong> button in the Jenkins page</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*VJbeenQ-mqIFh2PO8VqvAg.png" /></figure><p>And went through the following configuration steps. Notice that this pipeline is similar to the AWS one, so I selected for the first step the <em>Copy From Multi-Cloud-AWS-Deploy</em> option</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*1BnN_n1HJIPkbLY1xxPSUQ.png" /></figure><p>This conveniently presented the configuration window, with all the settings from the <em>Multi-Cloud-AWS-Deploy</em> pipeline conveniently there. I then tweaked them to the following values, and saved + applied the changes to get the Azure pipeline underway</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ESiJ9PPqajy50d-RrQv6tg.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*aK2S0iMiFB96m3U6Quz2pQ.png" /></figure><p>And then I also did repeated rounds of testing to get this over the finish line. Notice that I temporarily disabled the <strong>Multi-Cloud-AWS-Deploy </strong>pipeline so that I could focus only on the Azure pipeline without needing to deal with the automatic AWS-based deployment of EC2 and VPC instance. Just like in the previous <a href="https://secnate.medium.com/homelab-7-terraform-for-infrastructure-as-code-aws-azure-639d535d0b50">Terraform homelab</a>, changed the region from a restricted location that was blocked to <strong>eastus</strong>, upgraded the Public IP from Basic SKU (which was retired September 30, 2025 per Microsoft’s official announcement) to a Standard SKU with Static allocation. Some other adjustments in the Terraform, some additional testing, and that did the trick.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/618/1*Et5djhCnwH5K-ETh6vA8qA.png" /></figure><p>Finally!</p><p><strong>Section VII: Testing Automated Builds (Git Push Trigger)</strong></p><p>Next up was to test automated builds. This is because the CI/CD pipelines that I created were supposed to catch any changes with the GitHub webhook trigger within a five minute period, and trigger both the AWS and Azure pipelines without manual intervention.</p><p>I did this by making a small change in the <strong>app.py</strong> file by changing the version number from <strong>1.0 </strong>to <strong>2.0</strong>. This change in version number would also be presented in the presented HTML outputs in the Flask applications for both of them.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/834/1*sWpzhh1ZrjQU5pOk3XE8KA.png" /></figure><p>Then I committed the changes and pushed it out to my GitHub repository, and waited for five minutes to see what would transpire.</p><p>Sure enough, the two pipelines got triggered, and the builds got underway</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ALaiXSM1-p4YCvuZ8FXYLQ.png" /></figure><p>And then after that, I took a look at the pipeline-made resources, and voila, just as expected — the <strong>Version 2.0</strong> appeared!</p><p>For AWS:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/514/1*1-xUoo74E2benbc4oJrx4Q.png" /></figure><p>For Azure:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/488/1*CWksmx4VJlbEud_g-_RQOw.png" /></figure><p><strong>Section VIII: Cost Management and Monitoring</strong></p><p>As we all know, cloud spend isn’t free. Let’s take a look at the costs for the two cloud providers.</p><p>First, in AWS, I opened the console data of the last Jenkins pipeline, and took a look at the cost estimate generated by <strong>Infracost</strong>.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*6rglvWPKYK5rftvrSlAShQ.png" /></figure><p>So here we saw that this project used 7 cloud resources, one was estimated to cost money, and 6 were free. The total monthly cost was about $8 (or $8.39). Not too shabby for a homelab exercise.</p><p>Then I logged into the AWS <em>Billing and Cost Management &gt;&gt; Cost Explorer</em>, set the date range to the time period I was working on this homelab exercise, and took a look at how much I generated in total costs.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*pM44pR8iCXPnwPS4jRZgDQ.png" /></figure><p>About $0.20. Not bad. My free credits that AWS gave to students handily covered all that — and some.</p><p>Next up was to check on the Azure-related costs. I also opened up the console data of the last executed Jenkins pipeline, and took a look at the <strong>Infracost</strong>-provided data estimate:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*v6SxoOb7GbPgN_Nj_ml6rA.png" /></figure><p>Well well well. Infracost indicated that to deploy the same Flask application into Azure, it takes 9 cloud resources [instead of AWS’ 7], and that 6 were free and 3 involved expenses. The total estimated monthly cost was about $16.04. If this was a real Flask application and I was looking to optimize cloud spend, I would naturally lean towards Azure for an approximately ~$8 worth of total monthly savings.</p><p>Now to check on the total cloud spend in Azure. I went into the Cost Management portal in Azure, and set the date ranges for the time period that I worked on this homelab</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*xdC4Kq8e2EvBgiWL7E_04w.png" /></figure><p>Five cents in total. Pretty cheap. My Azure credits handily covered that as well.</p><p>The last thing that I did for this section was to use the <a href="https://github.com/mivano/azure-cost-cli"><strong>azure-cost-cli</strong></a><strong> </strong>tool, which I found online. The Azure Cost CLI is a cross-platform command-line tool that queries Azure’s Cost Management API to display cost breakdowns, budgets, and spending forecasts directly in the terminal, providing quick visibility into Azure resource expenses without needing to navigate the Azure Portal. I included it in the homelab as a bonus to demonstrate cost-awareness practices in DevOps — enabling developers and operators to monitor infrastructure spending as part of their daily workflow and catch budget overruns early. Cloud spend is a very finicky thing and can accumulate quickly if left unchecked.</p><p>I had dotnet installed on my homelab workstation, so I used the <strong>dotnet tool install –global azure-cost-cli</strong> command to install the tool</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/988/1*NmBzsQzWDA7uhCh4Mu12gA.png" /></figure><p>And then I used the <strong>azure-cost -s “&lt;REDACTED_SUBSCRIPTION_ID&gt;”</strong> to take a look at the total costs involved. As expected, it shows about five cents in total.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*7w_GPNSJRY10IZu3gINedQ.png" /></figure><p>Then I used the <strong>azure-cost costByResource -s “&lt;REDACTED_SUBSCRIPTION_ID&gt;” </strong>command to see a breakdown by resources</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*AaaWXU4nhL_qQfaoVf2Aww.png" /></figure><p>So I spent about 5 cents in total on the Azure component of this homelab, with four cents going to the <strong>Standard IPv4 Static Public IP</strong> and 1 cent going to a storage disk for the VM instance’s configuration and data (i.e. acting like its physical hard drive).</p><p><strong>Section VIII: Cleaning Up Resources</strong></p><p>To avoid unnecessary cloud costs, I made sure to terminate all provisioned resources once the homelab experiments were complete.</p><p>The infrastructure was originally deployed through the Jenkins pipeline, which managed Terraform initialization and credential handling. Attempting to run the <strong>terraform destroy</strong> command locally in the configuration directories on my homelab workstation didn’t work, as it couldn’t properly access the remote state or the same execution context used by Jenkins.</p><p>Rather than spending additional time modifying the Jenkinsfile to add a dedicated destroy stage (which would have been ideal for full automation), I opted for a pragmatic approach: I manually terminated the resources via the AWS and Azure consoles.</p><p>This involved:</p><ul><li>Identifying and deleting the resource groups in Azure</li><li>Terminating EC2 instances, deleting VPCs, and other resources in AWS</li></ul><p>All high-cost resources (e.g., running VMs, NAT gateways, managed disks) were shut down and removed first, followed by the remaining supporting infrastructure. This ensured no ongoing spend while wrapping up the project quickly.</p><p>While not as elegant as a fully automated terraform destroy through the pipeline, this manual cleanup was sufficient for a short-lived homelab and allowed me to responsibly decommission everything without leaving stray costs behind.</p><p><strong>Lesson Learned</strong>: For future homelabs or similar work, I’ll need to add a destroy parameter to the Jenkins pipeline from the start to make teardown as seamless as creation, and have all pipelines take it into account. Such a parameter would look something like this in the Jenkins configuration console:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*-XOP4L2JWiO1Q9EIGhy5Bg.png" /></figure><p>For me this was quite the journey. I certainly learned a lot, and I hope you did too. See you in the next round!</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=39746b1b7ba0" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Homelab #11: Observability with Prometheus and Grafana]]></title>
            <link>https://secnate.medium.com/week-11-observability-with-prometheus-and-grafana-0aca9eeeb654?source=rss-f95ccfcb96af------2</link>
            <guid isPermaLink="false">https://medium.com/p/0aca9eeeb654</guid>
            <dc:creator><![CDATA[Nathan Pavlovsky]]></dc:creator>
            <pubDate>Thu, 25 Dec 2025 01:54:45 GMT</pubDate>
            <atom:updated>2025-12-25T02:18:31.371Z</atom:updated>
            <content:encoded><![CDATA[<p>Welcome to this round of the homelab series. This time, I went in to learn observability, specifically with Prometheus and Grafana.</p><p>Observability is the ability to understand your system’s internal states from its external outputs — primarily metrics, logs, and traces — allowing you to detect, diagnose, and fix issues without guesswork.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/595/1*iSD0KQ6KaEOfyBp53yy1iA.png" /></figure><p>In modern IT infrastructure, it’s essential for maintaining reliable services: spotting high resource usage before outages, identifying bottlenecks or leaks, and troubleshooting performance issues quickly — shifting from reactive firefighting to proactive management and ensuring scalability, availability, and efficiency.</p><p><strong>Prometheus</strong> excels at metrics collection with a pull-based architecture — the server periodically scrapes time-series data from instrumented targets (via exporters or client libraries), stores it efficiently in a local TSDB, and supports powerful querying with PromQL, alerting via Alertmanager, and service discovery.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/574/1*XJFB5TrOTVtLzmIg6Xbk7Q.png" /></figure><p>Pair it with <strong>Grafana</strong> for stunning, interactive dashboards that visualize Prometheus data through graphs, alerts, and templates that make complex metrics intuitive at a glance — and you can see how easy keeping track of what is going on in your infrastructure can become.</p><p>Let’s get started.</p><p><strong>Section I: Setup</strong></p><p>I needed to start by preparing for the actual work. From the past <a href="https://secnate.medium.com/homelab-9-containerization-with-docker-5b9777ebba2b">Docker container homelab</a>, I already had Docker installed on my computer. I quickly opened PowerShell and verified that Docker is running with the <strong>docker –version </strong>and <strong>docker ps</strong> command</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/731/1*uy8IDVBI8fAWAERjp0fs6g.png" /></figure><p>I then made a folder on my Windows homelab computer’s desktop for this homelab and made sub-folders titled <strong>prometheus</strong> and <strong>grafana</strong>. These were to be used later.</p><p>Great. Next up is to encode the configuration of the Prometheus server in — you guessed it — another YAML file! I wrote up the following YAML, heavily based on the example <a href="https://github.com/prometheus/prometheus/blob/main/documentation/examples/prometheus.yml"><strong>prometheus.yml</strong></a> file on the official Prometheus GitHub repository:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/881/1*Wx4UJ-QxOP8N87xHNHVaHA.png" /></figure><p>The <strong>scrape_configs</strong> section in Prometheus’s configuration file defined what systems to monitor and where to find them. I configured Prometheus as a data collector that regularly visited different addresses to gather metrics — a process called “scraping.” Here, I configured four distinct targets, each running on its own port:</p><ol><li><strong>Prometheus itself on localhost:9090</strong> — I had Prometheus monitor its own performance and health through meta-metrics, allowing me to track how well the monitoring system itself was performing.</li><li><strong>Windows Exporter on host.docker.internal:9182</strong> — Windows Exporter is a specialized Windows service that translates Windows performance counters (CPU usage, memory stats, disk I/O, running services) into Prometheus-compatible metrics, essentially acting as a bridge between Windows’s native monitoring capabilities and Prometheus’s data format. I used the special Docker hostname <strong>host.docker.internal</strong> because Prometheus ran inside a container and needed to reach out to the Windows host machine where this service was installed.</li><li><strong>Node Exporter on an Ubuntu VM </strong>— Node Exporter is the Linux equivalent of Windows Exporter. This service exposes hardware and kernel-level metrics like CPU load, memory pressure, filesystem usage, and network statistics by reading from <strong>/proc </strong>and <strong>/sys</strong> filesystems and presenting them in a format Prometheus could understand.</li><li><strong>cAdvisor on cadvisor:8080</strong> — cAdvisor (Container Advisor), a Google-developed tool, provides resource isolation metrics, historical usage data, and network statistics for a container. This gave me visibility into how the Prometheus, Grafana, and cAdvisor containers themselves were performing.</li></ol><figure><img alt="" src="https://cdn-images-1.medium.com/max/688/1*1qDIhLeEtakQBcUx4G5Ofg.png" /></figure><p>Each of these exporters — Windows Exporter, Node Exporter, and cAdvisor — acted as a translator, converting system-specific metrics into the standardized Prometheus format. Every 15 seconds (my configured scrape interval), Prometheus visited each of these endpoints, pulled the latest metrics, and stored them in its time-series database, ready for visualization in Grafana or evaluation against alert rules.</p><p>Cool. Next up was to make preparations for the alerting rules in Prometheus.</p><p>A quick intro to how alerting works in Prometheus: It transforms passive monitoring into active incident detection by continuously evaluating rules against collected metrics and firing alerts when conditions are met. Unlike traditional monitoring systems that might only check conditions periodically, Prometheus evaluates alert rules at a regular interval (I set mine to 30 seconds) against every scrape of fresh data, ensuring near real-time detection of issues.</p><p>The way alerts work follows a simple but powerful pattern: you define a condition using <a href="https://signoz.io/guides/promql-cheat-sheet/"><strong>PromQL </strong></a>(Prometheus’s query language), specify how long that condition must be true before firing, and attach metadata to help responders understand what’s happening. For example, my high CPU alert uses the expression 1<strong>00 — (avg by(instance) (rate(windows_cpu_time_total{mode=”idle”}[5m])) * 100) &gt; 80</strong>, which calculates the average non-idle CPU time over the past 5 minutes and triggers when it exceeds 80%. The <strong>for: 2m</strong> parameter means this condition must remain true for 2 consecutive minutes before the alert actually fires — this prevents false alarms from brief, harmless spikes.</p><p>Each alert moves through three states in its lifecycle: inactive (condition is not met), pending (condition is met but hasn’t exceeded the for duration yet), and firing (condition has been true long enough and the alert is active). This state machine approach prevents alert fatigue by filtering out transient issues while still catching sustained problems that need attention. I configured six different alerts covering CPU usage, memory pressure, disk space, service availability, and container resource consumption — each with carefully chosen thresholds and durations based on what would actually constitute a problem worth investigating.</p><p>The alerts themselves are defined in a separate YAML file (alert_rules.yml) organized into groups, with each alert containing the PromQL expression, the duration threshold, severity labels (like warning or critical), and human-readable annotations that explain what’s wrong and provide context. When an alert fires, Prometheus can forward it to Alertmanager (a separate component) for routing to notification channels like email, Slack, or PagerDuty, though in my homelab setup I focused on viewing active alerts directly in the Prometheus web UI to understand the alerting mechanism itself. This separation between metric collection (Prometheus), alert evaluation (Prometheus), and alert routing (Alertmanager) creates a flexible system where you can define sophisticated rules regarding what constitutes a problem and how to respond to them.</p><p>Theory lesson is over. I made the <strong>alerts_rules.yml</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/970/1*5eevh7p_TAyVPIv0NaTzFw.png" /></figure><p>Notice the <strong>system_alerts</strong> group at the top of the file. In Prometheus, <strong>groups</strong> are organizational containers that let you bundle related alert rules together and control how often they’re evaluated as a unit. Here, I used the <strong>system_alerts</strong> group to evaluate all my infrastructure alerts every 30 seconds, but in production environments one might create separate groups like critical_alerts (evaluated every 10 seconds) and warning_alerts (evaluated every 60 seconds) to prioritize urgent issues while reducing the load of checking less critical conditions. Groups are purely organizational — they don’t affect how alerts fire, they just determine when Prometheus checks the rules and help you logically separate concerns like “database alerts” versus “network alerts” in larger monitoring setups.</p><p>Next up was to create a <strong>Docker Compose</strong> file for spinning up the Prometheus, Grafana, and cadvisor containers. <strong>Docker Compose is a tool that uses a YAML file to define and run multi-container applications as a single orchestrated stack</strong>, allowing you to start, stop, and manage related containers together with one command (<strong>docker-compose up -d</strong>) instead of running separate<strong> docker run</strong> commands for each service. In my previous Docker homelab (<a href="https://secnate.medium.com/homelab-9-containerization-with-docker-5b9777ebba2b">Homelab #9</a>), I didn’t use Docker Compose because the goal was to learn the fundamentals of Docker by manually running individual containers with <strong>docker run</strong>, understanding networking, volumes, and container management at a granular level. However, for this observability lab, Docker Compose made sense because Prometheus, Grafana, and cAdvisor needed to work together as an integrated monitoring stack with shared networking and coordinated lifecycle management.</p><p>Below is the screenshot of what I wrote for the <strong>docker-compose.yaml </strong>files, as well as the explanatory comments to guide the reader through what each line means:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*9fNe6yeyFZweIsvJaS1RPA.png" /></figure><p>Additional note regarding the <strong>command </strong>field. Per <a href="https://docs.docker.com/reference/compose-file/services/#:~:text=m%2Dexecutor%2Dabcd-,command,for%20example%20by%20Dockerfile&#39;s%20CMD%20.&amp;text=If%20the%20value%20is%20null,words%20overridden%20to%20be%20empty.&amp;text=The%20value%20can%20also%20be,syntax%20used%20by%20the%20Dockerfile.">the documentation</a>, the <strong>command </strong>field in a Docker Compose file overrides the default command declared by the container’s image. In other words, it controls what the container runs when it starts. Notice that it keeps the image’s entrypoint intact. This is often very useful when dealing with “starting point” containers from Docker Hub or another shared repository that you need to get working for your own needs.</p><p>To illustrate, I pulled the latest Prometheus Docker container using the <strong>docker pull prom/prometheus:latest </strong>command on my Homelab machine, and then used the <strong>docker inspect prom/prometheus:latest</strong> command to see what the default command was — as well as the other out-of-box configuration and state information. Here is what I got:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*DRtveEDtwGJQjLSi8Fe55A.png" /></figure><p>That was educational. You can now see how the command that I specified for the Prometheus image in the Docker Compose file differs from what they provided out of box!</p><p>With the Docker Compose file made, I went into the directory where the project was located and used the <strong>docker-compose up -d </strong>command to start up all the services.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/941/1*4GkKOAtncyUx4dKeOEiVNA.png" /></figure><p>OK, now to check that the containers we wanted to launch are actually running</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*NbOXhmz6xWS8mozHAhLphg.png" /></figure><p>Looks good. Then I quickly used the <strong>docker logs prometheus</strong>, <strong>docker logs grafana</strong>, and <strong>docker logs cadvisor</strong> commands to look at their respective logs</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*RwpSImNX8XqJChuR9GP11g.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*6-jtfaUZ9SSBhcvX03AKZQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*oqaGMYWdEimXPd7XLKAt3A.png" /></figure><p>OK, we got some logs coming in. Now to see how things look in the browsers. I went into my homelab workstation’s browser and confirmed access to</p><ul><li><strong>Prometheus </strong>— at <a href="http://localhost:9090/">http://localhost:9090</a></li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*cYtkOv46fnPuvlN46Tutrg.png" /></figure><ul><li><strong>Grafana </strong>— at <a href="http://localhost:3000/">http://localhost:3000</a></li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*dMo8NqfyprU1OMa7uOuS0A.png" /></figure><ul><li><strong>cAdvisor </strong>— at <a href="http://localhost:8080/">http://localhost:8080</a></li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*HwTivGEAMNB5n4QlTSZrRw.png" /></figure><p>Great. Now it’s time to configure things on more of the boxes themselves.</p><p>First up was to install Windows Exporter onto my workstation. Windows Exporter is a Prometheus-compatible monitoring agent that runs as a Windows service and exposes system performance metrics (CPU, memory, disk, network, services) through an HTTP endpoint on port 9182. It acts as a translator between Windows Performance Counters (built-in Windows metrics that track system resource usage and are viewable in the Performance Monitor application) and Prometheus’s metrics format, allowing Prometheus to scrape and monitor Windows-specific system statistics.</p><p>I went onto the GitHub page for Windows Exporter and downloaded the .exe executable from the <a href="https://github.com/prometheus-community/windows_exporter/releases">https://github.com/prometheus-community/windows_exporter/releases</a> page and put it into a homelab-specific folder.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/877/1*AgEZkg_C6aMJ8kXiWscleQ.png" /></figure><p>Then I fired it up by double-clicking the file and got it running</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*EkRAPWwqKJz-lbS4snWEsQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*-Gw9f8-koiGrn0m0go1Pig.png" /></figure><p>Then, it was time to access Windows Exporter in my browser. It serves its metrics through a built-in HTTP web server on port 9182, allowing Prometheus (or any web browser) to access the metrics by simply making HTTP GET requests to <a href="http://localhost:9182/metrics,">http://localhost:9182/metrics,</a> which returns all the Windows performance data in plain text format that Prometheus can parse and store.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*v2Gz1oD5EGLmSgf2RNLFfA.png" /></figure><p>And we can see that the process is running appropriately</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*vJNjI2mjk2OrC1pNY8JH9A.png" /></figure><p>OK. Next up was to install Node Exporter on the Ubuntu VM that I quickly made for this lab. Node Exporter is the Linux equivalent of Windows Exporter — a lightweight Prometheus agent that exposes hardware and kernel-level metrics by reading from Linux’s /proc and /sys filesystems and serving them on port 9100. I installed Node Exporter on my Ubuntu VM to provide Prometheus with visibility into the VM’s CPU usage, memory consumption, disk space, and network traffic, creating a multi-platform monitoring setup where I could track both my Windows host and Linux VM from a single Prometheus instance and compare system performance across different operating systems.</p><p>Notice that this newly-created VM was configured with a host-only network adapter, and I confirmed that it could be pinged from my homelab workstation. The exact steps of creating an Ubuntu VM, configuring its network interfaces, and setting static IP addresses were covered multiple times in past homelab writeups, so I will not cover them for this round.</p><p>In this <em>newly-created </em>VM, I followed the steps in the <a href="https://prometheus.io/docs/guides/node-exporter/#installing-and-running-the-node-exporter">Prometheus Documentation page</a> to download the <a href="https://prometheus.io/download/#node_exporter"><strong>node_exporter</strong> application, version 1.10.2</a> (the releases can be found <a href="https://github.com/prometheus-community/windows_exporter/releases">on the GitHub page</a>).</p><p>First I used the <strong>wget </strong><a href="https://github.com/"><strong>https://github.com</strong></a><strong> /prometheus/node_exporter/releases/download/v1.10.2/node_exporter-1.10.2.darwin-amd64.tar.gz </strong>command to download the initial <strong>.tar.gz</strong> file.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/798/1*9HPpohCZcCl0NTlu27tftw.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/797/1*uIVTqdUOjlnLWOrVONSk7w.png" /></figure><p>Then I extracted its contents appropriately with the <strong>tar xvfz node_exporter-*.*-amd64.tar.gz </strong>command</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/601/1*qBPuByU0_PBKI9IlUChbBg.png" /></figure><p>And then opening the folder using the <strong>cd node_exporter -*.*-amd64 </strong>command, we see the actual executable</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1006/1*fw0HXlqH3n30LI_ZwJFF9A.png" /></figure><p>OK so this is just a starting point. We had to make a new user, specifically for Node Exporter! Why? We learned a lot in security classes about the concept of least privilege — and this is a prime illustration of such a case in point. If we run Node Exporter under the root user, an attacker could exploit a potential vulnerability and get access to the entire system. That’s not good. Meanwhile, running it under a personal account — say <strong>“nathanuser”</strong> — is also a bad idea. A personal user might have sudo privileges, or an attacker might get access to their personal files, SSH keys, and etc.</p><p>So that’s why I made a dedicated user under which Node Explorer will run under a dedicated <strong>systemd</strong>-managed service. I did this with the <strong>sudo useradd –no-create-home –shell /bin/false node_exporter </strong>command</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*iPcLaIiA49QxUleeKh_dYQ.png" /></figure><p>Let’s discuss briefly why this command was used.</p><p>The <strong>–no-create-home</strong> flag told the <strong>useradd</strong> command to not create a <strong>/home/node_exporter/ </strong>directory. This is because this is just a background service running, it doesn’t need to store any personal files, there is less clutter in the <strong>/home/</strong> folder, and also…. There is no place for an attacker to work in and access files/download files if <strong>Node Exporter</strong> gets compromised.</p><p>The <strong>–shell /bin/false</strong> flag was used to specify a program that immediately exits with a failure code — which prevents any console from appearing. This is intended to prevent interactive logins. If someone tries to log in as <strong>node_exporter</strong>, they will not be successful — and if an attacker compromises Node Exporter or gets credentials somehow, they won’t be able to do the following, which they could do with a regular <strong>/bin/bash</strong> shell:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/454/1*FekD14ObYp0ohhiVNK5-CA.png" /></figure><p>So now we see why hardening systems and being deliberate about what kinds of accounts are created (and the configurations thereof) are super important!</p><p>Next up was to set up Node Exporter as a systemd service, where it will run in the background and automatically start at boot. The following steps are derived from <a href="https://notes.kodekloud.com/docs/Prometheus-Certified-Associate-PCA/Prometheus-Fundamentals/Node-Exporter-systemd">a KodeKloud tutorial</a>.</p><p>First, I used the <strong>sudo nano /etc/systemd/system/node_exporter.service</strong> command to open up the text editor</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*4p2VloZUwrzHpTV8PTaRxA.png" /></figure><p>And then wrote the following service file:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/593/1*Dy7JWg-5LmU3_c2zNd-gig.png" /></figure><p>To explain what some of its lines mean: The <strong>Wants=network-online.target</strong> field establishes a soft dependency on the network being available (meaning Node Exporter prefers the network to be ready but will still attempt to start even if it isn’t), while the <strong>After=network-online.target </strong>field controls the startup order by ensuring Node Exporter waits for the network to come online before launching. These directives together prevent Node Exporter from starting before it can bind to port 9100 and serve metrics. The<strong> Type=simple </strong>setting tells systemd that Node Exporter runs as a straightforward foreground process that stays directly under systemd’s control, rather than spawning a copy of itself to run in the background (called “forking”) or detaching from the terminal (called “daemonizing”). The<strong> ExecStart=/usr/local/bin/node_exporter </strong>line specifies the exact binary path to execute when starting the service. The <strong>WantedBy=multi-user.target</strong> field tells systemd “when you run systemctl enable node_exporter, create a link that makes this service start automatically whenever the system boots into multi-user mode.” Essentially, it’s like adding Node Exporter to the system’s startup programs list so it runs every time the server boots up.</p><p>Neato burrito. Next up was to start the service.</p><p>To do this, I ran<strong> sudo systemctl daemon-reload</strong> to tell systemd to reload its configuration and recognize the new <strong>node_exporter.service</strong> file I just created. Then <strong>sudo systemctl start node_exporter </strong>was to launch the Node Exporter service,<strong> sudo systemctl enable node_exporter</strong> was to configure it to start automatically on every boot, and finally <strong>sudo systemctl status node_exporter</strong> to verify that the service was running correctly.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*RMrCLK25pQ24yprLIeIHeg.png" /></figure><p>Welp. That didn’t work. Scrolling a bit to the right revealed the red line error message: <strong>node_exporter.service: Failed at step EXEC spawning /user/local/bin/node_exporter: No such file or directory</strong></p><p>Ah. That was because I forgot to copy over the node exporter executable that I downloaded and extracted from that <strong>.tar.gz </strong>repository. Doable fix. I quickly went into the folder in which I had the <strong>node_exporter </strong>executable downloaded and used the <strong>sudo cp node_exporter /usr/local/bin/node_exporter</strong> command to quickly copy things over.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ksyeFIpy8rpBWjxh5J5lKQ.png" /></figure><p>Then I repeated the previously performed systemctl-related commands again — and got an <strong>Exec format error</strong>:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*-_7ULoq0gAHnH44e9sVf7g.png" /></figure><p>This is an error related to architecture incompatibility problems. I used the <strong>uname -m </strong>command to see my VM’s architecture and used the <strong>file /usr/local/bin/node_exporter </strong>command to see the <strong>node_exporter </strong>executable’s architecture.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*oHCYs3PnadtS48qYpMvPmQ.png" /></figure><p>Aha. This was the problem! The downloaded <strong>node_exporter</strong> binary was a <strong>Mach-0 </strong>executable! A Mach-O (Mach object) file is the native executable file format used by Apple’s operating systems, including macOS, iOS, and others based on the Mach kernel. It functions similarly to PE (Portable Executable) files in Windows and ELF (Executable and Linkable Format) files in Linux. Definitely a platform issue. My mistake.</p><p>This was not the end of the world and could be quickly fixed. I used the <strong>wget </strong><a href="https://github.com/"><strong>https://github.com</strong></a><strong> /prometheus/node_exporter/releases/download/v1.10.2/node_exporter-1.10.2.linux-amd64.tar.gz </strong>command to quickly download the Linux-version of an amd64 architecture version of node exporter. Then I used the <strong>tar xvfz node_exporter-*.*-amd64.tar.gz</strong> command to quickly extract it. Afterwards, I opened the extracted folder, used the <strong>sudo cp node_exporter /usr/local/bin/node_exporter </strong>to copy it over, and then ran the systemd-related commands all over again.</p><p>Finally, I reached the promised land, the Shangri-La, the state of nirvana. The <strong>node_exporter</strong> service was active and running!</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*zJ9I-xLaENWUMzsJeORIoA.png" /></figure><p>I then needed to verify that the metrics were being collected. I did this by opening my homelab workstation’s browser and going to <a href="http://192.168.100.253:9100/metrics"><strong>http://192.168.100.253:9100/metrics</strong></a></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/960/1*kNnz5V8HtY2qoZBv3bWIcg.png" /></figure><p>Beautiful. I then needed to make a tweak to my <strong>prometheus.yml</strong> file to ensure that the IP address is of the right value and corresponded to my actual Ubuntu VM address.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/685/1*gnR59fBOez4CHBsyaZj9tQ.png" /></figure><p>Wonderful. I then needed to lock and load the changes. This was done with the <strong>docker restart prometheus </strong>command, followed by the <strong>docker logs prometheus </strong>command to see the play-by-play</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*uIsOwcBXmRJ9ZZqKhpaw1A.png" /></figure><p>Then to check up on whether Prometheus had all the targets up and running, I went into my homelab workstation’s browser and entered the <a href="http://localhost:9090/targets"><strong>http://localhost:9090/targets</strong></a><strong> </strong>IP address</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*R7ddG15aY9QNT1XEOSebnw.png" /></figure><p>Perfect. We have been on quite the setup journey. Onwards to the next sections!</p><p><strong>Section II: Configure Grafana Dashboard</strong></p><p>After getting the data being fed into Prometheus, I turned my attention to Grafana. As a reminder, it is the graphical display of information from Prometheus (and other) data sources — and can be conveniently used as a single pane of glass.</p><p>I started by opening Grafana in my homelab workstation’s browser at the <a href="http://localhost:3000/"><strong>http://localhost:3000</strong></a><strong> </strong>address</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*AlRrFlIZrL59-fA0C6N_Lg.png" /></figure><p>Then I logged in using the default <strong>admin/admin</strong> credential, changed the password as prompted, and ended up on the homepage.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*A3NI8q3F8IV1jk0PpSIQ3Q.png" /></figure><p>Then I opened up the<strong> </strong><em>Connections &gt;&gt; Data Sources</em> page. As expected, there were no data sources currently defined</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*hmP5jWVCU7D4dgdYMyaZgQ.png" /></figure><p>Then I clicked the <em>Add Data Source</em> button and selected <em>Prometheus</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*snRuOJzddZMaxUuDT0gMHA.png" /></figure><p>And arrived at this beautiful configuration page</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*T2hpVJY4tuFmZBgn-Um0mQ.png" /></figure><p>I quickly entered the <a href="http://prometheus:9090/"><strong>http://prometheus:9090</strong></a><strong> </strong>url to configure the data source, scrolled down to the bottom, and clicked the <em>Save and Test</em> button. I got a green checkmark showing that Prometheus API has been successfully queried</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ZurTkRk4zOURFF0OCfZZnA.png" /></figure><p>Great. Now for the actual data. I went back to the Grafana’s Homepage and clicked the <em>Dashboards &gt;&gt; New Dashboard </em>button. I clicked the<em> +Add Visualization</em><strong> </strong>button and selected <em>Prometheus</em> as my data source.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*NilJyo-NU475pq1dGap6mQ.png" /></figure><p>So to explain (per the <a href="https://grafana.com/docs/grafana/latest/visualizations/dashboards/">Grafana documentation</a>), a Grafana dashboard is a set of one or more panels — visual representation of data composed of a query and a visualization — which are neatly organized and arranged into one or more rows. So that’s why I got presented with the page for creating a brand new panel</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*EqIObsO1RjhGhYiNnfMyKw.png" /></figure><p>Obviously, it was time to make the panels. Due to the number of panels involved, I will present a bulleted list of each with their properties followed by an explanatory screenshot:</p><ul><li><strong>Panel 1: Windows CPU Usage</strong></li></ul><p><strong>Query: </strong>100 — (avg by(instance) (rate(windows_cpu_time_total{mode=”idle”}[5m])) * 100)<br><strong>Legend:</strong> {{instance}} CPU<br><strong>Title:</strong> Windows CPU Usage (%)<br><strong>Unit:</strong> Percent (0–100)<br><strong>Thresholds:</strong> 80 (orange), 90 (red)</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*tIUKlMZb1ppSTQVZ38JfcA.png" /></figure><ul><li><strong>Panel 2: Windows Memory Usage</strong></li></ul><p><strong>Query:</strong> 100 — ((windows_memory_physical_free_bytes / windows_memory_physical_total_bytes) * 100)<br><strong>Legend: </strong>{{instance}} Memory<br><strong>Title: </strong>Windows Memory Usage (%)<br><strong>Unit:</strong> Percent (0–100)</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*H_mtq8aTq4KxqKM3GWrvKQ.png" /></figure><ul><li><strong>Panel 3: Windows Disk Space</strong></li></ul><p><strong>Query:</strong> (windows_logical_disk_free_bytes{volume=~”C:”} / windows_logical_disk_size_bytes{volume=~”C:”}) * 100<br><strong>Title: </strong>Windows C: Drive Free Space (%)<br><strong>Legend: </strong>{{instance}} Disk Space<br><strong>Unit:</strong> Percent (0–100)</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*fdQ67v7hpWzct6DjRYMynQ.png" /></figure><ul><li><strong>Panel 4: Ubuntu VM CPU</strong></li></ul><p><strong>Query: </strong>100 — (avg by(instance) (rate(node_cpu_seconds_total{instance=”ubuntu-vm”,mode=”idle”}[5m])) * 100)<br><strong>Title:</strong> Ubuntu VM CPU Usage (%)<br><strong>Legend: </strong>{{instance}} CPU Usage<br><strong>Unit:</strong> Percent (0–100)</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*HWIfuK8MqoIQm9aUdP_3CA.png" /></figure><ul><li><strong>Panel 5: Ubuntu VM Memory</strong></li></ul><p><strong>Query: </strong>(1 — (node_memory_MemAvailable_bytes{instance=”ubuntu-vm”} / node_memory_MemTotal_bytes{instance=”ubuntu-vm”})) * 100<br><strong>Title: </strong>Ubuntu VM Memory Usage (%)<br><strong>Legend: </strong>{{instance}} Memory Usage<br><strong>Unit:</strong> Percent (0–100)</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*qOxlPN_-NRgAKXwGoxDc7w.png" /></figure><ul><li><strong>Panel 6: Network Traffic</strong></li></ul><p><strong>Query A: </strong>rate(windows_net_bytes_received_total[5m])<br><strong>Query B:</strong> rate(windows_net_bytes_sent_total[5m])<br><strong>Title:</strong> Windows Network Traffic<br><strong>Unit:</strong> bytes/sec</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*tVqtHDniq6Tw3FqueAbhPQ.png" /></figure><ul><li><strong>Panel 7: Container CPU Usage</strong></li></ul><p><strong>Query:</strong> sum(rate(container_cpu_usage_seconds_total[5m])) by (container)<br><strong>Title:</strong> Container CPU Usage (cores)</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*kiq3NdVYKNMyn3azcj4YCA.png" /></figure><ul><li><strong>Panel 8: Container Memory</strong></li></ul><p><strong>Query:</strong> container_memory_usage_bytes{name!=””} / 1024 / 1024<br><strong>Title: </strong>Container Memory Usage (MB)<br><strong>Legend: </strong>{{ id }}</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*GWhnt7H5ZRp3KErp0GMf3g.png" /></figure><ul><li><strong>Panel 9: System Uptime (hours)</strong></li></ul><p><strong>Query (Windows):</strong> (time() — windows_system_boot_time_timestamp)/3600<br><strong>Query (Linux):</strong> (node_time_seconds — node_boot_time_seconds)/3600<br><strong>Title:</strong> System Uptime (hours)</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*14007qLzdx6ZbOKQDxtTNQ.png" /></figure><ul><li><strong>Panel 10: Active Alerts</strong></li></ul><p><strong>Query: </strong>ALERTS{alertstate=”firing”}<br><strong>Title:</strong> Active Alerts<br><strong>Visualization:</strong> Stat (show count)<br><strong>Legend:</strong> “{{job}}” (volume: {{volume}})</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Ki0UN7e8yO-V5XC3iWbApw.png" /></figure><p>Wonderful. With the panels made, I had to then rearrange the panels into a logical layout on one single screen. I rearranged them into a nice dashboard and had the panels with similar themes (memory usage %, CPU usage %, etc). I also used the <em>Add Row </em>button to name the row corresponding to these panels as <em>Homelab Monitoring — Windows &amp; Linux</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*TwKqXvthV6beDQbqc88eTQ.png" /></figure><p>I then used the <em>Save dashboard</em> button in the top right-hand corner and named what I created the “System Monitoring Dashboard.”</p><p>Also notice that Grafana has a cool ability to export/share dashboard-related information, specifically using JSON. I went into the settings page and saw the JSON Model</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*brMlzSXBd6ZipBVyAEhxOw.png" /></figure><p>There was no need for me to do anything with it at the time, but it was good to know that it was available if I needed it for whatever reason.</p><p><strong>Section III: Testing &amp; Alert Simulation</strong></p><p>The next section of the homelab involved stress testing the systems involved and monitoring the generated Prometheus alerts and the Grafana dashboard. This section will have sub-sections related to each task performed.</p><p><strong>Task #1: Test Windows CPU Alert</strong></p><p>The first thing I did was create a <em>testing and alert simulation</em> folder inside the repository where I kept the files related to this homelab. I then created a <strong>cpu-stress.ps1 </strong>PowerShell script</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/822/1*nkTVxfuO_38mNUMNYqO3YA.png" /></figure><p>Then, I ran it</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*cjsYe0t93xDgAjS-am7hiw.png" /></figure><p>I then set the Grafana dashboard from the previous section to cover the data collected from the last 30 minutes, and took a look at the <em>Windows CPU Usage (%) </em>graph. As expected, there was a spike</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/273/1*S_KNF26iADmkQEPZDSatpQ.png" /></figure><p>Notice that the graph did not cross the 80% threshold. I then went into Prometheus and saw — as expected — that the <strong>HighCPUUsage</strong> alert was in an <em>Inactive </em>state — i.e. — it did not fire.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*wbjgM0b9eU6xpQw0e3Q96w.png" /></figure><p>I also out of curiosity pulled up Prometheus’ graphing capabilities, put in the PromQL query corresponding to the alert, and took a look at the data more granularly.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*4DG1jGpf694PRZRVJDrnoA.png" /></figure><p>Everything was working as expected.</p><p><strong>Task #2: Test Memory Alert (Ubuntu VM)</strong></p><p>I opened up the Ubuntu VM that I had up and running and installed some updates using the <strong>sudo apt-get update</strong> command. Then I used the <strong>sudo apt-get install -y stress-ng </strong>command to install <a href="https://github.com/ColinIanKing/stress-ng"><strong>stress-ng</strong></a>. To explain simply what it is, <strong>stress-ng </strong>is a workload generator for Linux and other Unix-like systems and helps test their ability to maintain a good level of efficiency under unfavorable conditions. It features <a href="https://github.com/ColinIanKing/stress-ng">370+ stress tests</a> for CPU (integer, float, matrix ops), memory (cache, virtual memory), I/O, and kernel interfaces.</p><p>I then on the Ubuntu VM generated high memory usage with the following command: <strong>stress-ng –vm 1 –vm-bytes 90% –timeout 5m</strong>. Per <a href="https://manpages.ubuntu.com/manpages/focal/man1/stress-ng.1.html">the documentation</a>, the <strong>–vm 1 </strong>flag told <strong>stress-ng</strong> to start one worker that continually writes to allocated/mapped memory, the <strong>–vm-bytes 90%</strong> flag tells the worker to work with 90% of the total available memory, and the <strong>–timeout</strong> flag told <strong>stress-ng </strong>to end the stress tests after a 5 minute period</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*1Ev-iD8iTPNcHnvMdwHu1g.png" /></figure><p>After this was completed, I took a look into Grafana. Notice that the memory usage briefly crossed the 90% memory usage threshold, as expected.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/307/1*CHsIhS3S0CluAkjo52b2DA.png" /></figure><p>Then, I looked at Prometheus to see if any alerts fired on the VM-related activity. An alert did not fire.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*mPhKnMEBYgcrzJZ7yyA4Ww.png" /></figure><p>This was expected. As explained per the arrows pointing to two keywords in the above screenshot, the <strong>system_alerts</strong> for High CPU usage and High Memory usage were for Windows system only.</p><p><strong>Task #3: Test Service Down Alert</strong></p><p>On the Ubuntu VM, I used the <strong>sudo systemctl stop node_exporter</strong> command to stop node_exporter. As a reminder, it acts as a metrics/data collector on this Ubuntu VM endpoint for Prometheus to then be able to scrape.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/965/1*5p72QeXNdwGKi9iuH6Ggbg.png" /></figure><p>I then waited for a few minutes, and just as expected, Prometheus had the <strong>TargetDown</strong> alert fire</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*As6TAQ0U8ZHnRMRE_I8IsA.png" /></figure><p>And additionally, the Grafana panel showing Active Alerts showed that there was an alert on the Ubuntu VM</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*2w6_wiv0BtEaEyri_7IMDQ.png" /></figure><p>Everything was operating just as expected. Having achieved the goal of this test, I quickly restarted the <strong>node_exporter</strong> service back up on the Ubuntu VM and confirmed it was back up and running</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*hWLSMHoGNo8aCp3SNpJG7Q.png" /></figure><p><strong>Task #4: Test Container Stress</strong></p><p>I needed to do one more stress test. Per an explanation by <a href="https://medium.com/@ravipatel.it/stress-testing-your-system-with-docker-and-stress-bd7760b8fbcf">Ravi Patel</a><strong>, </strong>stress testing with the simulation of heavy loads and stress conditions can help identify potential bottlenecks, vulnerabilities, and performance issues before they impact users. We previously did that in the second task on the Ubuntu VM. This time, I needed to do it in the context of a Docker container.</p><p>To do this, I used the <a href="https://hub.docker.com/r/polinux/stress"><strong>polinux/stress</strong></a> Docker container image. First, I pulled the image using the <strong>docker pull polinux/stress</strong> command. Then I used the <strong>docker run -ti — rm polinux/stress stress — cpu 2 — io 1 — vm 1 — timeout 300s — verbose</strong> command to launch a stress test. The <strong>–rm </strong>flag ensured automatic cleanup after the exit, there were two workers performing continuous calculations, and one Input/Output worker generated sync operations.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*fg9ec4V9UKHAcJBGMTxcKA.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*DR8jRben1htBKS_Uxldgkw.png" /></figure><p>In Grafana, the graph for Container CPU Usage showed an expected spike</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/300/1*_arvyWOyw-hS8-EtnH5ROQ.png" /></figure><p>I didn’t see any alerts fire in Prometheus. This was expected given that my <strong>Container High Memory</strong> alert rule specifically checks for usage as a percentage of defined container memory limits (<strong>container_spec_memory_limit_bytes</strong>), and my stress test container ran without any resource limits configured. In a production environment, containers would typically have CPU and memory limits defined in their Docker Compose or Kubernetes configurations, making these percentage-based alerts appropriate. For this homelab, I confirmed that the alert rules were syntactically correct, validating my understanding of alert rule structure even though the specific metrics required to trigger these container alerts weren’t present in my test scenario.</p><p><strong>Section IV: Cleanups</strong></p><p>With this homelab exercise over, I shut down my Ubuntu VM in VirtualBox, and then used the <strong>docker-compose down</strong> command to end the execution of the <em>grafana, cadvisor, </em>and the <em>prometheus </em>containers</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*FW1V4avfcwYvmYMksaWjzQ.png" /></figure><p>Then I also closed the Window in which the Windows Exporter application was running too.</p><p>That’s all for this round. I certainly learned a lot and now understand how Prometheus, Grafana, and PromQL work. There certainly was a bit of setup to go through, but once everything is set up, it’s nice to sit back and visually take in a lot of data regarding your infrastructure’s functioning on a single screen, and be reassured that alerts will fire if any issues are detected.</p><p>See you all in the next round!</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=0aca9eeeb654" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Homelab #10: Kubernetes Basics with Multi-Cloud Integration]]></title>
            <link>https://secnate.medium.com/week-9-kubernetes-basics-with-multi-cloud-integration-ad0d53643c30?source=rss-f95ccfcb96af------2</link>
            <guid isPermaLink="false">https://medium.com/p/ad0d53643c30</guid>
            <dc:creator><![CDATA[Nathan Pavlovsky]]></dc:creator>
            <pubDate>Thu, 18 Dec 2025 00:50:39 GMT</pubDate>
            <atom:updated>2025-12-20T00:39:05.928Z</atom:updated>
            <content:encoded><![CDATA[<p>Welcome, my loyal readers to this homelab episode. In this round, I learned Kubernetes and how it orchestrates containers across both the AWS and Azure cloud providers.</p><p>Kubernetes (often shortened to K8s) is an open-source container orchestration platform that automates the deployment, scaling, and management of containerized applications across clusters of machines. Originally developed by Google and now maintained by the Cloud Native Computing Foundation, Kubernetes has become the industry standard for managing containerized workloads in production environments. It’s critical because it solves the challenges of running containers at scale — automatically handling failures, distributing traffic, managing storage, and ensuring applications stay available even as infrastructure changes beneath them.</p><p>Let’s get started.</p><p><strong>Section I: Initial Prep</strong></p><p>Before starting with this round, I quickly confirmed that I had <em>AWS Free Tier alerts </em>and <em>CloudWatch billing alerts</em> enabled in my AWS account. I also created a $10 budget in the Azure portal and set an alert to fire at the 80% threshold.</p><p>Then I quickly installed <em>Docker Desktop </em>on my Windows homelab laptop (yes, outside of any VMs this time). It is the desktop application of Docker which simplifies the development and container management process. Then I installed</p><ul><li><strong>kubectl</strong>, a command line tool that lets you manage Kubernetes</li><li><strong>minikube</strong>, a command line tool that lets you run Kubernetes on your<strong> local machine</strong>, primarily for testing and development purposes</li></ul><p><strong>A quick note on the distinction between these two tools:</strong> While both kubectl and minikube are command-line tools used in my Kubernetes workflow, they serve fundamentally different purposes. <strong>minikube </strong>is specifically for managing the Kubernetes cluster itself — it handles cluster lifecycle operations like starting, stopping, deleting the cluster, and managing cluster-level features through addons. Think of it as the tool that builds and maintains the infrastructure.</p><p><strong>kubectl</strong>, on the other hand, is the <em>universal Kubernetes client</em> that works with <em>any Kubernetes cluster </em>(whether it’s Minikube, EKS, AKS, or a production cluster) and is used to manage the applications and resources running inside the cluster — deployments, pods, services, and so on.</p><p>In simple terms: <strong>minikube </strong>manages the stage on your local computer, <strong>kubectl </strong>manages the actors performing on it.</p><p><strong>Section II: Local Kubernetes With Minikube</strong></p><p>I started by opening up a PowerShell window with administrative privileges and launching my first Kubernetes cluster. This was done with the <strong>minikube start –driver=docker –cpus=2 –memory=4096 </strong>command</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/958/1*etl7zJzlT5g4eXZuQ0h8ZQ.png" /></figure><p>Let’s briefly discuss what I just did. In Kubernetes, a <strong>node</strong> is essentially a worker machine (virtual or physical) in the cluster that runs your applications inside <strong>pods</strong> (groups of one or more containers).</p><p>When using <strong>Minikube</strong> (a tool for running a local single-node or multi-node Kubernetes cluster for development/testing), the behavior depends on the driver.</p><ul><li>With most drivers (e.g., virtualbox, hyperkit), Minikube creates a virtual machine (VM), and the Kubernetes <strong>node</strong> runs inside that VM.</li><li>With the <strong>— driver=docker</strong> flag (as in my command), Minikube runs the Kubernetes cluster <strong>directly as Docker containers</strong> on the host machine, without a separate VM. Specifically, for a single-node cluster (the default), the entire Kubernetes node (control plane + worker components like kubelet, kube-proxy, etc.) runs inside <strong>one main Docker container</strong>.<br>This container is often referred to informally as the “node container” or “node image” because it’s built from a pre-configured base container image that Minikube pulls and uses to bootstrap the node.</li></ul><p>Now, regarding the <strong>minikube start –driver=docker –cpus=2 –memory=4096</strong> command. This command started a local Kubernetes cluster using the Docker driver:</p><ul><li>Minikube first pulled a <strong>base container image</strong> (this is the “node image”) from a registry.</li><li>It then created and ran a Docker container from that image on my workstation’s Docker daemon.</li><li>This container became my Kubernetes <strong>node</strong>, allocated <strong>2 CPUs</strong> and <strong>4096 MB (4 GB) of memory</strong> as specified.</li><li>Inside this container, Minikube set up the full Kubernetes stack (API server, etcd, scheduler, etc.), and the pods ran as nested containers within it.</li></ul><p>Then I used the <strong>kubectl cluster-info </strong>command to check the cluster’s status. Here is what I got</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/833/1*t7uTKF9GG1KZbP4PYJhuoA.png" /></figure><p>And then I got information regarding the cluster’s nodes using the <strong>kubectl get nodes</strong> command. It is a simple way to list all the worker machines (nodes) in a Kubernetes cluster and to get information about them at a glance.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/408/1*ZCF_oMCvA3fEFXQ8S_-jYQ.png" /></figure><p>And then I used the <strong>kubectl get nodes -o wide </strong>command to get some additional details, such as the internal and external IP addresses.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/956/1*W1kA0U_LiSBWO8CWgIiOlg.png" /></figure><p>Now to introduce the notion of namespaces in Kubernetes. Namespaces are like virtual partitions that allow you to divide a single cluster into multiple isolated environments, making it easier to manage resources for different teams, projects, or applications without conflicts.</p><p>The <strong>kubectl get all — all-namespaces</strong> command lists all major resources (such as pods, services, deployments, and replicasets) across every namespace in your cluster, giving you a comprehensive overview of what’s running everywhere in one go. I executed it here:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1004/1*1HliUlp1h26_uZOWcbNQiw.png" /></figure><p>Let’s take a look further at what we saw. The two namespaces listed in the output’s first column are</p><ul><li><strong>default</strong>: It is automatically created when you set up a cluster and serves as the catch-all space for resources (like pods, services, and deployments) that you create without specifying any other namespace. It is usually used in simple setups, testing, or small teams</li><li><strong>kube-system</strong>: It is a reserved, system-created space dedicated to hosting core Kubernetes control plane components (like kube-apiserver, kube-scheduler, and kube-controller-manager) and essential add-ons (such as CoreDNS, kube-proxy, and networking plugins).<br><strong>You should never deploy your own applications here! </strong>It is strictly for cluster-internal resources. Access to it is typically restricted to prevent accidental disruptions to the cluster’s operation.</li></ul><p>From the screenshot, we see that all but one of the resources listed are from the <strong>kube-system</strong> namespace. These include <strong>coredns </strong>(the cluster’s DNS service for resolving internal domain names and service discovery), <strong>minikube </strong>(a Minikube-specific service often tied to addon management or internal cluster utilities),<strong> kube-proxy</strong> (handles network proxying and load balancing for services across nodes), <strong>kube-scheduler-minikube </strong>(the scheduler component responsible for assigning pods to nodes), and <strong>storage-provisioner </strong>(dynamically provisions <a href="https://spacelift.io/blog/kubernetes-persistent-volumes">persistent storage volumes</a> for pods).</p><p>Now it’s time to learn a bit about Kubernetes architecture. Per a <a href="https://www.doit.com/blog/kubernetes-architecture-explained-a-comprehensive-guide/#:~:text=The%20core%20Kubernetes%20architecture%20components,-(Source)&amp;text=Kubernetes%20architecture%20is%20built%20around,simplify%20scaling%20and%20managing%20workloads.">fantastic article explaining it</a>, it is divided around two core components: the control plane and the worker nodes.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/329/1*tlghiEekyAvyBxcps_J95Q.png" /></figure><p>Some of the components that we saw from that screenshot are as follows:</p><p><strong>Control Plane Components</strong> (in kube-system namespace):</p><ul><li><strong>etcd</strong>: Database storing all cluster data</li><li><strong>kube-apiserver</strong>: Front-end for Kubernetes control plane, handles all API requests</li><li><strong>kube-scheduler</strong>: Watches for new pods and assigns them to nodes</li><li><strong>kube-controller-manager</strong>: Runs controller processes (node, replication, endpoints, service account)</li></ul><p><strong>Node Components:</strong></p><ul><li><strong>kubelet</strong>: Agent running on each node, ensures containers are running</li><li><strong>kube-proxy</strong>: Network proxy maintaining network rules</li><li><strong>Container runtime</strong>: Docker (in our case) runs the containers</li></ul><p>Coolio. From there, I used the <strong>minikube addons enable dashboard</strong> command to enable the Kubernetes dashboard and the <strong>minikube dashboard</strong> to start it and open it up in the browser. It is exactly as it sounds, a graphical user interface shown in the browser for managing Kubernetes resources.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ZKjlmswHKQS0ETO15F4MKQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*MCPoQRjAMcqTW6sPcvNqbA.png" /></figure><p>Clicking around, I learned more about it. For example, clicking “Cluster” → “Nodes” , I saw my minikube node</p><p><strong>Section II: First Deployment</strong></p><p>Time to deploy Nginx. It is a high-performance web server and reverse proxy that’s widely used in production environments to serve web content, load balance traffic, and handle thousands of concurrent connections efficiently. I started to deploy it in this Kubernetes lab because it’s a lightweight, real-world application that perfectly demonstrates container orchestration concepts like scaling, service discovery, and self-healing — skills that directly translate to managing production workloads in cloud-native environments.</p><p>Remember YAML files from Ansible? Turns out they are the primary means of defining, creating, and managing objects and resources declaratively in Kubernetes. They serve as blueprints for an application’s desired state, which Kubernetes then works to maintain. A nice piece by the <a href="https://www.cncf.io/blog/2022/03/03/how-to-write-yaml-file-for-kubernetes/"><em>Cloud Native Computing Foundation</em></a> and a <a href="https://medium.com/@zwhitchcox/matchlabels-labels-and-selectors-explained-in-detail-for-beginners-d421bdd05362">Medium post</a> had some useful information regarding how YAML files work for Kubernetes.</p><p>Time to get to work. I started by creating a <strong>nginx-deployment.yaml</strong> file, heavily based on a <a href="https://kubernetes.io/docs/concepts/overview/working-with-objects/#describing-a-kubernetes-object">sample one found in a Kuberentes documentation page</a>.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/609/1*lQKdta-fRjr76kdHjWh_Ug.png" /></figure><p>Then I applied the deployment with the following commands:</p><ul><li><strong>kubectl apply -f .\nginx-deployment.yaml</strong> →</li><li><strong>kubectl get deployments</strong></li><li><strong>kubectl get pods</strong></li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/930/1*3UsufEvDY_6a9xHg1iv5kQ.png" /></figure><p>Now it’s time to create the NodePort service. To explain simply, a NodePort service exposes your Kubernetes application on a specific port on every node in the cluster, making it accessible from outside the cluster without needing a cloud load balancer. I started by creating the <strong>nginx-service.yaml</strong> file</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/734/1*FFZjZert2m_ZfVcbm3Z7AA.png" /></figure><p>Then I applied and tested the service using the following commands:</p><ul><li><strong>kubectl apply -f nginx-service.yaml</strong></li><li><strong>kubectl get services</strong></li><li><strong>minikube service nginx-service –url</strong></li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/913/1*Cg8FC0D4QFhKXC3A20GTGA.png" /></figure><p>Then I went into my homelab computer’s browser and saw this:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*DbkwvmkVvRw3ABJueQ43WQ.png" /></figure><p>It worked.</p><p><strong>Section III: Pod Failover</strong></p><p>To slightly rehash what was mentioned previously regarding Pods — and to go over concepts in greater detail with <a href="https://kubernetes.io/docs/tutorials/kubernetes-basics/explore/explore-intro/">a referencing of Kubernetes’ official documentation</a> — a “Pod is a Kubernetes abstraction that represents a group of one or more application containers (such as Docker), and some shared resources for those containers. Those resources include:</p><ul><li><strong>Shared storage</strong>, as Volumes</li><li><strong>Networking</strong>, as a unique cluster IP address</li><li><strong>Information about how to run each container</strong>, such as the container image version or specific ports to use”</li></ul><p>Now for the concept of Pod failovers. Kubernetes pod failover is a self-healing automatic process by which Kubernetes detects a failed pod and replaces it with a new, identical pod on a healthy node. This ensures continuous application availability without manual intervention.</p><p>Let’s start. First, I started by watching the pods in real-time using the <strong>kubectl get pods -w</strong> command.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/770/1*810gP67XQuwa2V5ADaqsOA.png" /></figure><p><strong>SIDE NOTE:</strong> The <strong>kubectl help get pods</strong> command outputted a pile of information regarding how the <strong>kubectl get pods </strong>command works and explained what the usage of the <strong>-w</strong> flag is. The <strong>-w </strong>flag tells kubectl to watch for changes after listing/getting the requested pod objects</p><p>Then I opened another terminal window and deleted a pod with the <strong>kubectl delete pod nginx-deployment-6f9664446b-7txt4 </strong>command</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/819/1*bg7_An1oMX6xy8YJyNSt5Q.png" /></figure><p>And then going back into the same terminal where I used the <strong>kubectl get pods -w</strong> command, I saw the following</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/789/1*VEtneZh3u8MZLkPjQ9Gwcg.png" /></figure><p>See what happened here? The pod got recreated! That is exactly what we expected.</p><p><strong>Section IV: Information Regarding Pods</strong></p><p>I then wanted to learn a bit more about what happened on the newly-created pod, so I used the <strong>kubectl logs nginx-deployment-6f9664446b-nshnj </strong>to see what happened with the pod that I deleted</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/904/1*ychsq4ktK-k1z5FRsczC9Q.png" /></figure><p>Then I wanted to learn a bit more about the <strong>nginx-deployment </strong>and <strong>nginx-service</strong> resources that I created. The <strong>kubectl help &lt;command_name&gt;</strong> is the Kubernetes equivalent of the Linux man pages and was useful for learning what the <strong>kubectl describe </strong>command does.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*VPxbBNK3xwFb-D12c15Ryg.png" /></figure><p>Basically, the <strong>kubectl describe</strong> command prints a detailed description of a given resource. The first parameter is the resource’s <em>type</em> and the second parameter is the resource’s <em>name</em>. I used the <strong>kubectl describe deployment nginx-deployment </strong>and the <strong>kubectl describe service nginx-service </strong>commands appropriately</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/807/1*ndsa_RxajWtg_91TvNxU4w.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/651/1*PjpS7dPLikdPVXJWMG0jAA.png" /></figure><p>Remember why I created two different Kubernetes objects: <strong>nginx-deployment</strong> manages the actual application (creating and maintaining 3 Nginx pods, handling restarts and scaling), while <strong>nginx-service</strong> handles the networking layer (exposing those pods to external traffic via NodePort so you can actually access the web server from your browser). This separation of concerns is a core Kubernetes design pattern — deployments manage the “what runs” and services manage the “how to reach it.”</p><p>OK. Now for some additional fun. First, I used the <strong>kubectl get all </strong>command to display all the main Kubernetes resources in the current namespace in one view, including pods, services, deployments, and replica sets.It’s the go-to command for getting a snapshot of what’s running in your cluster without having to run separate <strong>kubectl get pods</strong>, <strong>kubectl get services</strong>, etc. commands individually.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/807/1*2F_G0DclRKHnOcDLKLsaHA.png" /></figure><p>Remember how I <a href="https://secnate.medium.com/homelab-9-containerization-with-docker-5b9777ebba2b">in the previous docker homelab</a> was able to execute commands inside a container using the <a href="https://docs.docker.com/reference/cli/docker/container/exec/"><strong>docker container exec</strong></a><strong> </strong>command? Kubernetes has a similar one for pods!</p><p>I used the <strong>kubectl exec -it pod/nginx-deployment-6f9664446b-8ft5k — /bin/bash</strong> command to open up a shell on the pod and to execute some commands</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/827/1*RQTog8MoXcsCsh9Ys6tYpA.png" /></figure><p>That was nice. Then I wanted to get some more metrics information regarding the Kubernetes pods.</p><p>To do that, I enabled the metrics server in minikube using the <strong>minikube addons enable metrics-server </strong>command and used the <strong>kubectl top pods </strong>and <strong>kubectl top nodes</strong> commands to see information regarding resource usage</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1002/1*y-0dcm9LCMiAPzm5dyS4qw.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/970/1*DGeKRbwZjOi6sKDEfV0xnw.png" /></figure><p>Pretty informative.</p><p><strong>Section V: AWS EKS Setup</strong></p><p>The time had come to work with Amazon Elastic Kubernetes Service (EKS). I already had the AWS CLI installed on my homelab workstation from a past homelab. I quickly installed <a href="https://github.com/eksctl-io/eksctl"><strong>eksctl</strong></a>, a simple CLI tool for creating clusters on EKS — Amazon’s new managed Kubernetes service for EC2. I fortunately had AWS credentials already configured on my homelab workstation from a past exercise, so I didn’t need to configure those with the <strong>aws configure </strong>command.</p><p>With everything set up, I got to work on creating an EKS cluster in the free tier with the following command: <strong>eksctl create cluster — name my-eks-cluster — region us-east-1 — nodegroup-name standard-workers — node-type t3.micro — nodes 2 — nodes-min 1 — nodes-max 2 — managed</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ZX3OAJlA-hluPeqPgWuXag.png" /></figure><p>Notice that the command has the <strong>–managed</strong> flag. It tells eksctl to create managed node groups, which means that it is AWS which handles updates, patches, scaling, and replacement of unhealthy nodes. Without this flag, cloud engineers have self-managed node groups where <strong>they are responsible</strong> for manually updating the AMI, handling node upgrades, and managing the EC2 instances — a total hassle. Managed node groups are generally best practice because they significantly reduce operational overhead while maintaining the desired Kubernetes functionality.</p><p>The AWS management website also confirms the AWS EKS cluster being made:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*UDn0mUXsw0ciqLmBNOexKw.png" /></figure><p>Then I used the <strong>kubectl config current-context </strong>command to confirm the cluster’s state. Notice that a Kubernetes context is a configuration that tells kubectl which cluster to talk to, which user credentials to use, and which namespace to work in. It basically is your “connection profile” that lets you easily switch between different clusters like Minikube, EKS, and AKS.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/960/1*7PaW-0NKbUhAu0PELAlKmg.png" /></figure><p>Then I used the <strong>kubectl get nodes</strong> to list all the nodes in the Kubernetes cluster and to get a summary of their status and information</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/844/1*gPlwri7yYKwMSY9s9SiHYw.png" /></figure><p><strong>Now for the wrinkle</strong></p><p>Creating the EKS cluster only provisions the infrastructure (the Kubernetes control plane and worker nodes) and it’s an empty cluster with no applications running yet. That’s why I needed to then run <strong>kubectl apply</strong> to actually deploy the Nginx application into this new EKS cluster, just like I previously did with Minikube. This is because the <strong>kubectl apply</strong> command is the main tool for declaratively managing Kubernetes resources — and it is used to create or update resources in a cluster to match a desired state defined in configuration files (YAML or JSON). The YAML files tell Kubernetes what containers to run and how to expose them, which doesn’t happen automatically when the cluster is created.</p><p>This is because Kubernetes follows a declarative model where the cluster infrastructure (nodes, networking, control plane) is <em>completely separate</em> from the applications that run on it.</p><p>When you create an EKS cluster, AWS provisions the “stage” (the Kubernetes platform), but you as the developer/operator must explicitly tell Kubernetes what “actors” (applications) to put on that stage and how they should behave. This separation gives full control over what runs where and allows for applications to be managed independently of infrastructure.</p><p>We got the theory out of the way, and now it was time to apply the same manifests. First, I did that with the <strong>kubectl apply -f .\nginx-service.yaml</strong> command</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1012/1*UyRrcydtLus5C_9V2kMjZg.png" /></figure><p>And the <strong>kubectl apply -f nginx-deployment.yaml </strong>command</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*lLaaHi7-Jq7ZibV_lkMXow.png" /></figure><p>Now to see what services were up and running:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/865/1*ggfJLWjsWmKuOXo8bENwMw.png" /></figure><p>Wonderful. Everything was properly working in AWS.</p><p><strong>Section VI: Azure AKS Setup</strong></p><p>I fortunately had Azure CLI installed on my homelab workstation from a past homelab and a prior Azure subscription. I quickly logged in using the <strong>az login </strong>command and selected my subscription.</p><p>Now it was time to do the same thing in Azure that I previously did on AWS. After drumrolling hard for 5 seconds on my desk, I got straight to work.</p><p>First, I created a resource group using the <strong>az group create — name myAKSResourceGroup — location eastus2</strong> command. I needed to create a resource group first because Azure requires every resource to belong to an organizational container that logically groups all related components (the AKS cluster, VMs, disks, networking). Having such a resource group makes it easy to manage costs and delete everything together (such as when the lab is finished — for example).</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Q9qCTf300LKmWu8KZn9RfA.png" /></figure><p>And then I created an Azure Kubernetes Service cluster using the following command: <strong>az aks create — resource-group myAKSResourceGroup — name myAKSCluster — node-count 2 — node-vm-size Standard_B2s — enable-addons monitoring — generate-ssh-keys</strong></p><p>The <strong>— node-vm-size Standard_B2s</strong> flag was used to specify smaller, budget-friendly VMs that kept costs low while still providing enough resources for my testing and learning needs. I included <strong>— enable-addons monitoring </strong>to get Azure’s built-in monitoring dashboards that gave visibility into my cluster’s health and performance metrics — without having to manually configure additional monitoring tools.</p><p>I then needed to download the access credentials for my Azure Kubernetes Service cluster. This is because creating the AKS cluster only operated in Azure. Downloading the credentials and automatically adding them to my computer’s local Kubernetes configuration file ensured that kubectl would know how to connect to and authenticate with my new AKS cluster.</p><p>To do this, I used the <strong>az aks get-credentials — resource-group myAKSResourceGroup — name myAKSCluster</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*gIUMjwIE6yrhe_HQBsEHwQ.png" /></figure><p>You may wonder why I didn’t take such a step with the AWS Kubernetes cluster I made. The answer is that the <strong>eksctl </strong>did the same thing and downloaded the credentials automatically for me as part of the cluster creation process (per <a href="https://docs.aws.amazon.com/eks/latest/eksctl/creating-and-managing-clusters.html">this</a> and <a href="https://docs.aws.amazon.com/eks/latest/userguide/getting-started-eksctl.html">this</a> page of AWS documentation). It was just that Azure required an extra manual step for this to be done.</p><p>Now to see what services were up and running, I used the <strong>kubectl config current-context </strong>and <strong>kubectl get nodes</strong> command:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*QL4cardKGOToOx-692aCbw.png" /></figure><p>Looks about right. Now it was time to deploy the <strong>nginx-deployment.yaml</strong> and the <strong>nginx-service.yaml</strong> files into Azure. I did this using the <strong>kubectl apply -f nginx-deployment.yaml </strong>and <strong>kubectl apply -f nginx-service.yaml</strong> commands:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*u_2e4g9tiCCIJCbblSt4FQ.png" /></figure><p>Remember that the <strong>nginx-deployment.yaml</strong> defined how to run the application (managing the Nginx pod replicas, handling automatic restarts, and maintaining desired state), while <strong>nginx-service.yaml</strong> defined how to access the application over the network (by creating the NodePort networking layer that exposed those pods to external traffic). This is deliberately done per Kubernetes’ design philosophy of decoupling application management from networking concerns.</p><p>The <strong>kubectl get all </strong>command then gave information regarding the nature of the deployment</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/845/1*2x6qUYozEgsdzYQYdAmwHw.png" /></figure><p>Looks nice.</p><p><strong>Section VII: Multi-Cloud Management</strong></p><p>I then used the <strong>kubectl config get-contexts</strong> command to get a sense of the multiple contexts that I have running on Kubernetes across Minikube, Azure’s AKS, and AWS’s EKS</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*9PCi1KgAxYH1PHJRk7kvKA.png" /></figure><p>I then used the <strong>kubectl config use-context minikube</strong> command to switch over to the <strong>minikube </strong>context</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1003/1*BA2ChDKnf9jpCbIAvG8D5A.png" /></figure><p>And then got some information regarding what was going on in it using the <strong>kubectl get nodes</strong> , <strong>kubectl config current-context</strong> , and <strong>kubectl cluster-info</strong> commands:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/959/1*Vq-AtjrfF5zhdMC48oQkPw.png" /></figure><p>Then I did the same for the Azure AKS service by switching into the <strong>myAKSCluster</strong> context with the <strong>kubectl config use-context myAKSCluster</strong> command and using the same commands that I previously used to get some more information regarding the context</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*a6H4AHOqn4yHXgjkBy2ZiA.png" /></figure><p>And I did the same for the AWS’ EKS service by switching into the appropriate context with the <strong>kubectl config use-context Administrator@my-eks-cluster.us-east-1.eksctl.io</strong> command — and using the same commands that I previously used to get some more information regarding the context</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*1xBVG2KDYsLg3MoCyEXdkA.png" /></figure><p>Next up, to do some scripting to show how this could be done in a more automated way! Since I was working with Kubernetes in the PowerShell command line windows, I created a new <strong>multicloud-deployment.ps1</strong> script file</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/803/1*gxQWz136PhvOApASvJGAmA.png" /></figure><p>And then executed it</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/940/1*EYd_KzZUiuXLXlRyDkH5fw.png" /></figure><p>Looks nice.</p><p>So let’s rehash. I kept Minikube running as my <strong>local development/testing environment</strong> alongside the cloud cluster. Minikube lets cloud engineers quickly test changes, experiment without incurred cloud-computing costs, and compare how the same Nginx deployment behaves across different platforms (local vs AWS vs Azure). Now you see that Kubernetes is portable and one can manage multiple clusters simultaneously by switching contexts with <strong>kubectl config use-context</strong> and with nice scripts — which is a critical skill for real-world multi-cloud or hybrid-cloud environments.</p><p><strong>Section VIII: Python Scripting</strong></p><p>And now it’s time for some non-monty Python! (See what I did there?)</p><p>I used the <strong>python -m pip install kubernetes</strong> command to install the <a href="https://github.com/kubernetes-client/python">Python Kubernetes library</a>. It is an official Python SDK that allows you to programmatically interact with any Kubernetes cluster using Python code instead of kubectl commands, enabling automation of cluster management tasks like listing pods, creating deployments, or monitoring resources through standard Python scripts.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*pxYbJus5CY28QBbq_1hIHg.png" /></figure><p>Now let’s type some if else. I made a <strong>list_pods.py</strong> Python file and then executed the script</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/693/1*RnU-uf5XLYGNfD6gm5NVeQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/865/1*970MJ9Y6pEtEhKiZZm0hXQ.png" /></figure><p>And looking at the output, we see that the desired <strong>Cluster</strong>, <strong>Namespace</strong>, <strong>Pod Name</strong>, <strong>Status</strong>, <strong>IP</strong>, <strong>Node</strong>, <strong>Created</strong> data was properly outputted</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*UGlKK_Tm-Z6k1sQMGBRvcg.png" /></figure><p><strong>Section IX: Cleanups</strong></p><p>I did all the stuff that I needed to do. Now it was time to delete the Kubernetes clusters from execution. This is especially important to do in the cloud so that we don’t incur charges from leftover cluster instances running.</p><p>First, on my local machine, I used the <strong>minikube delete</strong> command to halt the cluster on my local machine</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/822/1*pG-cgh8abtOHAZQxq-1l3g.png" /></figure><p>Next up, for AWS! I used the <strong>eksctl delete cluster — name my-eks-cluster — region us-east-1</strong> command to do this. For Azure, I used the <strong>az aks delete — resource-group myAKSResourceGroup — name myAKSCluster — yes — no-wait </strong>and <strong>az group delete — name myAKSResourceGroup — yes — no-wait</strong> commands to delete the Kubernetes clusters in those clouds as well.</p><p>This was quite the journey. I’ll see you in the next round!</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=ad0d53643c30" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Homelab #9: Containerization With Docker]]></title>
            <link>https://secnate.medium.com/homelab-9-containerization-with-docker-5b9777ebba2b?source=rss-f95ccfcb96af------2</link>
            <guid isPermaLink="false">https://medium.com/p/5b9777ebba2b</guid>
            <dc:creator><![CDATA[Nathan Pavlovsky]]></dc:creator>
            <pubDate>Fri, 12 Dec 2025 02:19:22 GMT</pubDate>
            <atom:updated>2025-12-12T02:19:22.868Z</atom:updated>
            <content:encoded><![CDATA[<p>Greetings my fellow readers — and welcome to the ninth episode of the homelab series! In this round, I learned Docker and containerization fundamentals hands-on, while also briefly practicing the Ansible skills that I first picked up in <a href="https://secnate.medium.com/homelab-4-ansible-for-server-automation-0890845c89c7">Homelab #4</a>.</p><p>Think of Docker containers like shipping containers for software — they package your application and everything it needs into a standardized box that can run anywhere, whether on your laptop, a server, or the cloud. This solves the age-old problem where code works perfectly on a developer’s machine but breaks in production, and makes deploying and scaling applications dramatically simpler and faster.</p><p><strong>Section I: Initial Preparation</strong></p><p>For this round, I picked up with the initially-made control and managed note Ubuntu virtual machines from <a href="https://secnate.medium.com/homelab-4-ansible-for-server-automation-0890845c89c7">Homelab #4</a> where I learned and practiced Ansible. Before digging too deep into the new project, remember that we had the website deployed on the managed node, and could confirm that it was up and running on the control node with the <strong>curl </strong><a href="http://192.168.56.214/"><strong>http://192.168.56.214</strong></a><strong> </strong>command?</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/929/1*i2WkgfOP1bBjdpAmT9bdZQ.png" /></figure><p>I wanted to check in Ansible whether my control node had the nginx server up and running, to take it down if such were the case (to minimize the risk of the website it is running accidentally interfering with the container we want to get deployed), and then to proceed onwards with the actual homelab.</p><p>To do this, this is where the notion of an Ansible <em>Ad-Hoc</em> command comes in. To put it simply, it is a one-liner that administrators can use to make tweaks or check statuses in situations that are one-offs or so rare that they do not require a formal playbook to be written, tested, and used. More information regarding Ansible <em>Ad-Hoc </em>commands can be found on the <a href="https://docs.ansible.com/projects/ansible/latest/command_guide/intro_adhoc.html"><em>Introduction to Ad Hoc Commands</em></a> documentation page.</p><p>Here, I wanted to check the status, so I quickly used an ad-hoc command in Ansible to check</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*LKfBghVCsyOUj5UqRLQcmQ.png" /></figure><p>It was up and running. Time for another ad-hoc command to stop it:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Z4CvHfxH2lI9nNPwrU75nw.png" /></figure><p>OK now it’s time for actual docker and containerization. Remember how in a previous homelab I spent hours writing Terraform code from scratch, only to be told by the pros on a tech slack chat that Terraform is treated like CSS in the industry: find good stuff, copy, paste, and tweak. I wanted to get through this homelab as efficiently as possible, so I asked them whether Ansible was treated the same way, and if so, where they go to get good Ansible snippets to work with.</p><p>They responded that Ansible is also treated that way, and referred me primarily to <a href="https://galaxy.ansible.com/ui/">Ansible Galaxy</a>, <a href="https://ansible.jeffgeerling.com/#collections">Jeff Geerling’s Ansible content</a> (which is very popular and also hosted on Ansible Galaxy), and <a href="https://github.com/cloudposse">Cloud Posse’s GitHub page</a>. Ansible Galaxy is the official community hub (galaxy.ansible.com) where people publish and download ready-made Ansible automation content so you don’t have to write everything from scratch. These include roles and collections:</p><ul><li>A role is a standalone, reusable package that does one specific job really well (e.g., install and configure Nginx, Docker, or PostgreSQL) — think of it as a single Lego brick you drop into your playbooks.</li><li>A collection is the newer, more powerful format: a namespaced bundle that can contain multiple roles (those “lego bricks”), custom modules, plugins, and more, all versioned together (e.g., community.docker or ansible.windows).</li></ul><p>They exist so homelabbers and pros alike can set up complex servers in minutes instead of hours, keep configurations consistent, and share or reuse the same battle-tested code across projects.</p><p>Now that we prepared appropriately both on the VM and in terms of learning a bit more about Ansible management theory, let’s move into the actual implementation steps.</p><p><strong>Section II: Preparing By Installing Appropriate Roles &amp; Collections</strong></p><p>On my control VM, I first used the <strong>mkdir ~/docker-homelab</strong> command. Then I used the <strong>ansible-galaxy role install geerlingguy.docker </strong>command to install the <a href="https://galaxy.ansible.com/ui/standalone/roles/geerlingguy/docker/documentation/">geerlingguy.docker</a> role. Per the website, it is a role — i.e. a pre-prepared “lego” of Ansible-specified action — for installing Docker on a Linux system. Digging into the <a href="https://github.com/geerlingguy/ansible-role-docker">GitHub repository</a> confirms that this is a pretty robust role, which works very well across <a href="https://github.com/geerlingguy/ansible-role-docker/tree/master/tasks">different Linux distribution types</a>.</p><p>The documentation also provided a friendly reminder that I needed to install the <strong>geerlingguy.pip</strong> role since</p><blockquote><em>Many users of this role wish to also use Ansible to then build Docker images and manage Docker containers on the server where Docker is installed. In this case, you can easily add in the docker Python library using the geerlingguy.pip role</em></blockquote><p>I thus installed the <a href="https://github.com/geerlingguy/ansible-role-pip"><strong>geerlingguy.pip</strong></a><em> </em>role with the <strong>ansible-galaxy role install geerlingguy.pip </strong>command. This role installs the pip package manager on Linux systems:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/773/1*MSeaXPA4MaeIABDeLxwifg.png" /></figure><p>Wonderful. I also took advantage of the opportunity to verify with the <strong>ansible-galaxy collection list | less</strong> that the <a href="https://docs.ansible.com/projects/ansible/latest/collections/community/docker/index.html"><strong>community.docker</strong></a> collection for working with docker is installed. The usage of the “<strong>| less</strong>” in the command was to ensure that I could see all the lines outputted to the screen — one screen at a time.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/562/1*ojyIXhrQ1wOYO4YEzt65MA.png" /></figure><p>Looks nice. Let’s get moving. I started by copying over the same inventory file from the <strong>ansible-homelab</strong> folder to the <strong>docker-homelab</strong> folder on the control VM. This is because this homelab exercise uses the same setup and managed node as the previously-completed ansible <a href="https://secnate.medium.com/homelab-4-ansible-for-server-automation-0890845c89c7">Homelab #4</a>, so the same inventory file should suffice.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/763/1*gR8dc6c_zVrN58eCGPjd6w.png" /></figure><p>I then created a <strong>files</strong> and <strong>playbooks </strong>subfolders with the <strong>mkdir</strong> command:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/574/1*ZfLtlSzR0kMcjkOvkwVfzw.png" /></figure><p><strong>Section III: Installing Docker With Ansible</strong></p><p>Time to install Docker on the managed VM. To do this, I went into the <strong>playbooks</strong> folder on the control VM and used the <strong>nano docker-setup.yml</strong> command to kick things off. I then got to work and wrote up the YAML playbook appropriately. I wrote a lot of explanatory comments for each line as I went through this learning process, so for this screenshot, I used the <strong>grep -v ‘#’ docker-setup.yml</strong> command to show only the non-commented lines which make an impact when executed:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/735/1*6XM_ll95mPC6yNxgERKCWQ.png" /></figure><p>I then troubleshot the playbook, dealt with some syntax errors, indentation issues, and then executed the playbook successfully.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*_BYjjo0NoLvFUQS2nxV6Tg.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*wcZBEzX0F6GMz5AHoQZ50w.png" /></figure><p>And confirmed on the managed vm that the two were installed and that docker is up and running:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/516/1*q5Cb_DnF-6smq3wyzDio2A.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*OBfJYaDKGVKZkf8ZZXPJ1A.png" /></figure><p>Excellent.</p><p><strong>Section IV: Node.JS Application With Docker</strong></p><p>So I deployed Docker on the Ubuntu VM with ansible. It works. Afterwards, I switched to working hands-on with it on the server because that was the real learning goal for me right now. <strong>Docker</strong> is a tool that lets you package your app (code + everything it needs to run) into a tiny, portable box called a <strong>container</strong>.</p><p>This container works exactly the same on your laptop, a friend’s computer, or any cloud server — no more “it works on my machine” problems.</p><p>Now I needed to make a sample <a href="http://node.js/">Node.JS</a> application. On the control node, I created a folder for this homelab and then put inside it another folder which I would put the sample Node.JS application.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/469/1*TYh1uiCXb-O0u9aJoa9BnQ.png" /></figure><p>I then went into the folder and created a new <strong>server.js</strong> with a sample Node.JS application, which I found on the Node.JS website. The code looks like this:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/584/1*9l_vuj7Tts8ak9oWY27tIA.png" /></figure><p>Wonderful. Then I needed to make a <strong>package.json </strong>file, which I did by using the <strong>npm init</strong> command.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/650/1*7H0zTOg6G5gcPbVNvnGg6A.png" /></figure><p>Then I used <strong>npm install</strong> to create a <strong>package-lock.json</strong> file.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/461/1*QChie5elaXjfYs4OuKOgEw.png" /></figure><p><strong>package.json</strong> is the manifest file that declares your Node.js project’s metadata, dependencies, scripts, and configuration;<strong> package-lock.json</strong> is an auto-generated file that locks the exact versions of every installed package (including nested dependencies) to ensure consistent, reproducible installs across machines. Together, they make your project portable and prevent “it works on my machine” issues.</p><p>I then created a Docker file per <a href="https://docs.docker.com/get-started/workshop/02_our_app/">a sample tutorial from Docker’s website</a>.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/289/1*vOXd99zc6m__gT7ODsb3QQ.png" /></figure><p>A Dockerfile is a simple text file that contains a list of instructions telling Docker how to build your app into a ready-to-run container image — no manual setup needed, and it works the same way on your laptop, a server, or in the cloud.</p><p>Then I made a <strong>.dockerignore</strong> file, which is a file placed at the root folder of a Docker project which specifies which files and directories to exclude from the build context.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/619/1*-yVhPyguTpmmWgaAmGhORg.png" /></figure><p>Excellent. Then I used the command <strong>sudo docker build -t my-node-app .</strong> to build the container</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*fbAqU_RFv6M_P73qYBGj_A.png" /></figure><p>Then I used the <strong>sudo docker run -d -p 3000:3000 –name my-node-app-container my-node-app</strong> to launch the application</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*3wUacSYAzgLN54EqP6IELQ.png" /></figure><p>There were some troubleshooting fixings that I had to do to get to this point, including fixing a line or two in the <strong>dockerfile</strong>, but bottom-line, I got it working!</p><p><strong>Section V: Flask Application With Docker</strong></p><p>Time to make a Flask application into a Docker container. To put it simply, Flask is a popular, lightweight web application development framework in Python. I started by making a <strong>flask_app</strong> folder, installing Flask using the <strong>pip install Flask </strong>command, and then updating the <strong>PATH </strong>variable to ensure that the flask folder installed is seen.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*bLNe89IQ_grX_4kZDoU1Sw.png" /></figure><p>Then I created <strong>hello.py</strong> and filled it out per the text of a nice <a href="https://medium.com/analytics-vidhya/flask-hello-world-47ca1ccd0d30">Medium tutorial</a> I found.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/505/1*tkGdjnEfsn3Ke7vpLTpeTQ.png" /></figure><p>Then I created a <strong>requirements.txt </strong>file. It is a file which Python projects use to list its dependencies and versions. For this case, I kept things really simple with two lines:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/587/1*G5tU7PVoYkokqasXZalPIQ.png" /></figure><p>Then I built the dockerfile, the container, and launched execution:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*JVaj2eBk7a7uarH7YSJZVA.png" /></figure><p>During my building process I noticed an error. I put down <strong>app.py </strong>into the dockerfile originally, which led to a container execution error. I caught the mistake and rewrote the line in the dockerfile’s command to run the Flask application at the <strong>hello.py</strong> file.</p><p><strong>Section VI: Working With Docker Networking</strong></p><p>The next step is to try out Docker Networking. This allows containers to communicate with each other and the outside world — including non-Docker network services. There is a good website providing<a href="https://spacelift.io/blog/docker-networking"> an introduction to Docker networking</a> but the concepts are very similar to managing networking in VMs. For example, a few network types include:</p><ul><li><strong>Bridge</strong> (default) — Containers on same host can communicate</li><li><strong>Host</strong> — Container uses host’s network directly (no isolation)</li><li><strong>None</strong> — No networking</li><li><strong>Custom Bridge</strong> — Like bridge, but with more control</li></ul><p>Let’s start. First, I wanted to see the default networks with the <strong>sudo docker network ls </strong>command:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/583/1*vdi0eDEoQYvnJVsbLA4LBA.png" /></figure><p>We got the following default networks:</p><ul><li><strong>bridge</strong>: The default network driver, which creates a private internal network for containers to communicate with each other and the host.</li><li><strong>host</strong>: This driver removes network isolation between the container and the host, allowing the container to use the host’s network stack directly.</li><li><strong>none</strong>: This driver provides complete network isolation, meaning the container has no network interfaces.</li></ul><p>After that, I used the <strong>docker network inspect bridge</strong> command to learn more regarding the default bridge network</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/848/1*9c4uEBZuFiTOUHm0W2VaJw.png" /></figure><p>Per the screenshot, we see that the <strong>my-flask-app-container</strong> is attached to the bridge network for communicating with the VM machine that the container is running on.</p><p>Next up was to create a custom bridge network. Google tells me that there are multiple uses for such. The main one in my opinion though is that custom networks provide better isolation than the default bridge network, preventing containers from different projects or applications from interfering with each other’s network traffic. I used the <strong>sudo docker network create –driver bridge –subnet 172.20.0.0/16 –gateway 172.20.0.1 homelab-network</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Zog9PB3qYX2EvoEDkQavxA.png" /></figure><p>Then I verified and inspected the newly created network with the <strong>sudo docker network ls </strong>and <strong>sudo docker network inspect homelab-network </strong>commands</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/786/1*eHcSpTlHSJY1k8QNwEiw7g.png" /></figure><p>Then I launched the flask app connected to the custom <strong>homelab-network</strong> using the <strong>sudo docker run -d –name flask-networked –network homelab-network -p 5001:5000 –restart unless-stopped my-flask-app </strong>command. Notice the usage of the <strong>-p 5001:5000</strong> command, which maps the host port #5001 to the container’s port #5000. I then confirmed Flask is up and running:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*2b3BV9aIS7eM5tbYR3tpZA.png" /></figure><p>Then I used the <strong>sudo docker run -d –name nginx-proxy –network homelab-network -p 8080:80 –restart unless-stopped nginx:alpine</strong> command to run Nginx as a reverse proxy.Then I used the <strong>sudo docker inspect nginx-proxy | head -n 25 </strong>command to get information regarding its state:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*3CEFKW92Kok4yoVWIOPP1Q.png" /></figure><p>Now I needed to configure the Nginx container to proxy requests to flaskcat. Per the <a href="https://nginx.org/en/docs/beginners_guide.html">Nginx documentation</a>, I needed to create a <strong>nginx.conf</strong> file and then pass it on into the Nginx container to get it working properly. To explain simply, a <strong>nginx.conf </strong>file is used to specify how NGINX should handle requests for different locations within a server.</p><p>I quickly made it on the VM using the <strong>nano nginx.conf</strong> command. You can see what it looks like here.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/722/1*6wKBgXHgW4iVZe30aDpgSQ.png" /></figure><p>Next up, to get it into the actual container. The following documentation articles <a href="https://www.digitalocean.com/community/tutorials/understanding-the-nginx-configuration-file-structure-and-configuration-contexts">here</a>, <a href="https://docs.nginx.com/nginx/admin-guide/basic-functionality/managing-configuration-files/">here</a>, and <a href="https://medium.com/@mathur.danduprolu/understanding-nginx-architecture-and-configuration-basics-part-2-7-c4cab291c9b8">here </a>indicate that configuring the Nginx server is an art with multiple layers of blocks and contexts. In this case, we just wanted to affect the server context, simply… so I dug around on the container to see its internal file structure. The <a href="https://docs.docker.com/reference/cli/docker/container/exec/"><strong>docker container exec</strong></a> command allows one to execute commands within the container — including commands to see information regarding a file’s structure. I checked a few things and found the container’s original value of the file I need to replace the server context with — using the <strong>sudo docker exec nginx-proxy cat /etc/nginx/conf.d/default.conf</strong> command:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/904/1*5Wmhc5rOViQCF2UQKXEsKA.png" /></figure><p>Let’s get the actual file that I made into the container. This was done using the <strong>sudo docker cp nginx.conf nginx-proxy:/etc/nginx/conf.d/default.conf</strong> command</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/975/1*aGmkBkCtp9GqsYP26nbvyA.png" /></figure><p>Then I used the <strong>sudo docker restart nginx-proxy</strong> command to restart the container — and then used the <strong>curl </strong><a href="http://localhost:8080/"><strong>http://localhost:8080</strong></a><strong> </strong>command to see if I could access the Flask app through Nginx.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/610/1*DyVFWZZgIF9nHe6RmzsQvw.png" /></figure><p>It worked!</p><p>This is not all. Let’s demonstrate network isolation for Docker containers. What I did was to create a second network using the <strong>sudo docker network create –driver bridge –subnet 172.21.0.0/16 isolated-network</strong> command</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*cQKvUt-aN5_bO9CYKPasBA.png" /></figure><p>And then ran the nginx container on it using the <strong>sudo docker run -d –name isolated-app –network isolated-network nginx:alpine</strong> command</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*aX_XPA0eNXYCEAeXFFGUQA.png" /></figure><p>Wonderful. Now for the demonstration. I used the <a href="https://docs.docker.com/reference/cli/docker/container/exec/">docker in-container execution capabilities</a> to do this test. I originally tried to use the <strong>ping </strong>and then the <strong>curl </strong>commands from inside the <strong>flask-networked</strong> container to reach out to the <strong>isolated-app</strong> but they both were not found as executables inside the container. Research indicated that the nginx — and other official Docker container images — are deliberately lean and exclude “non-essential” tools and configurations both for size and security reasons. Finally, I came across a solution to do this demonstration: using the <strong>sudo docker exec flask-networked python -c “import socket; socket.gethostbyname(‘isolated-app’)”</strong> command.</p><p>As expected, we got an error in name resolution!</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*s7Rp5N5Xs8OHnyjlxvIJeA.png" /></figure><p>Last step. I connected the <strong>flask-networked</strong> container to the <strong>isolated-network </strong>using the <strong>sudo docker network connect isolated-network flask-networked</strong> command and then used the <strong>sudo docker exec flask-networked python -c “import socket; print(socket.gethostbyname(‘isolated-app’));” </strong>command to confirm connectivity. Notice this time it worked!</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*MmbAf0Cfvr7emkJbAGuq5g.png" /></figure><p>Time to do some cleanups! First I stopped the containers and deleted them</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*j2cvnmxYkTmsGBjWHCeYYg.png" /></figure><p>And then after that, I used the <strong>sudo docker network rm </strong>command to tear down the networks appropriately. Then I confirmed that everything is restored to the original clean slate using the <strong>sudo docker network ls </strong>and <strong>sudo docker ps -a </strong>commands.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/588/1*Z4aKBLAln_Ur-4z2yeg5Yg.png" /></figure><p>We’ve been on quite a networking journey. Let’s briefly rehash a portion of the networking concepts learned before we proceed into the next round. The following list is not all-inclusive:</p><ul><li><strong>DNS Resolution:</strong> Containers on <strong>custom networks</strong> can reach each other by name, with Docker providing automatic DNS resolution. For example <strong>flask-networked </strong>→ 172.20.0.2<br>Per <a href="https://docs.docker.com/engine/network/drivers/bridge/#:~:text=When%20you%20start%20Docker%2C%20a,other%20by%20name%20or%20alias.">Docker documentation</a>, <em>default bridge networks</em> do not have automatic DNS resolution. Containers on default bridge networks can only access each other through IP addresses.</li><li><strong>Network Isolation: </strong>Containers on different networks can’t communicate. <br>Also, user-defined bridge networks provide better isolation by creating separate, scoped network segments for groups of containers and preventing unrelated services from communicating by default. This reduces security risks, improves control, and prevents network congestion. This is unlike the default bridge network — which connects all containers.</li><li><strong>IP Ranges: </strong>Custom networks also have the bonus of allowing for custom IP ranges. Meanwhile the default Docker bridge network automatically assigns IP addresses from a pre-defined range (typically 172.17.0.0/16).</li><li><strong>Multi-Network Containers: </strong>A container can be on multiple networks, just like a regular physical host can be connected to multiple ethernet networks via cable!</li><li><strong>Port Mapping: </strong>Port mapping (or port forwarding) in Docker exposes a container’s internal port to a port on the host machine, allowing outside traffic to reach services running inside the container. It creates a bridge between the container’s private network and the host’s public-facing network.</li></ul><p>And finally, for some of the reasons mentioned — using custom networks in Docker is generally considered to be best practice!</p><p><strong>Section VI: Pushing Container Image to Docker Hub</strong></p><p>In this section, I learned about Docker Hub. Docker Hub is the world’s largest container registry — a public repository where developers share pre-built images like nginx, python, or postgres, letting you pull production-ready software in seconds instead of spending hours installing and configuring from scratch. It’s like GitHub for containers: you can use official images maintained by companies, share your own images publicly, or store private images for your team.</p><p>I created an account in Docker Hub, and then logged into it on my machine.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/986/1*PKAkumF9VmI0fRW_jeULZQ.png" /></figure><p>Then I used the <strong>sudo docker build -t my-flask-app . </strong>command to make the container image.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Ed0QYbpFJN_3NfUHAVoPkA.png" /></figure><p>And then after that, used the <strong>sudo docker tag my-flask-app nathanpavl/my-flask-app:v1.0</strong> to tag the image. Think of it as labeling the container image as being of such and such version or release information. Then I pushed it to docker using the <strong>sudo docker push nathanpavl/my-flask-app:v1.0 </strong>command</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/870/1*sDTVhZVjLV7MKe_rmhb8SQ.png" /></figure><p>Opening the Hub page on my docker website, it’s confirmed: I pushed the container to Docker successfully!</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*_HY7aa95iZZwiPE1_gcJlA.png" /></figure><p><strong>Section VII: Docker Image Optimization &amp; Multi-Stage Builds</strong></p><p>When we build Docker containers, we don’t start from zeroes and ones — we start from a base container image of… <em>something</em>… that we then use as a starting point to build our final application container image from. First you get a python image, then you do some configurations, then you add package this and package that. Think of it as a multi-layer cake that <strong>you </strong>will build on top of a provided cake base board.</p><p>There is a nice <a href="https://stackoverflow.com/questions/31222377/what-are-docker-image-layers">StackOverflow post</a> that I found with a fantastic (its main) explanation by a user by the name of <em>David Castillo</em> but basically, every <strong>RUN</strong>, <strong>COPY</strong>, or <strong>FROM </strong>in your Dockerfile creates a new layer. Each layer is extra memory and consumption — and is cached separately. If something changes — only the relevant layer gets updated. Docker also has <a href="https://docs.docker.com/get-started/docker-concepts/building-images/understanding-image-layers/">a nice piece on its website</a> as well explaining this. Bottom-line — the more layers a container has, the larger the final container image memory size will be.</p><p>You can for example see all the layers of <strong>my-flask-app</strong> using the <strong>sudo docker history my-flask-app</strong> command.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/926/1*f3WcAJQq-v8R6LEQXKfW9g.png" /></figure><p>The layering mechanism is definitely useful but needs to be thoughtfully considered. Containers managed unwisely will result in unnecessary packages that bloat the image, security risks (more packages = more vulns to handle &amp; manage), slower build times, extended download and launch times. It… doesn’t take the cake. (See what I did there?)</p><p>Time to demonstrate multi-stage docker building, so that we can see how this works. Think of multi-docker stage building as being in a kitchen, you whip the final product up on a messy countertop with the ingredients, and then pass the desired outcome to the final customer — without any of the leftover ingredients and soiled utensils.</p><p>I created a new docker file called <strong>Dockerfile.multistage</strong>. The following screenshot’s comments explain how the multi-stage process works</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/809/1*QIAHAWunIlUraNirR5RvcQ.png" /></figure><p>Then I built it using the <strong>sudo docker build -f Dockerfile.multistage -t my-slim-flask-app .</strong> command and got it running using the <strong>sudo docker run -d -p 5000:5000 –name my-slim-flask-app-container my-slim-flask-app</strong> command</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*bbElzftjxNunOhvD1SpAEQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*nYunt9kNDif5k7tgJTsyMw.png" /></figure><p>And confirmed it works:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/609/1*uWiPViJiN483arRxjw7eJQ.png" /></figure><p>The <strong>sudo docker history my-slim-flask-app </strong>command revealed that it has 16 layers instead of the original container’s 21 layers. This is precisely what I was going for</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/984/1*Vy5Wzik5tcEH7yhDuGcdMA.png" /></figure><p>I now reached an interesting wrinkle: the <strong>sudo docker images &lt;container_name&gt;:latest</strong> <strong>–format “Size: {{ .Size }}” </strong>command showed… that both had a size of 195 MB</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/966/1*vMPzNT6e2ht456iA7t6wKw.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/922/1*FznNXZaewLL6lzcjHuwNYQ.png" /></figure><p>This is actually expected and illustrates an important point. Multi-stage builds help with size savings when you (1) install build tools like the gcc compiler, make, etc. (2) compile code or (3) download large dependencies temporarily. Remember the cake making analogy? These are intermediary ingredients for making the final executing program that we do not want in the final container. Since Flask and Python are pure script packages with no compilation needed, there was nothing to leave behind!</p><p>Let’s do one more illustrative example to wrap up this multi-stage build section and the overall homelab. I created a new sub-directory under the <strong>homelab_9</strong> folder titled <strong>bulk_multistage_build</strong> and got to work. In this case we will be building another Flask app, but we will have some build tools that we want to install. This is actually realistic for the case of a scripted Python application since some — yes — Python libraries like NumPy are heavily C-based and need those compiler tools for making the final image. I started by making the <strong>requirements.txt </strong>file:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/545/1*rg4nEKyq8gRvTwzAengy_A.png" /></figure><p>Then I created the <strong>Dockerfile.heavy</strong> file, where I build the cake in one fell swoop:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/667/1*RYzWZb_nQ2XPMblGFoILrQ.png" /></figure><p>Notice that here we install build tools that could be used for compiling NumPy or another such C-based library. Then we create a <strong>Dockerfile.lighter</strong> file where the cake is made in steps:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/566/1*dVhD-G79EmlgiwbBFr11Jg.png" /></figure><p>Then I quickly made the <strong>app.py</strong> file</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/599/1*1iaVMdF7ZbCIAtzGCFBozg.png" /></figure><p>I then quickly built the containers for the heavy and lighter docker files</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*0OiyOB44GH2oGak5QVwNqw.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*AFDWTWhEO7DiMcyIuBHO2w.png" /></figure><p>And checked the history of the two:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/919/1*T5sN-qymqDfCi81NPQ2h3w.png" /></figure><p>And they were up and running!</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/964/1*pHH70gQNvp2ONATpoKm3UQ.png" /></figure><p>Notice that the heavier container has 20 layers and the lighter one has only 16 — there <strong>is </strong>a difference. And the size of the overall containers matters too: <strong>1.75 GB </strong>versus <strong>274 MB</strong>. That is an approximately 84% reduction — pretty sizable!</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*SNP7er2gY9aq7K0m7S4dgw.png" /></figure><p>So now you see why multi-stage builds are important!</p><p>This was quite the journey, and a very involved homelab. I learned a lot about how containers and Docker work, hopefully it also taught you something too. Thank you for reading — and I will see you in the next one!</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=5b9777ebba2b" width="1" height="1" alt="">]]></content:encoded>
        </item>
    </channel>
</rss>