<?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 Lumenyx on Medium]]></title>
        <description><![CDATA[Stories by Lumenyx on Medium]]></description>
        <link>https://medium.com/@lumenyx?source=rss-322c8ff982a8------2</link>
        <image>
            <url>https://cdn-images-1.medium.com/fit/c/150/150/1*sPvXiUKiSNQbY3e2NcxSxw.png</url>
            <title>Stories by Lumenyx on Medium</title>
            <link>https://medium.com/@lumenyx?source=rss-322c8ff982a8------2</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Thu, 05 Nov 2026 03:41:47 GMT</lastBuildDate>
        <atom:link href="https://medium.com/@lumenyx/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[Beyond the Default VPC: Creating a Custom CTF Environment with Terraform]]></title>
            <link>https://medium.com/@lumenyx/beyond-the-default-vpc-creating-a-custom-ctf-environment-with-terraform-633a64394a96?source=rss-322c8ff982a8------2</link>
            <guid isPermaLink="false">https://medium.com/p/633a64394a96</guid>
            <category><![CDATA[aws]]></category>
            <category><![CDATA[ctf]]></category>
            <dc:creator><![CDATA[Lumenyx]]></dc:creator>
            <pubDate>Mon, 02 Nov 2026 07:43:38 GMT</pubDate>
            <atom:updated>2026-11-02T07:43:38.096Z</atom:updated>
            <content:encoded><![CDATA[<p>I have been asked by a penetration tester friend of mine to help set up the infrastructure for a Capture the Flag (CTF) game.</p><p>There will be an intentionally vulnerable server which is hosted in the cloud which he wants his friends to attempt to hack into.</p><p>Rather than using the default VPC I thought this would be a good time to learn more about provisioning VPCs, route tables, subnets by using Terraform instead.</p><p>Users will use WireGuard to VPN into the public subnet, and from there they will be able to access the target (10.0.0.10).</p><p>Below is a diagram of what I ended up creating:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/713/1*YvyputN1tTNhDfk32izK0A.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*idGaGhZhrkiUU5fNuGrOwA.png" /><figcaption>terraform graph | dot -Tsvg &gt; graph.svg</figcaption></figure><h3>TLDR — THE FINISHED CONFIGURATION FILES</h3><p>If you just want the final configuration files, here they are:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*JdxlKipthjzx1bMxzYlazQ.png" /></figure><p><strong>PROVIDERS.TF:</strong></p><pre>provider &quot;aws&quot; {<br>        region = var.aws_region<br>}</pre><p><strong>VARIABLES.TF:</strong></p><pre>variable &quot;aws_region&quot; {<br>        default = &quot;ap-southeast-2&quot;<br>        description = &quot;The AWS region were our resources will reside.&quot;<br>}<br><br>variable &quot;instance_type&quot; {<br>        default = &quot;t2.micro&quot;<br>        description = &quot;The instance type of the EC2 instance.&quot;<br>}<br><br>variable &quot;ami_id&quot; {<br>        default = &quot;ami-0ba8d27d35e9915fb&quot;<br>        description = &quot;The AMI ID of the EC2 insance.&quot;<br>}<br><br>variable &quot;az&quot; {<br>        default = &quot;ap-southeast-2a&quot;<br>}<br><br>variable &quot;my_public_key&quot; {<br>        description = &quot;The public key which will be imported into aws_instance.ctf_EC2_vpn&quot;<br>}<br><br>variable &quot;your_public_ip_address&quot; {<br>        description = &quot;Your actual public IP address. Eg) 1.2.3.4/32 &quot;<br>}<br><br>variable &quot;friends_public_key&quot; {<br>        description = &quot;Enter your friend&#39;s public key here: &quot;</pre><ul><li>When you run terraform apply it will prompt you for your public key as well as your public IP address. This will be used to restrict SSH access to only your IP address.</li></ul><p><strong>VERSIONS.TF:</strong></p><pre>terraform {<br>        required_version = &quot;&gt;= 0.12&quot;<br><br>        required_providers {<br>                aws = {<br>                        source = &quot;hashicorp/aws&quot;<br>                        version = &quot;~&gt; 5.0&quot;<br>                }<br>        }<br>}</pre><p><strong>MAIN.TF:</strong></p><pre>## Create the VPC ##<br><br>resource &quot;aws_vpc&quot; &quot;ctf_vpc&quot; {<br>  cidr_block           = &quot;10.0.0.0/16&quot;<br>  enable_dns_support   = true<br>  enable_dns_hostnames = true<br>  tags = {<br>    Name = &quot;ctf_vpc&quot;<br>  }<br>}<br><br>## Create the private subnet ##<br><br>resource &quot;aws_subnet&quot; &quot;ctf_private_subnet&quot; {<br>  vpc_id            = aws_vpc.ctf_vpc.id<br>  cidr_block        = &quot;10.0.0.0/24&quot;<br>  availability_zone = var.az<br>  tags = {<br>    Name = &quot;ctf_private_subnet&quot;<br>  }<br>}<br><br>## Now to create the security group for the PRIVATE subnet ##<br><br>resource &quot;aws_security_group&quot; &quot;ctf_sg_private_subnet&quot; {<br>  name        = &quot;sg_private_subnet&quot;<br>  description = &quot;Allow access from the public subnet only.&quot;<br>  vpc_id      = aws_vpc.ctf_vpc.id<br>  tags = {<br>    Name = &quot;ctf_sg_private_subnet&quot;<br>  }<br>}<br><br>## Now to allow SSH from the private subnet ##<br><br>resource &quot;aws_vpc_security_group_ingress_rule&quot; &quot;Allow_access_from_public_subnet&quot; {<br>  security_group_id = aws_security_group.ctf_sg_private_subnet.id<br>  cidr_ipv4         = &quot;10.0.1.0/24&quot; ## The source IPv4 range ##<br>  from_port         = 22<br>  to_port           = 22<br>  ip_protocol       = &quot;tcp&quot;<br>}<br><br>## Allow ICMP traffic to the PRIVATE subnet ##<br><br>resource &quot;aws_vpc_security_group_ingress_rule&quot; &quot;Allow_ICMP&quot; {<br>  security_group_id = aws_security_group.ctf_sg_private_subnet.id<br>  cidr_ipv4         = &quot;10.0.1.0/24&quot; ## The source IPv4 range ##<br>  from_port         = -1<br>  to_port           = -1<br>  ip_protocol       = &quot;icmp&quot;<br>}<br><br>## Allow all egress out of the private subnet ##<br><br>resource &quot;aws_vpc_security_group_egress_rule&quot; &quot;Allow_all_outbound_from_private_subnet&quot; {<br>  security_group_id = aws_security_group.ctf_sg_private_subnet.id<br>  cidr_ipv4         = &quot;0.0.0.0/0&quot; ## The destination IPv4 range ##<br>  ip_protocol       = -1<br>}<br><br>## Create the PUBLIC subnet ##<br><br>resource &quot;aws_subnet&quot; &quot;ctf_public_subnet&quot; {<br>  vpc_id                  = aws_vpc.ctf_vpc.id<br>  cidr_block              = &quot;10.0.1.0/24&quot;<br>  availability_zone       = var.az<br>  map_public_ip_on_launch = true ## Assigns a public IP address on instance launch<br>  tags = {<br>    Name = &quot;ctf_public_subnet&quot;<br>  }<br>}<br><br>## Now to create the security group for the PUBLIC subnet ##<br><br>resource &quot;aws_security_group&quot; &quot;ctf_sg_public_subnet&quot; {<br>  name        = &quot;sg_public_subnet&quot;<br>  description = &quot;Allow VPN access from everywhere.&quot;<br>  vpc_id      = aws_vpc.ctf_vpc.id<br>  tags = {<br>    Name = &quot;ctf_sg_public_subnet&quot;<br>  }<br>}<br><br>## Add rule to allow connections from 0.0.0.0/0 into UDP 51832 ##<br><br>resource &quot;aws_vpc_security_group_ingress_rule&quot; &quot;Allow_VPN&quot; {<br>  security_group_id = aws_security_group.ctf_sg_public_subnet.id<br>  cidr_ipv4         = &quot;0.0.0.0/0&quot;<br>  from_port         = 51832<br>  to_port           = 51832<br>  ip_protocol       = &quot;udp&quot;<br>}<br><br>## Add rule to allow connections from 0.0.0.0/0 into TCP 22 ##<br>## Alternatively you can change cidr_ipv4 to be your public IP address ##<br><br>resource &quot;aws_vpc_security_group_ingress_rule&quot; &quot;Allow_SSH&quot; {<br>  security_group_id = aws_security_group.ctf_sg_public_subnet.id<br>  cidr_ipv4         = var.your_public_ip_address<br>  from_port         = 22<br>  to_port           = 22<br>  ip_protocol       = &quot;tcp&quot;<br>}<br><br>resource &quot;aws_vpc_security_group_egress_rule&quot; &quot;Allow_all_outbound&quot; {<br>  ip_protocol       = &quot;-1&quot; ## All protocols ##<br>  security_group_id = aws_security_group.ctf_sg_public_subnet.id<br>  cidr_ipv4         = &quot;0.0.0.0/0&quot;<br>  description       = &quot;Allow all outbound traffic&quot;<br>}<br><br>## Now to create the route table for the PRIVATE subnet ##<br><br>resource &quot;aws_route_table&quot; &quot;ctf_rtb_private_subnet&quot; {<br>  vpc_id = aws_vpc.ctf_vpc.id<br>  route {<br>    #   cidr_block = &quot;10.0.0.0/16&quot;<br>    #   gateway_id = &quot;local&quot; ##  this as a local route is already created by default<br>    cidr_block     = &quot;0.0.0.0/0&quot;<br>    nat_gateway_id = aws_nat_gateway.ctf_nat_gw.id<br>  }<br>  tags = {<br>    Name = &quot;ctf_rtb_private_subnet&quot;<br>  }<br>}<br><br>## Now we have to associate the routing table wuth the private subnet ##<br><br>resource &quot;aws_route_table_association&quot; &quot;ctf_rtb_association_private_subnet&quot; {<br>  route_table_id = aws_route_table.ctf_rtb_private_subnet.id<br>  subnet_id      = aws_subnet.ctf_private_subnet.id<br>}<br><br>## Create an Internet Gateway ##<br><br>resource &quot;aws_internet_gateway&quot; &quot;ctf_igw&quot; {<br>  vpc_id = aws_vpc.ctf_vpc.id<br>  tags = {<br>    Name = &quot;ctf_igw&quot;<br>  }<br>}<br><br>## Now to create the route table for the PUBLIC subnet ##<br><br>resource &quot;aws_route_table&quot; &quot;ctf_rtb_public_subnet&quot; {<br>  vpc_id = aws_vpc.ctf_vpc.id<br>  route {<br>    cidr_block = &quot;0.0.0.0/0&quot;<br>    gateway_id = aws_internet_gateway.ctf_igw.id<br><br>  }<br>  tags = {<br>    Name = &quot;ctf_rtb_public_subnet&quot;<br>  }<br>}<br><br>## Now we have to associate the routing table with the public subnet ##<br><br>resource &quot;aws_route_table_association&quot; &quot;ctf_rtb_association_public_subnet&quot; {<br>  route_table_id = aws_route_table.ctf_rtb_public_subnet.id<br>  subnet_id      = aws_subnet.ctf_public_subnet.id<br>}<br><br>## Now to create the NACL for the private subnet ##<br><br>## Commented this out as this is too restrictive for what the CTF needs ##<br><br>resource &quot;aws_network_acl&quot; &quot;ctf_nacl_private_subnet&quot; {<br>  vpc_id     = aws_vpc.ctf_vpc.id<br>  subnet_ids = [aws_subnet.ctf_private_subnet.id] # subnet_ids expects a list, therefore must be in square brackets<br>  #    egress {<br>  #        protocol   = &quot;-1&quot;<br>  #        rule_no    = 100<br>  #        action     = &quot;allow&quot;<br>  #        cidr_block = &quot;10.0.1.0/24&quot;<br>  #       from_port  = 0<br>  #        to_port    = 0<br>  #   }<br>  #   ingress {<br>  #       protocol   = &quot;tcp&quot;<br>  #       rule_no    = 100<br>  #       action     = &quot;allow&quot;<br>  #       cidr_block = &quot;10.0.1.0/24&quot;<br>  #       from_port  = 22<br>  #       to_port    = 22<br>  #   }<br>  #   ingress {  ## Allow ICMP Echo Reply ##<br>  #       protocol = &quot;icmp&quot;<br>  #       rule_no = 101<br>  #       action = &quot;allow&quot;<br>  #       cidr_block = &quot;10.0.1.0/24&quot;<br>  #       from_port = 0<br>  #       to_port = 0<br>  #   }<br>  #   ingress {  ## Allow ICMP Echo Response ##<br>  #       protocol = &quot;icmp&quot;<br>  #       rule_no = 102<br>  #       action = &quot;allow&quot;<br>  #       cidr_block = &quot;10.0.1.0/24&quot;<br>  #       from_port = 0<br>  #       to_port = 0<br>  #       icmp_type = 8<br>  #   }<br><br>  ingress {<br>    protocol   = -1<br>    rule_no    = 100<br>    action     = &quot;allow&quot;<br>    cidr_block = &quot;0.0.0.0/0&quot;<br>    from_port  = 0<br>    to_port    = 0<br>  }<br>  egress {<br>    protocol   = -1<br>    rule_no    = 100<br>    action     = &quot;allow&quot;<br>    cidr_block = &quot;0.0.0.0/0&quot;<br>    from_port  = 0<br>    to_port    = 0<br>  }<br>  tags = {<br>    Name = &quot;ctf_nacl_private_subnet&quot;<br>  }<br>}<br><br>## Create the NACL for the public subnet ##<br><br>resource &quot;aws_network_acl&quot; &quot;ctf_nacl_public_subnet&quot; {<br>  vpc_id     = aws_vpc.ctf_vpc.id<br>  subnet_ids = [aws_subnet.ctf_public_subnet.id]<br><br><br>  ingress {<br>    protocol   = &quot;udp&quot;<br>    rule_no    = 100<br>    action     = &quot;allow&quot;<br>    cidr_block = &quot;0.0.0.0/0&quot;<br>    from_port  = 51832<br>    to_port    = 51832<br>  }<br>  ingress {<br>    protocol   = &quot;tcp&quot;<br>    rule_no    = 101<br>    action     = &quot;allow&quot;<br>    cidr_block = var.your_public_ip_address<br>    from_port  = 22<br>    to_port    = 22<br>  }<br>  ingress {<br>    protocol   = &quot;tcp&quot;<br>    rule_no    = 102<br>    action     = &quot;allow&quot;<br>    cidr_block = &quot;0.0.0.0/0&quot;<br>    from_port  = 1024<br>    to_port    = 65535<br>  }<br>  ingress {<br>    protocol   = &quot;udp&quot;<br>    rule_no    = 103<br>    action     = &quot;allow&quot;<br>    cidr_block = &quot;0.0.0.0/0&quot;<br>    from_port  = 1024<br>    to_port    = 65535<br>  }<br>  ingress {<br>    protocol   = &quot;icmp&quot;<br>    rule_no    = 104<br>    action     = &quot;allow&quot;<br>    cidr_block = &quot;0.0.0.0/0&quot;<br>    from_port  = 0<br>    to_port    = 0<br>  }<br>  ingress {<br>    protocol   = -1<br>    rule_no    = 105<br>    action     = &quot;allow&quot;<br>    cidr_block = &quot;10.0.0.0/16&quot;<br>    from_port  = 0<br>    to_port    = 0<br>  }<br>  egress {<br>    protocol   = &quot;-1&quot;<br>    rule_no    = 100<br>    action     = &quot;allow&quot;<br>    cidr_block = &quot;0.0.0.0/0&quot;<br>    from_port  = 0<br>    to_port    = 0<br>  }<br>  tags = {<br>    Name = &quot;ctf_nacl_public_subnet&quot;<br>  }<br>}<br><br>## Now to create the EC2 Instance within the PRIVATE subnet ##<br><br>resource &quot;aws_instance&quot; &quot;ctf_EC2_vuln&quot; {<br>  ami                    = var.ami_id<br>  availability_zone      = var.az<br>  instance_type          = var.instance_type<br>  subnet_id              = aws_subnet.ctf_private_subnet.id<br>  vpc_security_group_ids = [aws_security_group.ctf_sg_private_subnet.id]<br>  private_ip             = &quot;10.0.0.10&quot;<br>  key_name               = aws_key_pair.lumenyx_public_key.id<br>  tags = {<br>    Name = &quot;ctf_EC2_vuln&quot;<br>  }<br>  root_block_device {<br>    volume_size = 50<br>    volume_type = &quot;gp3&quot;<br>  }<br>  user_data = &lt;&lt;-EOF<br>    #!/bin/bash<br>    sudo apt update<br>    sudo apt upgrade -y<br>    sudo apt install docker.io -y<br>    echo &quot;${var.friends_public_key}&quot; &gt;&gt; /home/ubuntu/.ssh/authorized_keys<br>    sudo hostnamectl set-hostname vulnerable_instance<br>    sed -i &#39;s/preserve_hostname: false/preserve_hostname: true/&#39; /etc/cloud/cloud.cfg<br>  EOF<br><br>  depends_on = [aws_nat_gateway.ctf_nat_gw] ## The script was running before the NAT gateway was provisioned. Therefore we need this ##<br>}<br><br>## Adding the key pair ##<br><br>resource &quot;aws_key_pair&quot; &quot;lumenyx_public_key&quot; {<br>  key_name   = &quot;lumenyx_public_key&quot;<br>  public_key = var.my_public_key<br>  tags = {<br>    Name = &quot;lumenyx_public_key&quot;<br>  }<br>}<br><br>## Now to create the EC2 Instance within the PUBLIC subnet ##<br><br>resource &quot;aws_instance&quot; &quot;ctf_EC2_vpn&quot; {<br>  ami                    = var.ami_id<br>  availability_zone      = var.az<br>  instance_type          = var.instance_type<br>  subnet_id              = aws_subnet.ctf_public_subnet.id<br>  vpc_security_group_ids = [aws_security_group.ctf_sg_public_subnet.id]<br>  key_name               = aws_key_pair.lumenyx_public_key.id<br>  tags = {<br>    Name = &quot;ctf_EC2_vpn&quot;<br>  }<br><br>  user_data = &lt;&lt;-EOF<br>  #!/bin/bash<br>  sudo apt update<br>  sudo apt upgrade -y<br>  sudo hostnamectl set-hostname vpn-server<br>  sed -i &#39;s/preserve_hostname: false/preserve_hostname: true/&#39; /etc/cloud/cloud.cfg<br>  EOF<br>}<br><br>## Adding the NAT Gateway ##<br><br>resource &quot;aws_nat_gateway&quot; &quot;ctf_nat_gw&quot; {<br>  allocation_id = aws_eip.ctf_nat_gw_eip.id<br>  subnet_id     = aws_subnet.ctf_public_subnet.id<br>}<br><br>## Allocating an Elastic IP address for the NAT gw ##<br><br>resource &quot;aws_eip&quot; &quot;ctf_nat_gw_eip&quot; {<br>  tags = {<br>    Name = &quot;ctf_nat_gw_eip&quot;<br>  }<br>  depends_on = [aws_internet_gateway.ctf_igw]<br>}</pre><p><strong>OUTPUTS.TF:</strong></p><pre>output &quot;vpc_id&quot; {<br>        description = &quot;The ID of the VPC.&quot;<br>        value = aws_vpc.ctf_vpc.id<br>}<br><br>output &quot;instance_public_ip&quot; {<br>       description = &quot;The public IP of the EC2 instance.&quot;<br>       value = aws_instance.ctf_EC2_vpn.public_ip<br>}</pre><h3>AWS MANAGEMENT CONSOLE — OPTIONAL</h3><p>I’m going to briefly go over the basics in the AWS Management Console first before I translate it to Terraform.</p><p><strong>You can skip this section by going to TERRAFORM CONFIGURATIONS below.</strong> However if you’re here to learn how to build things in Terraform (which is text-based), it might help to do it visually in the GUI first.</p><p>Now we’ll create the basic infrastructure.</p><p>Let it be known that there is a better and faster way of creating a VPC than the way I’m going to show. There’s actually a way to automate the creation of all of this in less than 5 minutes. However for learning purposes we’ll do everything the long way so we can internalise what is needed for when we create it in Terraform.</p><p>I chose the region physically closest to me. You can change it to whatever you want as long as it remains consistent:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*4dW9GIYjLNjZtTBAeLnflg.png" /></figure><p>Here are the basic steps we will be doing in this section:</p><ol><li>Create the VPC.</li><li>Create public / private subnets.</li><li>Enable a public IPv4 address for the public subnet.</li><li>Create the Internet Gateway.</li><li>Attach the IGW to the VPC.</li><li>Add a default gateway route for the public route table.</li><li>Explicitly associate the public route table with the public subnet.</li><li>Create a route table for the private subnet.</li><li>Configure the rtb’s inbound rules to come from one port (22) from the public subnet</li><li>Create a security group for the public subnet</li><li>Edit the security groups inbound rules (allow UDP 51832 from anywhere)</li></ol><h4>VPC</h4><p>Go to the VPC Console and Create VPC:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*2iuJjEjg7U3sbAxyL5RfLA.png" /></figure><p>Create the VPC. Below are the settings that I chose:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*u-b95oXE0kF0lnfiOqnz1g.png" /></figure><p>Create the VPC.</p><p>Now we can review:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*6qHoQYWAc4zzXMR65M_oTw.png" /></figure><h4>SUBNETS</h4><p>We’ll create the Public and Private Subnets. Take note of the information needed for each subnet, like the IPv4 range and the VPC we’re linking to.</p><p>Still within the VPC Console, go to Subnets and ‘Create subnet’:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*kBwdVCc13EnlnGkmngcUCg.png" /></figure><p>Within the screenshots are the steps to create the <strong>Public subnet</strong>:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*qaIqXzS1SQs3dG0GqN001g.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*3cpT1HCuwKzmiDBO_xJX9Q.png" /></figure><p>Take note of the IPv4 subnet block and the Availability Zone. We will be using adding this in Terraform manually soon.</p><p>Now that that’s done, let’s create the <strong>Private subnet</strong>:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*N-gKDr4px7j5w9eOsLN41Q.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*akUSs8yBL9wDsXmJ41FPPw.png" /></figure><p>Create the subnet.</p><p>Now we have two newly created subnets:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*RXZ1gkk9d2hXaLhbyNpiVg.png" /></figure><p>Now we want to assign <strong>ctf-public-subnet</strong> with a public IP address:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*kcvVgSB_TSSOdfMpXcJ8og.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*H7xUYk_381ye0xiH1rJMKQ.png" /></figure><p>NOTE: The subnet is still not public. The difference between public and private subnets is their internet connectivity. For a subnet to be considered public, it has to have a route to an Internet Gateway and resources must have a public IPv4 address.</p><h4>INTERNET GATEWAY</h4><p>Now we will create an Internet gateway and link it to the route table (which was automatically created):</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*CtV6uPB7dF_kSEx8gh_vOQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Pco8EaMKX0wHdSzv0du-qw.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*yD4VfyKudMfAleL057ZYuw.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*EJxVTM-kkk8myPd31Ohq3A.png" /></figure><p>Now the Internet gateway is attached to the ‘ctf’ VPC. It is still <strong>NOT</strong> connected to the public subnet yet.</p><p>The route table still needs to be updated.</p><h4>ROUTE TABLES</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*DpX-yCM6V6Yn-52yIzIKLQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*-0yPhQtvqtWpaW4wVRp-og.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*lcfiVyYZk5mtv29BKe-gRg.png" /></figure><p>Now we have to explicitly associate this route table with the public subnet:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*kkKbm6XU6cSW14FxXrfz_A.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ekj1ef4PTO9RG47IJUTb-g.png" /></figure><h4>CREATE SECURITY GROUPS</h4><p>Create a security group for the public subnet.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*OTghhSBMWRc_PiNmPZ_gxw.png" /></figure><p>Name the group: <strong>ctf-sg-public-subnet</strong></p><p>Description: <strong>Security group for the public subnet.</strong></p><p>We’ll also add a rule:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/694/1*23C7ZT5C0Wp4u47UxlodpA.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Ytq4-ef6TvdBU0p7igy3iA.png" /><figcaption>CORRECTION: We’re going to use WireGuard not OpenVPN.</figcaption></figure><p>Create the security Group.</p><p>I’ll stop it here with the AWS Management Console. There is more we’ll do with the configurations in Terraform.</p><h3>TERRAFORM CONFIGURATIONS</h3><p>Install AWS CLI by following the official AWS documentation <a href="https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html">here</a>.</p><p>Install Terraform for your specific OS by following the Terraform documentation <a href="https://developer.hashicorp.com/terraform/install">here</a>.</p><p>Create the Terraform folders:</p><pre>mkdir terraform-projects &amp;&amp; cd terraform-projects</pre><pre>mkdir ctf-project2026-03-09 &amp;&amp; cd ctf-project</pre><p>CREATE THE TERRAFORM FILES:</p><pre>touch providers.tf variables.tf main.tf outputs.tf versions.tf</pre><ul><li><strong>main.tf</strong> — The main configuration file for Terraform.</li><li><strong>outputs.tf —</strong><em> “Terraform output values let you export structured data about your resources. You can use this data to configure other parts of your infrastructure with automation tools, or as a data source for another Terraform workspace.”</em></li><li><strong>providers.tf</strong> — Is used to define what cloud platforms (like AWS, Azure, GCP) will be used.</li><li><strong>variables.tf</strong> — Used to define variables.</li><li><strong>versions.tf</strong> — Is used to define what version of Terraform will be used to ensure that the configurations remain consistent over time.</li></ul><p>Edit the files:</p><p><strong>VARIABLES.TF</strong>:</p><p>There are 3 parameters for the <strong>variables.tf</strong> file which are default, type, and description.</p><pre>variable &quot;aws_region&quot; {<br>        default = &quot;ap-southeast-2&quot;<br>        description = &quot;The AWS region were our resources will reside.&quot;<br>}<br><br>variable &quot;instance_type&quot; {<br>        default = &quot;t2.micro&quot;<br>        description = &quot;The instance type of the EC2 instance.&quot;<br>}<br><br>variable &quot;ami_id&quot; {<br>        default = &quot;ami-0ba8d27d35e9915fb&quot;<br>        description = &quot;The AMI ID of the EC2 insance.&quot;<br>}<br><br>variable &quot;az&quot; {<br>        default = &quot;ap-southeast-2a&quot;<br>}</pre><p>I got my AMI ID from EC2 &gt; Instances &gt; Launch an Instance &gt; Chose my AMI and copied the ID:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*IWrc6tjCPTQ_A7MR4sXLNg.png" /></figure><p><strong>PROVIDERS.TF</strong>:</p><pre>provider &quot;aws&quot; {<br>        region = var.aws_region<br>}</pre><p><strong>VERSIONS.TF</strong>:</p><pre>terraform {<br>        required_version = &quot;&gt;= 0.12&quot;<br><br>        required_providers {<br>                aws = {<br>                        source = &quot;hashicorp/aws&quot;<br>                        version = &quot;~&gt; 5.0&quot;<br>                }<br>        }<br>}</pre><p><strong>OUTPUTS.TF</strong>:</p><pre>output &quot;vpc_id&quot; {<br>        description = &quot;The ID of the VPC.&quot;<br>        value = aws_vpc.ctf_vpc.id<br>}<br><br>output &quot;instance_public_ip&quot; {<br>       description = &quot;The public IP of the EC2 instance.&quot;<br>       value = aws_instance.ctf_EC2_vpn.public_ip<br>}</pre><h4>BUILDING MAIN.TF</h4><p>Here are my initial configurations for main.tf. This is what I started off with and fixed it up heavily down the line.</p><p><strong>MAIN.TF</strong>:</p><p><strong>Virtual Private Cloud — VP</strong>C:</p><pre>## Create the VPC ##<br><br>resource  &quot;aws_vpc&quot; &quot;ctf_vpc&quot; {<br>    cidr_block = &quot;10.0.0.0/16&quot;<br>    enable_dns_support = true<br>    enable_dns_hostnames = true<br>    tags = {<br>        Name = &quot;ctf_vpc&quot;<br>    }<br>}</pre><ul><li>Documentation for <a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/vpc"><strong>aws_vpc</strong></a><strong>.</strong></li></ul><p><strong>Private Subnet:</strong></p><pre>## Create the private subnet ##<br><br>resource &quot;aws_subnet&quot; &quot;ctf_private_subnet&quot; {<br>    vpc_id = aws_vpc.ctf_vpc.id<br>    cidr_block = &quot;10.0.0.0/24&quot;<br>    availability_zone = var.az<br>    tags = {<br>        Name = &quot;ctf_private_subnet&quot;<br>    }<br>}</pre><ul><li>Documentation for <a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/subnet"><strong>aws_subnet</strong></a><strong>.</strong></li></ul><p><strong>Security Group with Rules — Private Subnet:</strong></p><pre>## Now to create the security group for the PRIVATE subnet ##<br><br>resource &quot;aws_security_group&quot; &quot;ctf_sg_private_subnet&quot; {<br>    name = &quot;sg_private_subnet&quot;<br>    description = &quot;Allow access from the public subnet only.&quot;<br>    vpc_id = aws_vpc.ctf_vpc.id<br>}<br><br>## Now to create the ingress rules for the PRIVATE subnet ##<br><br>resource &quot;aws_vpc_security_group_ingress_rule&quot; &quot;Allow_access_from_public_subnet&quot; {<br>    security_group_id = aws_security_group.ctf_sg_private_subnet.id<br>    cidr_ipv4 = &quot;10.0.1.0/24&quot; ## The source IPv4 range ##<br>    from_port = 22<br>    to_port = 22<br>    ip_protocol = &quot;tcp&quot;<br>}</pre><ul><li>Documentation for <a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/security_group"><strong>aws_security_group</strong></a><strong>.</strong></li><li>Documentation for <a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_security_group_ingress_rule"><strong>aws_vpc_security_group_ingress_rule</strong></a><strong>.</strong></li></ul><p><strong>Route Table — Private Subnet:</strong></p><pre>## Now to create the route table for the PRIVATE subnet ##<br><br>resource &quot;aws_route_table&quot; &quot;ctf_rtb_private_subnet&quot; {<br>    vpc_id =  aws_vpc.ctf_vpc.id<br>#   cidr_block = &quot;10.0.0.0/16&quot;<br>#   gateway_id = &quot;local&quot; ##  this as a local route is already created by default<br>    tags = {<br>        Name = &quot;ctf_rtb_private_subnet&quot;<br>    }<br>}<br><br>## Now we have to associate the routing table wuth the private subnet ##<br><br>resource &quot;aws_route_table_association&quot; &quot;ctf_rtb_association_private_subnet&quot; {<br>    route_table_id = aws_route_table.ctf_rtb_private_subnet.id<br>    subnet_id = aws_subnet.ctf_private_subnet.id<br>}</pre><ul><li>Documentation for <a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route_table"><strong>aws_route_table</strong></a><strong>.</strong></li><li>Documentation for <a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route_table_association"><strong>aws_route_table_association</strong></a><strong>.</strong></li></ul><p><strong>Public Subnet:</strong></p><pre>## Create the PUBLIC subnet ##<br><br>resource &quot;aws_subnet&quot; &quot;ctf_public_subnet&quot; {<br>    vpc_id = aws_vpc.ctf_vpc.id<br>    cidr_block = &quot;10.0.1.0/24&quot; <br>    availability_zone = var.az<br>    map_public_ip_on_launch = true  ## Assigns a public IP address on instance launch<br>    tags = {<br>        Name = &quot;ctf_public_subnet&quot;<br>    }<br>}</pre><ul><li>Documentation for <a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/subnet"><strong>aws_subnet</strong></a><strong>.</strong></li></ul><p><strong>Security Group with Rules — Public Subnet:</strong></p><pre>## Now to create the security group for the PUBLIC subnet ##<br><br>resource &quot;aws_security_group&quot; &quot;ctf_sg_public_subnet&quot; {<br>    name = &quot;sg_public_subnet&quot;<br>    description = &quot;Allow VPN access from everywhere.&quot;<br>    vpc_id = aws_vpc.ctf_vpc.id<br>    tags = {<br>        Name = &quot;ctf_sg_public_subnet&quot;<br>    }<br>}<br><br>## Add rule to allow connections from 0.0.0.0/0 into UDP 51832 ##<br><br>resource &quot;aws_vpc_security_group_ingress_rule&quot; &quot;Allow_VPN&quot; {<br>    security_group_id = aws_security_group.ctf_sg_public_subnet.id<br>    cidr_ipv4 = &quot;0.0.0.0/0&quot;<br>    from_port = 51832<br>    to_port = 51832<br>    ip_protocol = &quot;udp&quot;<br>}</pre><ul><li>Documentation for <a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/security_group"><strong>aws_security_group</strong></a><strong>.</strong></li><li>Documentation for <a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_security_group_ingress_rule"><strong>aws_vpc_security_group_ingress_rule</strong></a><strong>.</strong></li></ul><p><strong>Route Table — Public Subnet:</strong></p><pre>## Now to create the route table for the PUBLIC subnet ##<br><br>resource &quot;aws_route_table&quot; &quot;ctf_rtb_public_subnet&quot; {<br>    vpc_id =  aws_vpc.ctf_vpc.id<br>    route {<br>        cidr_block = &quot;0.0.0.0/0&quot;<br>        gateway_id = aws_internet_gateway.ctf_igw.id<br>        <br>    }<br>    tags = {<br>        Name = &quot;ctf_rtb_public_subnet&quot;<br>    }<br>}<br><br>## Now we have to associate the routing table with the public subnet ##<br><br>resource &quot;aws_route_table_association&quot; &quot;ctf_rtb_association_public_subnet&quot; {<br>    route_table_id = aws_route_table.ctf_rtb_public_subnet.id<br>    subnet_id = aws_subnet.ctf_public_subnet.id<br>}</pre><ul><li>Documentation for <a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route_table"><strong>aws_route_table</strong></a><strong>.</strong></li><li>Documentation for <a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route_table_association"><strong>aws_route_table_association</strong></a><strong>.</strong></li></ul><p><strong>Internet Gateway:</strong></p><pre>## Create an Internet Gateway ##<br><br>resource &quot;aws_internet_gateway&quot; &quot;ctf_igw&quot; {<br>    vpc_id = aws_vpc.ctf_vpc.id<br>    tags = {<br>        Name = &quot;ctf_igw&quot;<br>    }<br>}</pre><ul><li>Documentation for <a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/internet_gateway"><strong>aws_internet_gateway.</strong></a></li></ul><h4>TESTING</h4><p>So far we have added everything in the main.tf file except for the Network Access Control Lists (NACLs) and the EC2 instances themselves.</p><p>Message from the future: There was so much more I had to add and fix.</p><p>Let’s test out how it’s going:</p><p>Run these commands to initialise, plan, and apply the configurations:</p><pre>terraform init</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/740/1*cX6uTYCUH01VVyuMnxFCVw.png" /></figure><pre>terraform plan</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*nFcH1dodzkvuu2GLchL6HQ.png" /></figure><pre>terraform apply</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*_9zOtSLdrVAhOGncLlmCDg.png" /></figure><p>Now let’s check the AWS management console.</p><p>VPC:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/722/1*Thn4IKwb7bTkR6KewSuepg.png" /></figure><p>Subnets:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*dhPaC-FLdiBoWkKCIxAqCw.png" /></figure><p>Route tables:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ifRLaUXCwJA2XuXZJEwg_Q.png" /></figure><p>Internet Gateway:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ImYyibA_VKbK6GtsVe6SGA.png" /></figure><p>Security groups:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*_eaLs1-wyBcwz-tFzrcI_w.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*HtFFyqYUyH4bNlm71zoGNA.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Mm1sLY_avUfBsxEvgDvzDQ.png" /></figure><p>Resource map shows that items within the public subnet can reach the Internet Gateway:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Iy5oNoG-WW9fjYO2LrI2ZA.png" /></figure><p>Look good to me so far. If there’s any upcoming issues I’ll troubleshoot it.</p><h4>EC2 &amp; NETWORK ACCESS CONTROL LISTS (NACLs)</h4><p>Now let’s add the NACLs and the EC2 Instances to the <strong>main.tf</strong> file.</p><blockquote>A Network Access Control list is basically a stateless subnet-level firewall.</blockquote><blockquote>Since it’s stateless you must explicitly state inbound rules as well as outbound.</blockquote><p>NOTE: We don’t necessarily need to add the NACLs, however it is good to learn how to use them.</p><ul><li>Documentation for <a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/network_acl"><strong>aws_network_acl</strong></a><strong>.</strong></li><li>Documentation for <a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/instance"><strong>aws_instance</strong></a><strong>.</strong></li></ul><p><strong>NACL — Private Subnet:</strong></p><pre>## Now to create the NACL for the private subnet ##<br><br>resource &quot;aws_network_acl&quot; &quot;ctf_nacl_private_subnet&quot; {<br>    vpc_id = aws_vpc.ctf_vpc.id<br>    subnet_ids = aws_subnet.ctf_private_subnet.id<br>    ingress {<br>        protocol   = &quot;tcp&quot;<br>        rule_no    = 100<br>        action     = &quot;allow&quot;<br>        cidr_block = &quot;10.0.0.0/24&quot;<br>        from_port  = 22<br>        to_port    = 22<br>    }<br>    tags = {<br>        Name = &quot;ctf_nacl_private_subnet&quot;<br>  }<br>}</pre><p><strong>NACL — Public Subnet:</strong></p><pre>## Create the NACL for the public subnet ## <br><br>resource &quot;aws_network_acl&quot; &quot;ctf_nacl_public_subnet&quot; {<br>    vpc_id = aws_vpc.ctf_vpc.id<br>    subnet_ids = aws_subnet.ctf_public_subnet.id<br>    ingress {<br>        protocol   = &quot;udp&quot;<br>        rule_no    = 100<br>        action     = &quot;allow&quot;<br>        cidr_block = &quot;0.0.0.0/0&quot;<br>        from_port  = 51832<br>        to_port    = 51832<br>    }<br>    tags = {<br>        Name = &quot;ctf_nacl_public_subnet&quot;<br>  }<br>}</pre><p><strong>EC2 Instance — Vulnerable Instance:</strong></p><pre>## Now to create the EC2 Instance within the PRIVATE subnet ##<br><br>resource &quot;aws_instance&quot; &quot;ctf_EC2_vuln&quot; {<br>  ami                    = var.ami_id<br>  availability_zone      = var.az<br>  instance_type          = var.instance_type<br>  subnet_id              = aws_subnet.ctf_private_subnet.id<br>  vpc_security_group_ids = [aws_security_group.ctf_sg_private_subnet.id]<br>  private_ip             = &quot;10.0.0.10&quot;<br>  key_name               = aws_key_pair.lumenyx_public_key.id<br>  tags = {<br>    Name = &quot;ctf_EC2_vuln&quot;<br>  }<br>  root_block_device {<br>    volume_size = 50<br>    volume_type = &quot;gp3&quot;<br>  }<br>  user_data = &lt;&lt;-EOF<br>    #!/bin/bash<br>    sudo apt update<br>    sudo apt upgrade -y<br>    sudo apt install docker.io -y<br>    echo &quot;${var.friend_public_key}&quot; &gt;&gt; /home/ubuntu/.ssh/authorized_keys<br>    sudo hostnamectl set-hostname vulnerable_instance<br>    sed -i &#39;s/preserve_hostname: false/preserve_hostname: true/&#39; /etc/cloud/cloud.cfg<br>  EOF<br>}</pre><ul><li>Within `user_data`, I want to ensure that my friend’s public key is added so he can SSH into the vulnerable box too.</li></ul><p><strong>EC2 Instance — VPN Server:</strong></p><pre>## Now to create the EC2 Instance within the PUBLIC subnet ##<br><br>resource &quot;aws_instance&quot; &quot;ctf_EC2_vpn&quot; {<br>  ami                    = var.ami_id<br>  availability_zone      = var.az<br>  instance_type          = var.instance_type<br>  subnet_id              = aws_subnet.ctf_public_subnet.id<br>  vpc_security_group_ids = [aws_security_group.ctf_sg_public_subnet.id]<br>  key_name               = aws_key_pair.lumenyx_public_key.id<br>  tags = {<br>    Name = &quot;ctf_EC2_vpn&quot;<br>  }<br><br>  user_data = &lt;&lt;-EOF<br>  #!/bin/bash<br>  sudo apt update<br>  sudo apt upgrade -y<br>  sudo hostnamectl set-hostname vpn-server<br>  sed -i &#39;s/preserve_hostname: false/preserve_hostname: true/&#39; /etc/cloud/cloud.cfg<br>  EOF<br>}</pre><blockquote>NOTE: Regarding the private IP address of aws_instance.ctf_EC2_vuln, the first 4 ip addresses of the range is reserved (0 to 3), therefore I couldn’t make it 10.0.0.1.</blockquote><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*kmT9clyqJwDON7Pb7akfRA.png" /></figure><p>Now that the EC2 Instances are created, let’s also allow keys for the VPN instance so we can SSH from our host machine.</p><p>Added these three variables to variables.tf:</p><pre>variable &quot;my_public_key&quot; {<br>        description = &quot;The public key which will be imported into aws_instance.ctf_EC2_vpn&quot;<br>}<br><br>variable &quot;your_public_ip_address&quot; {<br>        description = &quot;Your actual public IP address. Eg) 1.2.3.4/32 &quot;<br>}<br><br>variable &quot;friend_public_key&quot; {<br>  description = &quot;Friend&#39;s public key: &quot;<br>}</pre><p>Added this to main.tf:</p><pre>## Adding the key pair ##<br><br>resource &quot;aws_key_pair&quot; &quot;lumenyx_public_key&quot; {<br>    key_name = &quot;lumenyx_public_key&quot;<br>    public_key = var.my_public_key<br>    tags = {<br>        Name = &quot;lumenyx_public_key&quot;<br>    }<br>}</pre><ul><li>Documentation for <a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/key_pair"><strong>aws_key_pair</strong></a><strong>.</strong></li></ul><p>When you run the terraform plan or terraform apply commands, it will prompt you for your public key as well as your public IP address.</p><p>The public key will be used to authenticate SSH access, and the public IP will be used to whitelist only your public IP address.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/723/1*uIFFmEm51TNuoS7SGMvWaA.png" /></figure><p>Now let’s test it out on our host machine if we can SSH into it without a password:</p><pre>ssh ubuntu@[IP address listed]</pre><p>It timed out.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/622/1*4XdO2urdWgGgN3L-E0MiLA.png" /></figure><p>I believe the issue is because we didn’t update the NACL configuration to allow SSH, nor did I allow outbound traffic (there’s an implicit deny that blocks all outbound traffic).</p><p>After lots of troubleshooting I realised that the rules that I ended up using were too restrictive to allow the CTF to properly take place, which is why I opened it up completely.</p><p>Here is what I ended up with:</p><pre>## Now to create the NACL for the private subnet ##<br><br>## Commented this out as this is too complicated for what the CTF needs ##<br><br>resource &quot;aws_network_acl&quot; &quot;ctf_nacl_private_subnet&quot; {<br>    vpc_id = aws_vpc.ctf_vpc.id<br>    subnet_ids = [aws_subnet.ctf_private_subnet.id] # subnet_ids expects a list, therefore must be in square brackets<br>#    egress {<br>#        protocol   = &quot;-1&quot;<br>#        rule_no    = 100<br>#        action     = &quot;allow&quot;<br>#        cidr_block = &quot;10.0.1.0/24&quot;<br>#       from_port  = 0<br>#        to_port    = 0<br>#   }<br>#   ingress {<br>#       protocol   = &quot;tcp&quot;<br>#       rule_no    = 100<br>#       action     = &quot;allow&quot;<br>#       cidr_block = &quot;10.0.1.0/24&quot;<br>#       from_port  = 22<br>#       to_port    = 22<br>#   }<br>#   ingress {  ## Allow ICMP Echo Reply ##<br>#       protocol = &quot;icmp&quot;<br>#       rule_no = 101<br>#       action = &quot;allow&quot;<br>#       cidr_block = &quot;10.0.1.0/24&quot;<br>#       from_port = 0<br>#       to_port = 0<br>#   }<br>#   ingress {  ## Allow ICMP Echo Response ##<br>#       protocol = &quot;icmp&quot;<br>#       rule_no = 102<br>#       action = &quot;allow&quot;<br>#       cidr_block = &quot;10.0.1.0/24&quot;<br>#       from_port = 0<br>#       to_port = 0<br>#       icmp_type = 8<br>#   }<br>   <br>    ingress {<br>        protocol   = -1<br>        rule_no    = 100<br>        action     = &quot;allow&quot;<br>        cidr_block = &quot;0.0.0.0/0&quot;<br>        from_port  = 0<br>        to_port    = 0<br>    }<br>    egress {<br>        protocol   = -1<br>        rule_no    = 100<br>        action     = &quot;allow&quot;<br>        cidr_block = &quot;0.0.0.0/0&quot;<br>        from_port  = 0<br>        to_port    = 0<br>    }<br>   tags = {<br>       Name = &quot;ctf_nacl_private_subnet&quot;<br> }<br>}</pre><pre>## Create the NACL for the public subnet ##<br><br>resource &quot;aws_network_acl&quot; &quot;ctf_nacl_public_subnet&quot; {<br>    vpc_id = aws_vpc.ctf_vpc.id<br>    subnet_ids = [aws_subnet.ctf_public_subnet.id]<br><br>    ingress {  ## Allow inbound VPN traffic ##<br>        protocol   = &quot;udp&quot;<br>        rule_no    = 100<br>        action     = &quot;allow&quot;<br>        cidr_block = &quot;0.0.0.0/0&quot;<br>        from_port  = 51832<br>        to_port    = 51832<br>    }<br>   ingress { ## Allow SSH from only my public IP address ##<br>        protocol = &quot;tcp&quot;<br>        rule_no = 101<br>        action = &quot;allow&quot;<br>       cidr_block = var.your_public_ip_address<br>        from_port = 22<br>        to_port = 22<br>    }<br>    ingress { ## Allow TCP traffic on ephemeral ports ##<br>        protocol = &quot;tcp&quot;<br>        rule_no = 102<br>        action = &quot;allow&quot;<br>        cidr_block = &quot;0.0.0.0/0&quot;<br>        from_port = 1024<br>        to_port = 65535<br>    }<br>    ingress { ## Allow UDP traffic on ephemeral ports ##<br>        protocol = &quot;udp&quot;<br>        rule_no = 103<br>        action = &quot;allow&quot;<br>        cidr_block = &quot;0.0.0.0/0&quot;<br>        from_port = 1024<br>        to_port = 65535<br>    }<br>    ingress { ## Allow ICMP Echo Replies ##<br>        protocol = &quot;icmp&quot;<br>        rule_no = 104<br>        action = &quot;allow&quot;<br>        cidr_block = &quot;0.0.0.0/0&quot;<br>        from_port = 0<br>        to_port = 0<br>    }<br>    ingress { ## Allow all traffic from 10.0.0.0/16 ##<br>        protocol = -1<br>        rule_no = 105<br>        action = &quot;allow&quot;<br>        cidr_block = &quot;10.0.0.0/16&quot;<br>        from_port = 0<br>        to_port = 0<br>    }<br>    egress { ## Allow all outbound traffic ##<br>        protocol   = &quot;-1&quot;<br>        rule_no    = 100<br>        action     = &quot;allow&quot;<br>        cidr_block = &quot;0.0.0.0/0&quot;<br>        from_port  = 0<br>        to_port    = 0<br>    }<br>    tags = {<br>        Name = &quot;ctf_nacl_public_subnet&quot;<br>  }<br>}</pre><p>Now lets try to SSH into it now:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/396/1*2sS7SLabKb8xk_5ub9lAYA.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/627/1*_dj57TCctebpfWjsME4LYQ.png" /><figcaption>This calls for a celebration.</figcaption></figure><h3>FIXING SSH — THE VULNERABLE INSTANCE</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/713/1*wcFJfL6FGwGclpdPF7K1cA.png" /></figure><p>I’m not able to SSH into the vulnerable instance yet. It keeps timing out.</p><p>I believe the issue is that there is a missing outbound rule for the public subnet’s security group. Therefore I added this resource:</p><pre>resource &quot;aws_vpc_security_group_egress_rule&quot; &quot;Allow_all_outbound_from_private_subnet&quot; {<br>    security_group_id = aws_security_group.ctf_sg_private_subnet.id<br>    cidr_ipv4 = &quot;0.0.0.0/0&quot; ## The destination IPv4 range ##<br>    ip_protocol = -1<br>}</pre><p>As long as the VPN is on, I can now SSH into the vulnerable server from my host machine.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/827/1*hD8yg5ei5ynE2Mrnw8pguA.png" /></figure><p>However there’s another issue. It cannot connect to the internet as it’s in a private subnet.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/736/1*fk6duxKV6iBiUhe9BoUWGQ.png" /></figure><p>It needs an internet connection to pull docker images and dependencies for the CTF.</p><p>We can resolve this by adding a NAT Gateway.</p><h3>NAT GATEWAY AND ELASTIC IP</h3><p>A NAT Gateway is used to allow resources in a <strong>private</strong> subnet to initiate outbound connections to the internet without the internet being able to talk to these resources directly.</p><p>The NAT gateway must be attached to a <strong>public subnet </strong>and also have an elastic IP address attached.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*cFbugAzuhvuRSVnoj1LNIg.png" /></figure><p>NAT Gateway:</p><pre>## Adding the NAT Gateway ##<br><br>resource &quot;aws_nat_gateway&quot; &quot;ctf_nat_gw&quot; {<br>    allocation_id = aws_eip.ctf_nat_gw_eip.id<br>    subnet_id = aws_subnet.ctf_public_subnet.id<br>}</pre><ul><li>Documentation for <a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/nat_gateway"><strong>aws_nat_gateway</strong></a><strong>.</strong></li></ul><p>Elastic IP:</p><pre>## Allocating an Elastic IP address for the NAT gw ##<br><br>resource &quot;aws_eip&quot; &quot;ctf_nat_gw_eip&quot; {<br>    tags = {<br>        Name = &quot;ctf_nat_gw_eip&quot;<br>    }<br>    depends_on = [aws_internet_gateway.ctf_igw]<br>}</pre><ul><li>Documentation for <a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/eip"><strong>aws_eip</strong></a><strong>.</strong></li></ul><p>That’s all the configurations for main.tf. Here is the finished version:</p><pre><br>## Create the VPC ##<br><br>resource &quot;aws_vpc&quot; &quot;ctf_vpc&quot; {<br>  cidr_block           = &quot;10.0.0.0/16&quot;<br>  enable_dns_support   = true<br>  enable_dns_hostnames = true<br>  tags = {<br>    Name = &quot;ctf_vpc&quot;<br>  }<br>}<br><br>## Create the private subnet ##<br><br>resource &quot;aws_subnet&quot; &quot;ctf_private_subnet&quot; {<br>  vpc_id            = aws_vpc.ctf_vpc.id<br>  cidr_block        = &quot;10.0.0.0/24&quot;<br>  availability_zone = var.az<br>  tags = {<br>    Name = &quot;ctf_private_subnet&quot;<br>  }<br>}<br><br>## Now to create the security group for the PRIVATE subnet ##<br><br>resource &quot;aws_security_group&quot; &quot;ctf_sg_private_subnet&quot; {<br>  name        = &quot;sg_private_subnet&quot;<br>  description = &quot;Allow access from the public subnet only.&quot;<br>  vpc_id      = aws_vpc.ctf_vpc.id<br>  tags = {<br>    Name = &quot;ctf_sg_private_subnet&quot;<br>  }<br>}<br><br>## Now to allow SSH from the private subnet ##<br><br>resource &quot;aws_vpc_security_group_ingress_rule&quot; &quot;Allow_access_from_public_subnet&quot; {<br>  security_group_id = aws_security_group.ctf_sg_private_subnet.id<br>  cidr_ipv4         = &quot;10.0.1.0/24&quot; ## The source IPv4 range ##<br>  from_port         = 22<br>  to_port           = 22<br>  ip_protocol       = &quot;tcp&quot;<br>}<br><br>## Allow ICMP traffic to the PRIVATE subnet ##<br><br>resource &quot;aws_vpc_security_group_ingress_rule&quot; &quot;Allow_ICMP&quot; {<br>  security_group_id = aws_security_group.ctf_sg_private_subnet.id<br>  cidr_ipv4         = &quot;10.0.1.0/24&quot; ## The source IPv4 range ##<br>  from_port         = -1<br>  to_port           = -1<br>  ip_protocol       = &quot;icmp&quot;<br>}<br><br>## Allow all egress out of the private subnet ##<br><br>resource &quot;aws_vpc_security_group_egress_rule&quot; &quot;Allow_all_outbound_from_private_subnet&quot; {<br>  security_group_id = aws_security_group.ctf_sg_private_subnet.id<br>  cidr_ipv4         = &quot;0.0.0.0/0&quot; ## The destination IPv4 range ##<br>  ip_protocol       = -1<br>}<br><br>## Create the PUBLIC subnet ##<br><br>resource &quot;aws_subnet&quot; &quot;ctf_public_subnet&quot; {<br>  vpc_id                  = aws_vpc.ctf_vpc.id<br>  cidr_block              = &quot;10.0.1.0/24&quot;<br>  availability_zone       = var.az<br>  map_public_ip_on_launch = true ## Assigns a public IP address on instance launch<br>  tags = {<br>    Name = &quot;ctf_public_subnet&quot;<br>  }<br>}<br><br>## Now to create the security group for the PUBLIC subnet ##<br><br>resource &quot;aws_security_group&quot; &quot;ctf_sg_public_subnet&quot; {<br>  name        = &quot;sg_public_subnet&quot;<br>  description = &quot;Allow VPN access from everywhere.&quot;<br>  vpc_id      = aws_vpc.ctf_vpc.id<br>  tags = {<br>    Name = &quot;ctf_sg_public_subnet&quot;<br>  }<br>}<br><br>## Add rule to allow connections from 0.0.0.0/0 into UDP 51832 ##<br><br>resource &quot;aws_vpc_security_group_ingress_rule&quot; &quot;Allow_VPN&quot; {<br>  security_group_id = aws_security_group.ctf_sg_public_subnet.id<br>  cidr_ipv4         = &quot;0.0.0.0/0&quot;<br>  from_port         = 51832<br>  to_port           = 51832<br>  ip_protocol       = &quot;udp&quot;<br>}<br><br>## Add rule to allow connections from 0.0.0.0/0 into TCP 22 ##<br>## Alternatively you can change cidr_ipv4 to be your public IP address ##<br><br>resource &quot;aws_vpc_security_group_ingress_rule&quot; &quot;Allow_SSH&quot; {<br>  security_group_id = aws_security_group.ctf_sg_public_subnet.id<br>  cidr_ipv4         = var.your_public_ip_address<br>  from_port         = 22<br>  to_port           = 22<br>  ip_protocol       = &quot;tcp&quot;<br>}<br><br>resource &quot;aws_vpc_security_group_egress_rule&quot; &quot;Allow_all_outbound&quot; {<br>  ip_protocol       = &quot;-1&quot; ## All protocols ##<br>  security_group_id = aws_security_group.ctf_sg_public_subnet.id<br>  cidr_ipv4         = &quot;0.0.0.0/0&quot;<br>  description       = &quot;Allow all outbound traffic&quot;<br>}<br><br>## Now to create the route table for the PRIVATE subnet ##<br><br>resource &quot;aws_route_table&quot; &quot;ctf_rtb_private_subnet&quot; {<br>  vpc_id = aws_vpc.ctf_vpc.id<br>  route {<br>    #   cidr_block = &quot;10.0.0.0/16&quot;<br>    #   gateway_id = &quot;local&quot; ##  this as a local route is already created by default<br>    cidr_block     = &quot;0.0.0.0/0&quot;<br>    nat_gateway_id = aws_nat_gateway.ctf_nat_gw.id<br>  }<br>  tags = {<br>    Name = &quot;ctf_rtb_private_subnet&quot;<br>  }<br>}<br><br>## Now we have to associate the routing table wuth the private subnet ##<br><br>resource &quot;aws_route_table_association&quot; &quot;ctf_rtb_association_private_subnet&quot; {<br>  route_table_id = aws_route_table.ctf_rtb_private_subnet.id<br>  subnet_id      = aws_subnet.ctf_private_subnet.id<br>}<br><br>## Create an Internet Gateway ##<br><br>resource &quot;aws_internet_gateway&quot; &quot;ctf_igw&quot; {<br>  vpc_id = aws_vpc.ctf_vpc.id<br>  tags = {<br>    Name = &quot;ctf_igw&quot;<br>  }<br>}<br><br>## Now to create the route table for the PUBLIC subnet ##<br><br>resource &quot;aws_route_table&quot; &quot;ctf_rtb_public_subnet&quot; {<br>  vpc_id = aws_vpc.ctf_vpc.id<br>  route {<br>    cidr_block = &quot;0.0.0.0/0&quot;<br>    gateway_id = aws_internet_gateway.ctf_igw.id<br><br>  }<br>  tags = {<br>    Name = &quot;ctf_rtb_public_subnet&quot;<br>  }<br>}<br><br>## Now we have to associate the routing table with the public subnet ##<br><br>resource &quot;aws_route_table_association&quot; &quot;ctf_rtb_association_public_subnet&quot; {<br>  route_table_id = aws_route_table.ctf_rtb_public_subnet.id<br>  subnet_id      = aws_subnet.ctf_public_subnet.id<br>}<br><br>## Now to create the NACL for the private subnet ##<br><br>## Commented this out as this is too restrictive for what the CTF needs ##<br><br>resource &quot;aws_network_acl&quot; &quot;ctf_nacl_private_subnet&quot; {<br>  vpc_id     = aws_vpc.ctf_vpc.id<br>  subnet_ids = [aws_subnet.ctf_private_subnet.id] # subnet_ids expects a list, therefore must be in square brackets<br>  #    egress {<br>  #        protocol   = &quot;-1&quot;<br>  #        rule_no    = 100<br>  #        action     = &quot;allow&quot;<br>  #        cidr_block = &quot;10.0.1.0/24&quot;<br>  #       from_port  = 0<br>  #        to_port    = 0<br>  #   }<br>  #   ingress {<br>  #       protocol   = &quot;tcp&quot;<br>  #       rule_no    = 100<br>  #       action     = &quot;allow&quot;<br>  #       cidr_block = &quot;10.0.1.0/24&quot;<br>  #       from_port  = 22<br>  #       to_port    = 22<br>  #   }<br>  #   ingress {  ## Allow ICMP Echo Reply ##<br>  #       protocol = &quot;icmp&quot;<br>  #       rule_no = 101<br>  #       action = &quot;allow&quot;<br>  #       cidr_block = &quot;10.0.1.0/24&quot;<br>  #       from_port = 0<br>  #       to_port = 0<br>  #   }<br>  #   ingress {  ## Allow ICMP Echo Response ##<br>  #       protocol = &quot;icmp&quot;<br>  #       rule_no = 102<br>  #       action = &quot;allow&quot;<br>  #       cidr_block = &quot;10.0.1.0/24&quot;<br>  #       from_port = 0<br>  #       to_port = 0<br>  #       icmp_type = 8<br>  #   }<br><br>  ingress {<br>    protocol   = -1<br>    rule_no    = 100<br>    action     = &quot;allow&quot;<br>    cidr_block = &quot;0.0.0.0/0&quot;<br>    from_port  = 0<br>    to_port    = 0<br>  }<br>  egress {<br>    protocol   = -1<br>    rule_no    = 100<br>    action     = &quot;allow&quot;<br>    cidr_block = &quot;0.0.0.0/0&quot;<br>    from_port  = 0<br>    to_port    = 0<br>  }<br>  tags = {<br>    Name = &quot;ctf_nacl_private_subnet&quot;<br>  }<br>}<br><br>## Create the NACL for the public subnet ##<br><br>resource &quot;aws_network_acl&quot; &quot;ctf_nacl_public_subnet&quot; {<br>  vpc_id     = aws_vpc.ctf_vpc.id<br>  subnet_ids = [aws_subnet.ctf_public_subnet.id]<br><br><br>  ingress {<br>    protocol   = &quot;udp&quot;<br>    rule_no    = 100<br>    action     = &quot;allow&quot;<br>    cidr_block = &quot;0.0.0.0/0&quot;<br>    from_port  = 51832<br>    to_port    = 51832<br>  }<br>  ingress {<br>    protocol   = &quot;tcp&quot;<br>    rule_no    = 101<br>    action     = &quot;allow&quot;<br>    cidr_block = var.your_public_ip_address<br>    from_port  = 22<br>    to_port    = 22<br>  }<br>  ingress {<br>    protocol   = &quot;tcp&quot;<br>    rule_no    = 102<br>    action     = &quot;allow&quot;<br>    cidr_block = &quot;0.0.0.0/0&quot;<br>    from_port  = 1024<br>    to_port    = 65535<br>  }<br>  ingress {<br>    protocol   = &quot;udp&quot;<br>    rule_no    = 103<br>    action     = &quot;allow&quot;<br>    cidr_block = &quot;0.0.0.0/0&quot;<br>    from_port  = 1024<br>    to_port    = 65535<br>  }<br>  ingress {<br>    protocol   = &quot;icmp&quot;<br>    rule_no    = 104<br>    action     = &quot;allow&quot;<br>    cidr_block = &quot;0.0.0.0/0&quot;<br>    from_port  = 0<br>    to_port    = 0<br>  }<br>  ingress {<br>    protocol   = -1<br>    rule_no    = 105<br>    action     = &quot;allow&quot;<br>    cidr_block = &quot;10.0.0.0/16&quot;<br>    from_port  = 0<br>    to_port    = 0<br>  }<br>  egress {<br>    protocol   = &quot;-1&quot;<br>    rule_no    = 100<br>    action     = &quot;allow&quot;<br>    cidr_block = &quot;0.0.0.0/0&quot;<br>    from_port  = 0<br>    to_port    = 0<br>  }<br>  tags = {<br>    Name = &quot;ctf_nacl_public_subnet&quot;<br>  }<br>}<br><br>## Now to create the EC2 Instance within the PRIVATE subnet ##<br><br>resource &quot;aws_instance&quot; &quot;ctf_EC2_vuln&quot; {<br>  ami                    = var.ami_id<br>  availability_zone      = var.az<br>  instance_type          = var.instance_type<br>  subnet_id              = aws_subnet.ctf_private_subnet.id<br>  vpc_security_group_ids = [aws_security_group.ctf_sg_private_subnet.id]<br>  private_ip             = &quot;10.0.0.10&quot;<br>  key_name               = aws_key_pair.lumenyx_public_key.id<br>  tags = {<br>    Name = &quot;ctf_EC2_vuln&quot;<br>  }<br>  root_block_device {<br>    volume_size = 50<br>    volume_type = &quot;gp3&quot;<br>  }<br>  user_data = &lt;&lt;-EOF<br>    #!/bin/bash<br>    sudo apt update<br>    sudo apt upgrade -y<br>    sudo apt install docker.io -y<br>    echo &quot;${var.friend_public_key}&quot; &gt;&gt; /home/ubuntu/.ssh/authorized_keys<br>  EOF<br>}<br><br>## Adding the key pair ##<br><br>resource &quot;aws_key_pair&quot; &quot;lumenyx_public_key&quot; {<br>  key_name   = &quot;lumenyx_public_key&quot;<br>  public_key = var.my_public_key<br>  tags = {<br>    Name = &quot;lumenyx_public_key&quot;<br>  }<br>}<br><br>## Now to create the EC2 Instance within the PUBLIC subnet ##<br><br>resource &quot;aws_instance&quot; &quot;ctf_EC2_vpn&quot; {<br>  ami                    = var.ami_id<br>  availability_zone      = var.az<br>  instance_type          = var.instance_type<br>  subnet_id              = aws_subnet.ctf_public_subnet.id<br>  vpc_security_group_ids = [aws_security_group.ctf_sg_public_subnet.id]<br>  key_name               = aws_key_pair.lumenyx_public_key.id<br>  tags = {<br>    Name = &quot;ctf_EC2_vpn&quot;<br>  }<br><br>  user_data = &lt;&lt;-EOF<br>  #!/bin/bash<br>  sudo apt update<br>  sudo apt upgrade -y<br>  EOF<br>}<br><br>## Adding the NAT Gateway ##<br><br>resource &quot;aws_nat_gateway&quot; &quot;ctf_nat_gw&quot; {<br>  allocation_id = aws_eip.ctf_nat_gw_eip.id<br>  subnet_id     = aws_subnet.ctf_public_subnet.id<br>}<br><br>## Allocating an Elastic IP address for the NAT gw ##<br><br>resource &quot;aws_eip&quot; &quot;ctf_nat_gw_eip&quot; {<br>  tags = {<br>    Name = &quot;ctf_nat_gw_eip&quot;<br>  }<br>  depends_on = [aws_internet_gateway.ctf_igw]<br>}</pre><h3>INSTALLING WIREGUARD WITH PIVPN</h3><p>Now I’ll SSH into the VPN server and install WireGuard:</p><pre>ssh ubuntu@[public-IP]</pre><p>Now let’s update everything:</p><pre>sudo apt update &amp;&amp; sudo apt upgrade -y</pre><p>Install PiVPN:</p><pre>curl -L https://install.pivpn.io | bash</pre><p>Go through the installer:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/682/1*mDeJK_zWPdNLJsus_NPaXA.png" /><figcaption>*game</figcaption></figure><p>Ensure the port is UDP 51832:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*n3SqhPz_nptCxyXPCtfhLw.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/650/1*S4llZprlVbzli3hjqcUGfQ.png" /></figure><p>Now we can add everyone’s profile and generate keys for them to log into the VPN:</p><pre>pivpn -a</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/692/1*f7hF2Ioe2qLfHQXj08EMSw.png" /></figure><p>I’ll supply the configurations with the players:</p><pre>cat ~/configs/[configuration file]</pre><h3>CONCLUSION</h3><p>Now every client who has a configuration can now access the public subnet, and from there they can attack the vulnerable server in the other subnet.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*RpiYN3PtaXJHstS2JchqTA.png" /></figure><p><a href="https://medium.com/@lumenyx/using-the-wireguard-client-on-kali-linux-ae70a0cb3320">How to connect to the VPN server</a>.</p><p>We’re all set! I’ll see you on game day.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=633a64394a96" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Using Authentik to Renew SSH Certificates]]></title>
            <link>https://medium.com/@lumenyx/using-authentik-to-renew-ssh-certificates-be2bcd20072c?source=rss-322c8ff982a8------2</link>
            <guid isPermaLink="false">https://medium.com/p/be2bcd20072c</guid>
            <dc:creator><![CDATA[Lumenyx]]></dc:creator>
            <pubDate>Sat, 31 Oct 2026 01:09:44 GMT</pubDate>
            <atom:updated>2026-10-31T01:09:44.591Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*LtjMwpd5zCqWJu6NWuU1TQ.png" /></figure><p>Authentik is an open-source identity provider that you host yourself, so your logins and user data stay on your own hardware instead of with a third-party service.</p><p>In this guide we’ll use it to issue SSH certificates.</p><p>This is part 3 of my 3 part series.</p><ol><li><a href="http://Running a Private Certificate Authourity with Step-CA in Docker">Set up a Private Certificate Authority</a></li><li><a href="https://medium.com/@lumenyx/setting-up-ssh-certificates-with-step-ca-34fe3a3f03be">Set up SSH certificates</a></li><li>Set up Authentik so SSH certificates are provisioned when logged into.</li></ol><p>For more detailed information about this, please refer to the official <a href="https://docs.goauthentik.io/install-config/install/docker-compose/">documentation</a>.</p><h3>HOW IT LOOKS ONCE IT’S SET UP</h3><ol><li>You’re unable to SSH in.</li></ol><p>2. You run the step ssh certificate command.</p><p>3. You put in your username and password when the browser opens.</p><p>4. You can now SSH into authorised machines as you have now been given a valid SSH certificate.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*-0e4YAuJVL1zYiI7LyZfYg.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ip11BTsgf3nHgHOrdj5O_A.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*vawscw2bkiBGurYCGMgvKw.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/803/1*4oiV9JVgTsLacQfyjwtKsA.png" /></figure><h3>CREATE THE AUTHENTIK DOCKER CONTAINER</h3><p>Create a dedicated folder:</p><pre>mkdir authentik &amp;&amp; cd authentik</pre><p>Download the Docker compose file:</p><pre>wget https://docs.goauthentik.io/compose.yml</pre><p>Generate PostgreSQL Password and Secret Key:</p><pre>echo &quot;PG_PASS=$(openssl rand -base64 36 | tr -d &#39;\n&#39;)&quot; &gt;&gt; .env<br>echo &quot;AUTHENTIK_SECRET_KEY=$(openssl rand -base64 60 | tr -d &#39;\n&#39;)&quot; &gt;&gt; .env</pre><p>Comment out the /var/run/docker.sock:/var/run/docker.sock line:</p><pre>services:<br>  postgresql:<br>    env_file:<br>    - .env<br>    environment:<br>      POSTGRES_DB: ${PG_DB:-authentik}<br>      POSTGRES_PASSWORD: ${PG_PASS:?database password required}<br>      POSTGRES_USER: ${PG_USER:-authentik}<br>    healthcheck:<br>      interval: 30s<br>      retries: 5<br>      start_period: 20s<br>      test:<br>      - CMD-SHELL<br>      - pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}<br>      timeout: 5s<br>    image: docker.io/library/postgres:16-alpine<br>    restart: unless-stopped<br>    volumes:<br>    - database:/var/lib/postgresql/data<br>  server:<br>    command: server<br>    depends_on:<br>      postgresql:<br>        condition: service_healthy<br>    env_file:<br>    - .env<br>    environment:<br>      AUTHENTIK_POSTGRESQL__HOST: postgresql<br>      AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik}<br>      AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}<br>      AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}<br>      AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}<br>    image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2026.8.5}<br>    ports:<br>#    - ${COMPOSE_PORT_HTTP:-9000}:9000 ## I want to use HTTPS exclusively ##<br>    - ${COMPOSE_PORT_HTTPS:-9443}:9443<br>    restart: unless-stopped<br>    shm_size: 512mb<br>    volumes:<br>    - ./data:/data<br>    - ./custom-templates:/templates<br>  worker:<br>    command: worker<br>    depends_on:<br>      postgresql:<br>        condition: service_healthy<br>    env_file:<br>    - .env<br>    environment:<br>      AUTHENTIK_POSTGRESQL__HOST: postgresql<br>      AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik}<br>      AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}<br>      AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}<br>      AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}<br>    image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2026.8.5}<br>    restart: unless-stopped<br>    shm_size: 512mb<br>    user: root<br>    volumes:<br>#    - /var/run/docker.sock:/var/run/docker.sock # We only need this if <br>    - ./data:/data                               # we use Outposts <br>    - ./certs:/certs<br>    - ./custom-templates:/templates<br>volumes:<br>  database:<br>    driver: local</pre><p>The reason we are commenting out the /var/run/docker.sock line is because “<em>Mounting the Docker socket to a container comes with some inherent security risks. To reduce these risks, you can use a </em><a href="https://docs.goauthentik.io/add-secure-apps/outposts/integrations/docker/#docker-socket-proxy"><em>Docker Socket Proxy</em></a><em> as an additional layer of protection.</em>”</p><p>Here is how it’s a security risk:</p><p>Some apps can’t log users in through Authentik directly because they only understand older protocols like LDAP. This is when Authentik uses an ‘outpost’, which is a helper container that Authentik spins up (on its own) that sits between the app and Authentik and translates between the two.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/704/1*ekPoLzs6cCOnLazsBZ6BVA.png" /></figure><p>In order to spin up this helper container, Authentik needs admin-level access to Docker. It gets this power by having the host’s /var/run/docker.sock (which is the Docker socket) mounted into the container at the same path /var/run/docker.sock.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/441/1*5UAXJnOOQ52ToPsBXo3-fw.png" /></figure><p>Think of /var/run/docker.sock as the entry point for controlling Docker. Every docker command (like docker run) goes through here.</p><p>In the above scenario, we would be giving the container access to the entry point for controlling Docker, because it needs to create and remove helper containers.</p><p>If the container were to become compromised then an attacker could use that access to create a new container with your whole filesystem mounted inside it, essentially giving them root on the host machine.</p><p>Apps that support modern standards like OIDC and SAML don’t need an outpost at all as they can talk to Authentik directly. Therefore they don’t need to mount /var/run/docker.sock.</p><p>In today’s scenario we don’t need outposts, so commenting out that line is fine.</p><p>Create the container:</p><pre>docker compose up -d</pre><p>You can now log in:</p><pre>https://&lt;your server&#39;s IP or hostname&gt;:9443</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/753/1*nQCEgF_t3jktruGxEYowrQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*NB5XryTTVorMeud11d6ong.png" /><figcaption>I changed it to dark mode off-camera.</figcaption></figure><h3>ISSUE A CERTIFICATE FOR AUTHENTIK</h3><p>Here is the <a href="https://docs.goauthentik.io/sys-mgmt/certificates/">official documentation</a> for TLS certificates.</p><ol><li>Install Step-CLI for your <a href="https://smallstep.com/docs/step-cli/installation/">operating system</a>.</li></ol><p>2. Ensure you’re in the authentik container directory:</p><pre>cd ~/authentik</pre><p>3. Use the hostname that you will type into the browser to reach the service:</p><pre>step ca certificate authentik-worker-1 \<br>~/authentik/certs/authentik-worker-1.crt \<br>~/authentik/certs/authentik-worker-1.key \<br>--san &lt;main-service-hostname&gt; \<br>--san &lt;any-additional-hostnames/ip-addresses&gt;</pre><p>In my specific case:</p><pre>step ca certificate authentik-worker-1 \<br>~/docker-files/authentik/certs/authentik-worker-1.crt \<br>~/docker-files/authentik/certs/authentik-worker-1.key \<br>--san endeavour-pi-01-tailscale \<br>--san 100.117.215.78</pre><p>The certificate is named after the authentik-worker-1 container because that’s where the certs folder is located (see compose.yml below).</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*FmsH15bAkfNUCsHKDw4xeA.png" /></figure><h3>GIVE THE AUTHENTIK CONTAINER ACCESS TO THE CERTIFICATE</h3><p>step creates the certificate files so that only your user can read them, however the container needs to be able to read them too.</p><ol><li>On the machine hosting the container, find out which user the container runs as:</li></ol><pre>docker exec authentik-worker-1 id</pre><p>Look at the <strong>uid </strong>at the start of the output. If it’s <strong>uid=0(root)</strong>, the container runs as root and can already read the files, so skip to step 3.</p><p>If the <strong>uid </strong>is any other number, continue to step 2, even if the gid is 0.</p><p>For example, Grafana shows <strong>uid=472(grafana) gid=0(root)</strong>. It runs as an ordinary user, so it still needs step 2:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/701/1*4Ztpv4d0vg2hDqqKchvvaA.png" /></figure><p>2. Note the gid number. Give that group read-only access to the key and make the certificate readable:</p><pre>sudo chgrp &lt;gid&gt; certs/authentik-worker-1.key<br>chmod 640 certs/authentik-worker-1.key<br>chmod 644 certs/authentik-worker-1.crt</pre><p>The certificate is public (644) and the key is restricted to those who own it and the container’s group (640).</p><p>3. Restart the container:</p><pre>docker compose up -d</pre><h3>CONFIGURE THE WEB CERTIFICATE</h3><p>Time to get rid of the HTTPS ‘Not Secure’ error.</p><ol><li>Within the admin interface, go to <strong>System &gt; Brands &gt; Actions</strong>:</li></ol><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*civqm5XfdQXDqdHUtS-jWQ.png" /></figure><p>2. Select ‘authentik-worker-1’ as the Web Certificate:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*efMxjuM5zfIIrlvIBOX2ug.png" /></figure><p>3. Now visit the website but with the <strong>DNS name </strong>instead. In my case:</p><pre>https://endeavour-pi-01-tailscale:9443</pre><p>4. The certificate error should no longer be present.</p><h3>SET UP CERTIFICATE AUTO-RENEW</h3><p>We are going to create the service <strong>on the machine </strong>that hosts the service whose certificate you want to auto-renew.</p><p>Run this as your normal user (not root) if you haven’t set it up before:</p><pre>sudo tee /etc/systemd/system/cert-renewer@.service &gt; /dev/null &lt;&lt; EOF<br>[Unit]<br>Description=Renew certificate %i<br>After=network-online.target<br><br>[Service]<br>Type=oneshot<br>User=$USER<br>Environment=STEPPATH=$HOME/.step<br>Environment=CERT=$HOME/certs/%i.crt<br>Environment=KEY=$HOME/certs/%i.key<br>ExecCondition=/usr/bin/step certificate needs-renewal \${CERT}<br>ExecStart=/usr/bin/step ca renew --force \${CERT} \${KEY}<br>ExecStartPost=-/bin/chmod 644 \${CERT}<br>ExecStartPost=-/bin/sh -c &#39;docker inspect -f &quot;{{.State.Running}}&quot; %i 2&gt;/dev/null | grep -qx true &amp;&amp; docker restart %i&#39;<br>EOF</pre><p>This is used for the certificates in the ~/certs directory (see <a href="https://medium.com/@lumenyx/running-a-private-ca-with-step-ca-in-docker-f39681671068"><strong>here</strong></a><strong> </strong>for more context).</p><p>Create the <strong>timer </strong>on the machine that hosts the service whose certificate you want to auto-renew (if you haven’t set it up before):</p><pre>sudo tee /etc/systemd/system/cert-renewer@.timer &gt; /dev/null &lt;&lt; EOF<br>[Unit]<br>Description=Check certificate %i for renewal<br><br>[Timer]<br>OnCalendar=*:0/15<br>RandomizedDelaySec=60<br>Persistent=true<br><br>[Install]<br>WantedBy=timers.target<br>EOF</pre><p>This timer checks every 15 minutes if a certificate needs renewing.</p><ul><li><strong>@</strong> marks a template in systemd. It’s used for one file that’s used many times, each time under a different name. @ comes right before the name, and before the extension (.timer).<br>E.g. <strong>cert-renewer@grafana.timer</strong></li><li><strong>%i</strong> is used as a placeholder for ‘whatever comes after the @’.</li></ul><p>Now we will add an override so the renewer uses Authentik’s certificate.</p><p>By default it looks in ~/certs, but Authentik keeps its certificates in its own certs folder. The override changes the location for Authentik only, so the other certificates aren’t affected:</p><pre>sudo mkdir -p /etc/systemd/system/cert-renewer@authentik-worker-1.service.d<br>sudo tee /etc/systemd/system/cert-renewer@authentik-worker-1.service.d/override.conf &gt; /dev/null &lt;&lt; &#39;EOF&#39;<br>[Service]<br>Environment=CERT=/home/&lt;YOUR_USERNAME&gt;/authentik/certs/%i.crt<br>Environment=KEY=/home/&lt;YOUR_USERNAME&gt;/authentik/certs/%i.key<br>EOF</pre><p>In my specific case:</p><pre>sudo mkdir -p /etc/systemd/system/cert-renewer@authentik-worker-1.service.d<br>sudo tee /etc/systemd/system/cert-renewer@authentik-worker-1.service.d/override.conf &gt; /dev/null &lt;&lt; &#39;EOF&#39;<br>[Service]<br>Environment=CERT=/home/lumenyx/docker-files/authentik/certs/%i.crt<br>Environment=KEY=/home/lumenyx/docker-files/authentik/certs/%i.key<br>EOF</pre><p>Now reload systemd:</p><pre>sudo systemctl daemon-reload</pre><p>Enable the cert-renewer timer:</p><pre>CERT_NAME=$(basename -s .crt certs/*.crt)</pre><pre>sudo systemctl enable --now cert-renewer@${CERT_NAME}.timer</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*a2AgjsFlIeLct6IwUl3n-A.png" /></figure><p>Check the timer is working:</p><pre>systemctl list-timers &#39;cert-renewer*&#39;</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Xjf4GS6zxfMS95pH4XH8rQ.png" /></figure><p>Confirm that the service itself works:</p><pre>sudo systemctl start cert-renewer@${CERT_NAME}.service<br>journalctl -u cert-renewer@${CERT_NAME}.service -n 20</pre><p>The logs may say that it was skipped, this is fine since it’s not time to renew the certificate.</p><pre>Oct 28 22:48:52 ENDEAVOUR-PI-01 systemd[1]: Starting cert-renewer@authentik-worker-1.service - Renew certificate authentik-worker-1...<br>Oct 28 22:48:52 ENDEAVOUR-PI-01 systemd[1]: cert-renewer@authentik-worker-1.service: Skipped due to &#39;exec-condition&#39;.<br>Oct 28 22:48:52 ENDEAVOUR-PI-01 systemd[1]: Condition check resulted in cert-renewer@authentik-worker-1.service - Renew certificate authentik-worker-1 being skipped.</pre><p>However if you see a ‘file not found’ error instead, then that may be due to a misconfiguration.</p><p>When the certificate renews you will be able to see it:</p><pre>journalctl -u cert-renewer@authentik-worker-1.service --no-pager | grep -C10 &quot;Your certificate has been saved&quot;</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*twhyQg_GplGw1iLHc3S3MA.png" /></figure><h3>FINALLY SETTING UP AUTHENTIK TO RENEW SSH CERTIFICATES</h3><ol><li>Log into Authentik with your admin account and click on <strong>Manage users</strong>:</li></ol><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*DXNq6Ma8LDgwwztz-gimSA.png" /></figure><p>Click on <strong>New User</strong>:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Gzpqd_v16LhG83dtCOiCyw.png" /></figure><p>Choose Internal User:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/880/1*Rd75FlAMeOUVT-MB9syGjg.png" /></figure><p>Then create the account.</p><p><strong>IMPORTANT: The name before the @ (user@domain), has to be the same as the SSH username, or else it won’t work.</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*R44fnWmN_NAkidrP63RCOA.png" /></figure><p>Create an ssh-users group:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*bUa_2LVDaMUIFBplMrRN5w.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*DCBb7huyq790u-wgfla3LA.png" /></figure><p>Then add your user into the group.</p><p>Now to create the SSH Renewal application:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*q2Jx3CfqCfxWNCdc3XdBbg.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*bnlxP-t3HlBDZ5-eV8qAuw.png" /></figure><p>Choose <strong>OAuth2/OpenID Provider</strong>:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*v-cWsJG-EQp5IgwS6Dipug.png" /></figure><p>For <strong>Authorization Flow</strong> choose the <strong>implicit-consent </strong>option:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*e_tAQiBMW7fwIsRfuJeQaw.png" /></figure><p>Note down the Client ID and Client Secret:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Z6sgQShAT4uDXI3xpui2-g.png" /></figure><p>Redirect URIs/Origins (RegEx):</p><pre>http://127\.0\.0\.1:\d+.*</pre><ul><li>After you log in on the SSH client, Authentik redirects your browser to the redirect URI (in this case 127.0.0.1:&lt;random-port&gt;) carrying a one-time code that step uses to finish the login.</li></ul><p>Signing Key: authentik Self-signed Certificate</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*1edC_WTLr417Y9yPU7P8Yw.png" /></figure><p>The ‘authentik Self-signed Certificate’ is used to sign the login tokens and prove to the Certificate Authority that they actually came from Authentik and that they haven’t been changed.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/90/1*uBu4fzRxJzoa1NgiBwlLAQ.png" /></figure><p>This screen outlines who can get an SSH certificate through Authentik. If you leave it empty then every Authentik user can get one:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ZL34PTvtmwffUKK_R8f8oA.png" /></figure><p>I will configure it to only allow users who are part of ‘ssh-users’ to have this access:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/895/1*UwVkEkHyjCzb2QntrKmSRg.png" /></figure><p>Now we need to add a provisioner (the thing that tells step-ca how to check who you are before it signs a certificate).</p><p>On the machine that is hosting the Certificate Authority, run this command:</p><pre>docker exec -it step-ca step ca provisioner add authentik --type OIDC \<br>--client-id &lt;CLIENT_ID&gt; \<br>--client-secret &lt;CLIENT_SECRET&gt; \<br>--configuration-endpoint=&lt;OpenID_Configuration_URL&gt;</pre><p>In my specific case:</p><pre>docker exec -it step-ca step ca provisioner add authentik --type OIDC \<br>--client-id c82XSYTXEWmeOaZZaJaoariK8n6QjegM8rc4j80q \<br>--client-secret &lt;CLIENT_SECRET&gt; \<br>--configuration-endpoint=https://endeavour-pi-01-tailscale:9443/application/o/ssh-certificate-renewal/.well-known/openid-configuration</pre><ul><li>Admin name: step</li><li>Password: Your CA password</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*EzJhtnB-7oW9LNyMUfpvyg.png" /></figure><p>Restart the CA container:</p><pre>docker restart step-ca</pre><p>Crap. Ran into an issue. The container cannot resolve the hostname since it’s using 8.8.8.8:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*xsNNa88FGsrHCSUc8XIoAg.png" /></figure><p>We’ll have to reconfigure the step-ca container to use our internal DNS server.</p><p>We have to remove the container (since it was spun up using docker run):</p><pre>docker rm -f step-ca</pre><p>Recreate the container:</p><pre>docker run -d --name step-ca --restart unless-stopped \<br>    -v step:/home/step \<br>    -p 9000:9000 \<br>    --dns &lt;DOCKER_BRIDGE_GATEWAY_IP OR LAN_IP&gt; \<br>    -e &quot;SSL_CERT_DIR=/etc/ssl/certs:/home/step/certs&quot; \<br>    -e &quot;DOCKER_STEPCA_INIT_NAME=Smallstep&quot; \<br>    -e &quot;DOCKER_STEPCA_INIT_DNS_NAMES=localhost,$(hostname -f),&lt;hostname-clients-use-to-reach-the-ca&gt;,&lt;ipaddr-clients-will-use-to-reach-the-ca&gt;&quot; \<br>    -e &quot;DOCKER_STEPCA_INIT_REMOTE_MANAGEMENT=true&quot; \<br>    -e &quot;DOCKER_STEPCA_INIT_SSH=true&quot; \<br>    smallstep/step-ca:0.30.2</pre><ul><li>--dns sends the container’s DNS lookups to Pi-hole which is on the same machine. If the DNS server was a different machine then we would just use the LAN IP.</li><li>-e “SSL_CERT_DIR=/etc/ssl/certs:/home/step/certs” This is used so that step-ca trusts its own root certificate when acting as an ordinary HTTPS client.</li></ul><p>If the DNS server is on the same machine as the step-ca container then you can find the Docker bridge gateway IP like so:</p><pre>docker network inspect bridge | grep Gateway</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*de_tVr9TNwO2UN5opglWQw.png" /></figure><p>Add the provisioner:</p><pre>docker exec -it step-ca step ca provisioner add authentik --type OIDC \<br>--client-id &lt;CLIENT_ID&gt; \<br>--client-secret &lt;CLIENT_SECRET&gt; \<br>--configuration-endpoint=&lt;OpenID_Configuration_URL&gt;</pre><p>On the SSH client, run this command to get a new SSH certificate:</p><pre>step ssh certificate &lt;YOUR_AUTHENTIK_EMAIL&gt; ~/.ssh/id_ecdsa --provisioner authentik</pre><p>In my specific case:</p><pre>step ssh certificate lumenyx@endeavour.com ~/.ssh/id_ecdsa --provisioner authentik --no-password --insecure</pre><ul><li>I added the --no-password --insecure so my private key isn’t protected by a passphrase. I don’t want to put in 2 password since I already incorporate SSH passwords AND keys.</li></ul><p>It will open a browser:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*UzwWAMucZAf-XXIObKiN5A.png" /></figure><p>Then you log in with the account you set up earlier:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/479/1*UTAghdxLlR1O5UiamniHew.png" /></figure><p>And you’re in:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*YL9Td6QMW-cCmRJfWgtv_w.png" /></figure><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=be2bcd20072c" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Setting Up SSH Certificates with Step CA]]></title>
            <link>https://medium.com/@lumenyx/setting-up-ssh-certificates-with-step-ca-34fe3a3f03be?source=rss-322c8ff982a8------2</link>
            <guid isPermaLink="false">https://medium.com/p/34fe3a3f03be</guid>
            <category><![CDATA[ssh]]></category>
            <category><![CDATA[ssh-certificate]]></category>
            <category><![CDATA[certificate-authority]]></category>
            <dc:creator><![CDATA[Lumenyx]]></dc:creator>
            <pubDate>Thu, 22 Oct 2026 06:46:46 GMT</pubDate>
            <atom:updated>2026-10-31T01:11:33.294Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/804/1*XhQyokeCQLKvakwvteQ6tg.png" /></figure><h3>WHAT WE’LL BE DOING</h3><p>We’ll set up SSH user and host certificates so that a client with 1 valid SSH certificate can log into approved SSH servers.</p><p>The certificate will expire after 16 hours and will (at this stage) need to be renewed manually.</p><p>This is part 2 of my 3 part series.</p><ol><li><a href="http://Running a Private Certificate Authourity with Step-CA in Docker">Set up a Private Certificate Authority</a></li><li>Set up SSH certificates</li><li><a href="https://medium.com/@lumenyx/using-authentik-to-renew-ssh-certificates-be2bcd20072c">Set up Authentik so SSH certificates are provisioned when logged into.</a></li></ol><h3>HOW SSH CERTIFICATES WORK</h3><p>A CA signs the SSH client’s public key (newly created by step ssh certificate and saved as ~/.ssh/id_ecdsa.pub) along with the usernames it may log in as, and any server that trusts the CA’s user public key (/etc/ssh/ssh_user_ca.pub) will believe it.</p><p>A CA signs the SSH server’s public key (/etc/ssh/ssh_host_ed25519_key.pub) along with its hostnames, and any client that trusts the CA’s host public key (the @cert-authority line in ~/.ssh/known_hosts) will believe it.</p><p>We will go over this process below.</p><h3>THE GENERAL PROCESS</h3><h4>SETUP (DONE ONCE)</h4><p>1. The CA has two key pairs: a <strong>host CA pair</strong> used for signing SSH servers, and a <strong>user CA </strong>pair used for signing people.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/804/1*3_ZeH9LNsI7C_XzzoQMzJg.png" /></figure><ul><li>Private Host CA Key: /var/lib/docker/volumes/step/_data/<strong>secrets/ssh_host_ca_key</strong></li><li>Private User CA Key: /var/lib/docker/volumes/step/_data/<strong>secrets/ssh_user_ca_key</strong></li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/943/1*sXLnAHT6dxPczPMQyKJdCA.png" /></figure><ul><li>Public Host CA Key: /var/lib/docker/volumes/step/_data/<strong>certs/ssh_host_ca_key.pub</strong></li><li>Public User CA Key: /var/lib/docker/volumes/step/_data/<strong>certs/ssh_user_ca_key.pub</strong></li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ehfCb6ZbvY3JaUv2SHAK0g.png" /></figure><p>2. Each SSH server and SSH client is set up so it can securely talk to the CA (bootstrapping). This uses the fingerprint of the CA’s root SSL certificate (not the SSH keys), and it does this once per machine.</p><p><strong>On SSH servers, bootstrapping is run with </strong><strong>sudo.</strong> (The renewal service and writing to /etc/ssh needs root permissions)</p><p><strong>On SSH clients, bootstrapping is run as a normal user.</strong></p><p>3. Each SSH server gives the CA its public key (/etc/ssh/ssh_host_ed25519_key.pub) along with its valid hostnames.</p><p>The CA then signs them with its private host CA key (/var/lib/docker/volumes/step/_data/secrets/ssh_host_ca_key), and that becomes the <strong>SSH server’s host certificate </strong>(/etc/ssh/ssh_host_ed25519_key-cert.pub<strong>).</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/834/1*Lrg6B-x0j-Bs1DWhz2hiuA.png" /></figure><p>This host certificate is what each SSH server presents to every connecting SSH client so that the client can confirm the authenticity of the SSH server (as long as the client has the CA’s public host key stored, which we will get to shortly).</p><p>4. Each SSH server is then told to trust the CA’s public user key. We do this by copying the <strong>/var/lib/docker/volumes/step/_data/certs/ssh_user_ca_key.pub</strong> onto each SSH server (saved as /etc/ssh/ssh_user_ca.pub) and telling SSH to trust it.</p><p>5. Each SSH client is told to trust the CA’s public host key, via the @cert-authority line in known_hosts.</p><h4>WHEN THE USER CERTIFICATE EXPIRES</h4><p>6. When the user certificate expires, you run step ssh certificate. The SSH client makes a new private/public key pair and sends the public key and username to the CA. The CA then sends back a <strong>user certificate</strong>.</p><p>Three files are saved on the SSH client:</p><ul><li>The private key (made by step ssh certificate): ~/.ssh/id_ecdsa</li><li>The public key (made by step ssh certificate): ~/.ssh/id_ecdsa.pub</li><li>The user certificate (sent back by the CA): ~/.ssh/id_ecdsa-cert.pub</li></ul><p>The user certificate is valid for 16 hours by default.</p><h4>WHEN AN SSH CLIENT CONNECTS TO AN SSH SERVER</h4><p>7. The <strong>SSH server</strong> shows its <strong>host</strong> certificate. The SSH client checks that this certificate is signed by the host CA.</p><p>8. The <strong>SSH client</strong> shows its <strong>user</strong> certificate. The SSH server checks that this certificate is signed by the user CA.</p><p>9. Each side proves it holds the private key that matches its certificate by digitally signing a piece of data unique to this connection with that private key. The other side then checks the digital signature using the public key inside the certificate.</p><h3>DO THIS ON EACH SERVER</h3><ol><li>Install step-cli:</li></ol><p>Depending on your operating system the installation may vary. Please consult the <a href="https://smallstep.com/docs/step-cli/installation/">installation documentation</a>.</p><p>I am using Ubuntu:</p><pre>sudo apt-get update &amp;&amp; sudo apt-get install -y --no-install-recommends curl gpg ca-certificates<br>sudo install -d -m 0755 /etc/apt/keyrings<br>sudo curl -fsSL https://packages.smallstep.com/keys/apt/repo-signing-key.gpg -o /etc/apt/keyrings/smallstep.asc<br>sudo tee /etc/apt/sources.list.d/smallstep.sources &gt; /dev/null &lt;&lt; EOF<br>Types: deb<br>URIs: https://packages.smallstep.com/stable/debian<br>Suites: debs<br>Components: main<br>Signed-By: /etc/apt/keyrings/smallstep.asc<br>EOF<br>sudo apt-get update &amp;&amp; sudo apt-get -y install step-cli</pre><p>2. Get the fingerprint of the root TLS certificate (we will only use this cert for the initial bootstrap).</p><p>You can get this by going to the machine that hosts the container and running:</p><pre>CA_FINGERPRINT=$(docker run --rm -v step:/home/step smallstep/step-ca:0.30.2  step certificate fingerprint certs/root_ca.crt)<br>echo $CA_FINGERPRINT</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*J7J-MHgTKksmBVdIBtGWvQ.png" /></figure><p>3. Set your server up so it can securely talk to our Certificate Authority (bootstrapping):</p><p>Ensure it is <strong>run with sudo</strong> since the later steps run as root:</p><pre>sudo step ca bootstrap --ca-url https://&lt;CA_HOSTNAME/IP&gt;:9000 --fingerprint $CA_FINGERPRINT --install</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*LxkIfj39abRLxiTG7BxwTg.png" /></figure><p>Confirm the health from each of the servers that you’re bootstrapping:</p><pre>curl https://&lt;CA_IP/HOSTNAME&gt;:9000/health</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/878/1*0DDxM5Nl4mIyBc0JUu7FJQ.png" /></figure><p>4. From the SSH server, copy the <strong>ssh_user_ca_key.pub</strong> key (from the CA) into the <strong>/etc/ssh/</strong> directory (in the server):</p><pre>sudo sh -c &#39;step ssh config --roots &gt; /etc/ssh/ssh_user_ca.pub&#39;</pre><p>5. Have the sshd_config.d on all the servers trust the SSH user CA key (<strong>ssh_user_ca_key.pub</strong>):</p><pre>echo &#39;TrustedUserCAKeys /etc/ssh/ssh_user_ca.pub&#39; | sudo tee /etc/ssh/sshd_config.d/00-step-ca.conf</pre><ul><li>Think of the sshd_config.d directory as a place for extra SSH settings.</li><li>We are creating a new file called 00-step-ca.conf in the /etc/ssh/sshd_config.d folder. Its name starts with a ‘00’ so it loads first.</li><li>More info on the tee command is <a href="https://medium.com/@lumenyx/the-tee-command-in-linux-6d475039fbf4"><strong>here</strong></a>.</li></ul><p>6. Test the validity of the SSH configuration files. If it prints nothing then we’re all good:</p><pre>sudo sshd -t</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/645/1*g8tZ1-xS2j812AYkDUDFjw.png" /></figure><p>7. Reload the sshd daemon:</p><pre>sudo systemctl reload sshd</pre><h3>GIVE EACH SERVER A HOST CERTIFICATE</h3><p>Now we’ll set up a way to verify the authenticity of the SSH servers.</p><p>Ask your CA to sign the server’s existing host public key, which will then produce a host certificate.</p><p>(/etc/ssh/ssh_host_ed25519_key.pub (on the SSH server) <br>+ <br>/var/lib/docker/volumes/step/_data/secrets/ssh_host_ca_key (on the CA) <br>= <br>/etc/ssh/ssh_host_ed25519_key-cert.pub (on the SSH server))</p><ol><li>Do this on each server:</li></ol><pre>sudo step ssh certificate --host --sign \<br>--principal &lt;hostname&gt; \<br>--principal &lt;server-ip-address&gt; \<br>--principal &lt;hostname-your-domain&gt; \<br>&lt;hostname&gt; /etc/ssh/ssh_host_ed25519_key.pub</pre><p>--principal lists the names the cert is valid for. It will accept the name you put into your ssh command (eg ssh lumenyx@principal). Ensure you list all the names/principals that you would use when logging in.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/712/1*9BvglQxqIQxwBGAqLWOGcQ.png" /><figcaption>I included my tailscale addresses too.</figcaption></figure><p>2. Add a line to this configuration file on each ssh server. This will hand this certificate to anyone who connects:</p><pre>echo &#39;HostCertificate /etc/ssh/ssh_host_ed25519_key-cert.pub&#39; | sudo tee -a /etc/ssh/sshd_config.d/00-step-ca.conf</pre><p>Confirm that you see <strong>two</strong> lines:</p><pre>sudo cat /etc/ssh/sshd_config.d/00-step-ca.conf</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/836/1*YFK5qcCl85V9eEpccvWvLg.png" /></figure><p>3. Test and reload ssh:</p><pre>sudo sshd -t</pre><pre>sudo systemctl reload sshd</pre><h3>SET UP SSH HOST RENEWALS</h3><p>We’ll have to set up auto-renewal as host certs (what proves the ssh server is genuine) expire after 30 days.</p><p>To set up renewal, we use a provisioner called SSHPOP (SSH Proof Of Possession). Check if we have it installed.</p><p>Run this on the device that hosts the CA:</p><pre>docker exec step-ca step ca provisioner list</pre><p>Look for SSHPOP. If it’s there then we’re good to continue.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/450/1*z_FR_0BE--TXsfdozRxKNQ.png" /></figure><ol><li>Run this on each SSH server to create a renewal service:</li></ol><pre>sudo tee /etc/systemd/system/ssh-host-renew.service &gt; /dev/null &lt;&lt; &#39;EOF&#39;<br>[Unit]<br>Description=Renew SSH host certificate<br>After=network-online.target<br><br>[Service]<br>Type=oneshot<br>Environment=STEPPATH=/root/.step<br>ExecStart=/usr/bin/step ssh renew --force /etc/ssh/ssh_host_ed25519_key-cert.pub /etc/ssh/ssh_host_ed25519_key<br>ExecStartPost=/bin/systemctl reload sshd<br>EOF</pre><ul><li>This service runs around boot and waits for the network to be up (After=network-online.target)</li><li>It runs once then stops (Type=oneshot)</li><li>STEPPATH points to root’s saved bootstrap settings (Environment=STEPPATH=/root/.step)</li><li>The service renews the host certificate, then it reloads sshd</li></ul><p>2. Create the timer.</p><p>We need a timer to trigger the ssh-host-renew.service to run once a day.</p><p>Add this to all your SSH servers:</p><pre>sudo tee /etc/systemd/system/ssh-host-renew.timer &gt; /dev/null &lt;&lt; &#39;EOF&#39;<br>[Unit]<br>Description=Daily SSH host certificate renewal<br><br>[Timer]<br>OnCalendar=daily<br>RandomizedDelaySec=1h<br>Persistent=true<br><br>[Install]<br>WantedBy=timers.target<br>EOF</pre><ul><li>The timer runs once daily at midnight (OnCalendar=daily)</li><li>A random wait time of up to 1 hour is added so all the SSH servers aren’t hitting the CA at the same time (RandomizedDelaySec=1h)</li><li>If the SSH server happens to be off when the timer runs, then it will run when the server is back up (Persistent=true)</li></ul><p>3. Enable the timer.</p><p>Run this on each SSH server:</p><pre>sudo systemctl daemon-reload<br>sudo systemctl enable --now ssh-host-renew.timer</pre><p>This loads the new files into systemd (daemon-reload) and then enables the timer.</p><p>Check that the timer is enabled:</p><pre>systemctl list-timers ssh-host-renew.timer</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*i-FE5xNFBbAiVmEJnWMcvA.png" /></figure><p>Test the renewer manually (rather than waiting):</p><pre>sudo systemctl start ssh-host-renew.service<br>journalctl -u ssh-host-renew.service -n 20</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/978/1*MgomAdM7W-4b2px63de2Ug.png" /></figure><p>We have now set up the timer to renew the SSH host certificate every day. Since the host SSH certificate is valid for 30 days, this means that we have a month to catch an issue if something goes wrong.</p><h3>RUN THIS ON SSH CLIENTS</h3><ol><li>Install step-cli and bootstrap the CA.</li></ol><p>Depending on your operating system the installation may vary. Please consult the <a href="https://smallstep.com/docs/step-cli/installation/">installation documentation</a>.</p><p>I am using Ubuntu:</p><pre>sudo apt-get update &amp;&amp; sudo apt-get install -y --no-install-recommends curl gpg ca-certificates<br>sudo install -d -m 0755 /etc/apt/keyrings<br>sudo curl -fsSL https://packages.smallstep.com/keys/apt/repo-signing-key.gpg -o /etc/apt/keyrings/smallstep.asc<br>sudo tee /etc/apt/sources.list.d/smallstep.sources &gt; /dev/null &lt;&lt; EOF<br>Types: deb<br>URIs: https://packages.smallstep.com/stable/debian<br>Suites: debs<br>Components: main<br>Signed-By: /etc/apt/keyrings/smallstep.asc<br>EOF<br>sudo apt-get update &amp;&amp; sudo apt-get -y install step-cli</pre><p>Declare your CA Fingerprint:</p><pre>CA_FINGERPRINT=&lt;FINGERPRINT&gt;</pre><p>Set up a new client so it can securely talk to the CA (bootstrapping):</p><pre>step ca bootstrap --ca-url https://&lt;CA_HOSTNAME/IP&gt;:9000 --fingerprint $CA_FINGERPRINT --install</pre><p>This cross-referenced the root SSL certificate with the fingerprint. If they match then the SSL certificate is downloaded into the system trust store. This allows things like curl and Chrome to trust certs from the CA (Firefox is different).</p><p>3. Add the host CA’s public key (/var/lib/docker/volumes/step/_data/certs/ssh_host_ca_key.pub) to the known_hosts file on the SSH client:</p><pre>echo &quot;@cert-authority &lt;SERVER1_HOSTNAME&gt;,&lt;SERVER1_IP&gt;,&lt;SERVER2_HOSTNAME&gt;,&lt;SERVER2_IP&gt; $(step ssh config --host --roots)&quot; &gt;&gt; ~/.ssh/known_hosts</pre><ul><li>The @ symbol indicates a special line that tells SSH that this isn’t a normal entry (like a server name + its key) and to treat it differently.</li><li>The SERVER_HOSTNAME or the SERVER_IP are the addresses that will be used to SSH into each server. For example if we list example.local but not its IP address, and we SSH with its IP address, then we will get the ‘authenticity of host can’t be established’ warning.</li><li>step ssh config — host — roots fetches the CA’s host CA public key.</li></ul><p>For example my specific command is as follows:</p><pre>echo &quot;@cert-authority endeavour-pi-*,jennifer-pi-*,jennifer-nuc-*,192.168.0.*,192.168.15.*,100.*,remote-* $(step ssh config --host --roots)&quot; &gt;&gt; ~/.ssh/known_hosts</pre><p>If the .ssh folder doesn’t exist yet, create it then run the above command:</p><pre>mkdir -p ~/.ssh<br>chmod 700 ~/.ssh</pre><p>If you need to update this in the future when new SSH servers are added, include the hostnames and IP addresses into the line (don’t run the command again):</p><pre>vim ~/.ssh/known_hosts</pre><p>4. Get your user certificate.</p><p>Request a user certificate from the CA:</p><pre>step ssh certificate &lt;IDENTITY&gt; ~/.ssh/id_ecdsa --principal &lt;SERVER_USERNAME&gt; --provisioner &lt;PROVISIONER_NAME&gt;</pre><p>In my case:</p><pre>step ssh certificate lumenyx ~/.ssh/id_ecdsa --principal lumenyx --provisioner admin --no-password --insecure</pre><ul><li>I added --no-password and --insecure because I didn’t want to add another password. This is for a homelab. Just be aware that anyone who copies ~/.ssh/id_ecdsa can log in until the certificate expires.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*pkuiVXg3R66d6cKrpFS-TA.png" /></figure><h3>INCREASING THE USER CERT EXPIRY LIMIT</h3><p>Currently we have to create a new user certificate every 16 hours.</p><p>We can increase it like so on the CA host:</p><pre>docker exec -it step-ca step ca provisioner update admin \<br>  --ssh-user-default-dur 168h \<br>  --ssh-user-max-dur 168h<br>docker restart step-ca</pre><ul><li>The admin name is ‘step’.</li><li>The password is the CA password.</li></ul><p>This new expiry will take effect the next time you run step ssh certificate.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=34fe3a3f03be" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Setting Up DDNS With Duck DNS]]></title>
            <link>https://medium.com/@lumenyx/setting-up-ddns-with-duck-dns-0f4237a48a32?source=rss-322c8ff982a8------2</link>
            <guid isPermaLink="false">https://medium.com/p/0f4237a48a32</guid>
            <category><![CDATA[homelab]]></category>
            <category><![CDATA[dns]]></category>
            <dc:creator><![CDATA[Lumenyx]]></dc:creator>
            <pubDate>Mon, 12 Oct 2026 20:39:28 GMT</pubDate>
            <atom:updated>2026-10-25T07:24:39.919Z</atom:updated>
            <content:encoded><![CDATA[<p>I can’t believe how easy this was.</p><h3>DUCKDNS</h3><ol><li>Create and log into a Duck DNS account — <a href="https://www.duckdns.org/">https://www.duckdns.org/</a>.</li><li>Create your domain.</li><li>Follow the up instructions by copying and pasting the commands:</li></ol><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*XJiQCttpoM-Je-AlSYbxGw.png" /></figure><p>4. Done.</p><p>This will make your Raspberry Pi update the current public IP address for your domain every 5 minutes. If your public IP address ever changes for some reason, then the domain you created will then point to the current address after 5 minutes.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=0f4237a48a32" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Running a Private Certificate Authority with Step-CA in Docker]]></title>
            <link>https://medium.com/@lumenyx/running-a-private-ca-with-step-ca-in-docker-f39681671068?source=rss-322c8ff982a8------2</link>
            <guid isPermaLink="false">https://medium.com/p/f39681671068</guid>
            <category><![CDATA[small-steps]]></category>
            <category><![CDATA[linux]]></category>
            <category><![CDATA[ssl-certificate]]></category>
            <category><![CDATA[certificate-authority]]></category>
            <dc:creator><![CDATA[Lumenyx]]></dc:creator>
            <pubDate>Sun, 11 Oct 2026 03:45:33 GMT</pubDate>
            <atom:updated>2026-10-31T01:10:53.062Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/400/0*dzyVF9W-y2Rfd-e6.png" /></figure><p>This is part 1 of my 3 part series.</p><ol><li>Set up a Private Certificate Authority</li><li><a href="https://medium.com/@lumenyx/setting-up-ssh-certificates-with-step-ca-34fe3a3f03be">Set up SSH certificates</a></li><li><a href="https://medium.com/@lumenyx/using-authentik-to-renew-ssh-certificates-be2bcd20072c">Set up Authentik so SSH certificates are provisioned when logged into.</a></li></ol><p><a href="https://smallstep.com/docs/step-ca/">Smallstep</a> have created several simple ways to create your own private Certificate Authority.</p><p>We will also enable it to support SSH certificates.</p><p>We’ll be using their <a href="https://hub.docker.com/r/smallstep/step-ca?tag=0.30.2">steps for Docker</a>.</p><h3>GENERAL PROCESS</h3><ol><li>Install step-cli.</li><li>Run the step-ca container (creates the root and intermediate certificates).</li><li>Get the root certificate’s fingerprint.</li><li>Bootstrap each end device to trust the root (plus Firefox separately).</li><li>On the service’s host, request a certificate from the CA for the hostname clients use to reach the service.</li><li>Point the service at the certificate.</li><li>Set up auto-renewal.</li></ol><h3>INSTALL STEP-CLI</h3><p>Depending on your operating system the installation may vary. Please consult the <a href="https://smallstep.com/docs/step-cli/installation/">installation documentation</a>.</p><p>I am using Ubuntu:</p><pre>sudo apt-get update &amp;&amp; sudo apt-get install -y --no-install-recommends curl gpg ca-certificates<br>sudo install -d -m 0755 /etc/apt/keyrings<br>sudo curl -fsSL https://packages.smallstep.com/keys/apt/repo-signing-key.gpg -o /etc/apt/keyrings/smallstep.asc<br>sudo tee /etc/apt/sources.list.d/smallstep.sources &gt; /dev/null &lt;&lt; EOF<br>Types: deb<br>URIs: https://packages.smallstep.com/stable/debian<br>Suites: debs<br>Components: main<br>Signed-By: /etc/apt/keyrings/smallstep.asc<br>EOF<br>sudo apt-get update &amp;&amp; sudo apt-get -y install step-cli</pre><h3>CREATE THE CA CONTAINER</h3><p>Firstly we’ll create a container using the image provided by Smallstep.</p><p>It will listen on port 9000.</p><p>Run:</p><pre>docker run -d --name step-ca --restart unless-stopped \<br>    -v step:/home/step \<br>    -p 9000:9000 \<br>    -e &quot;DOCKER_STEPCA_INIT_NAME=Smallstep&quot; \<br>    -e &quot;DOCKER_STEPCA_INIT_DNS_NAMES=localhost,$(hostname -f),&lt;hostname-clients-use-to-reach-the-ca&gt;,&lt;ipaddr-clients-will-use-to-reach-the-ca&gt;&quot; \<br>    -e &quot;DOCKER_STEPCA_INIT_REMOTE_MANAGEMENT=true&quot; \<br>    -e &quot;DOCKER_STEPCA_INIT_SSH=true&quot; \<br>    smallstep/step-ca:0.30.2</pre><p>Ensure that you use a container tag that is not ‘latest’ as using the latest tag is bad practice since the container could introduce breaking changes if it’s ever recreated.</p><p>I’ll use <a href="https://hub.docker.com/r/smallstep/step-ca?tag=0.30.2"><strong>smallstep/step-ca:0.30.2</strong></a> instead.</p><p>What does this container do?</p><blockquote>This container creates a root key (meant to be kept private), a root certificate (can be shared publicly) as well as an intermediate certificate.</blockquote><blockquote>The root certificate is what all the devices trust.</blockquote><blockquote>However the root private key (currently stored in /var/lib/docker/volumes/step/_data/secrets/) must be kept secure. How you keep it secure is up to you. I’ve seen it recommended that people store it offline onto a Yubikey, and they use the intermediate <strong>key </strong>to sign everything.</blockquote><blockquote>The intermediate certificate is signed by the root, and its private key is what actually signs your service certificates.</blockquote><blockquote>If the intermediate <strong>key </strong>is ever compromised, you can use the root key to sign a new intermediate certificate, and your devices continue trusting the same root.</blockquote><p>What does <strong>DOCKER_STEPCA_INIT_DNS_NAMES</strong> do?</p><blockquote>This is the list of hostnames and IP addresses that clients will use to reach the CA.</blockquote><blockquote>The CA puts these into its own HTTPS certificate. When a client connects, it checks that the hostname or IP address that it used is actually in the certificate. If it isn’t then the connection is refused by the <strong>client</strong>.</blockquote><blockquote>For example, earlier I tried to connect to the CA using its IP address (rather than the DNS name that I specified) and therefore my connection was rejected.</blockquote><p>What does <strong>DOCKER_STEPCA_INIT_SSH=true</strong> do?</p><blockquote>This sets up the CA to also issue SSH certificates as well as HTTPS certificates.</blockquote><blockquote>Without this setting, the CA can only issue HTTPS certificates.</blockquote><h3>FIND THE USERNAME AND PASSWORD FOR THE CA</h3><p>The logs for this container will display the CA admin username and password:</p><pre>docker logs step-ca</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/933/1*DBJsgZ03srB25IzXE__Tyg.png" /></figure><h3>HAVE YOUR MACHINE TRUST THE CA</h3><p>We can connect to the Certificate Authority now that it’s up and running. To do this, clients need to know the URL, and the SHA256 fingerprint of the Certificate Authority.</p><pre>{<br>  CA_FINGERPRINT=$(docker run --rm -v step:/home/step smallstep/step-ca:0.30.2  step certificate fingerprint certs/root_ca.crt)<br>  step ca bootstrap --ca-url https://localhost:9000 --fingerprint $CA_FINGERPRINT --install<br>}</pre><p>What does this do?</p><blockquote>This spins up a temporary container that reads the root certificate’s fingerprint from the step volume (the volume is on the host at /var/lib/docker/volumes/step/_data) then places it into a variable called $CA_FINGERPRINT.</blockquote><blockquote>Then it sets up the client so that it can talk to the CA securely by downloading the root certificate (the root cert that needs to be installed on every device in order to trust the CA) and then cross-referencing it with the SHA256 hash. This is called ‘bootstrapping’.</blockquote><blockquote>The install flag adds the root certificate to the client’s system trust store, so your programs like curl and browsers will trust any certificate that the CA issues.</blockquote><p>Run a health check:</p><pre>curl https://localhost:9000/health</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/930/1*jSBSsvm6IvcEHXwijdt_jA.png" /></figure><h3>TRUSTING THE CA ON LINUX CLIENTS</h3><p>Every client needs the root certificate installed before it will trust certificates issued by the CA.</p><p><strong>The process is the same as above, except the client reaches the CA over the network instead of through </strong><strong>localhost.</strong></p><ol><li>On the client install step-cli. <a href="https://smallstep.com/docs/step-cli/installation/">Follow the documentation for your OS</a>.</li></ol><p>For Debian/Ubuntu:</p><pre>sudo apt-get update &amp;&amp; sudo apt-get install -y --no-install-recommends curl gpg ca-certificates<br>sudo install -d -m 0755 /etc/apt/keyrings<br>sudo curl -fsSL https://packages.smallstep.com/keys/apt/repo-signing-key.gpg -o /etc/apt/keyrings/smallstep.asc<br>sudo tee /etc/apt/sources.list.d/smallstep.sources &gt; /dev/null &lt;&lt; EOF<br>Types: deb<br>URIs: https://packages.smallstep.com/stable/debian<br>Suites: debs<br>Components: main<br>Signed-By: /etc/apt/keyrings/smallstep.asc<br>EOF<br>sudo apt-get update &amp;&amp; sudo apt-get -y install step-cli</pre><p>2. Obtain the root certificate’s fingerprint. This can be shared publicly as the hash doesn’t allow anyone to do anything with your CA.</p><p>On the CA <strong>HOST:</strong></p><pre>docker exec step-ca step certificate fingerprint certs/root_ca.crt</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Dgw9C2RZ1LjQ1x7fUX28Nw.png" /></figure><p>3. On the <strong>CLIENT</strong>, run these commands to download the root certificate, cross-reference with SHA256 fingerprint, and if it passes, install it to the trust store.</p><pre>CA_FINGERPRINT=&lt;ROOT_CERT_FINGERPRINT&gt;</pre><p>Ensure that you change the IP address to the correct IP address of the CA:</p><pre>step ca bootstrap --ca-url https://&lt;CA_IP_ADDRESS&gt;:9000 --fingerprint $CA_FINGERPRINT --install</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*VQM2I9z8up2kEUrVVjWQCA.png" /></figure><p>4. Confirm that it actually works:</p><pre>curl https://&lt;CA_IP_ADDRESS_OR_DNS_NAME&gt;:9000/health</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*EJLygvjHDY8wNdflaI-J2g.png" /></figure><p>If it fails it would look like this:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*URXxaXFRbll_ljZ5IUem1g.png" /></figure><p>If you ever want to uninstall the root certificate:</p><pre>step certificate uninstall ~/.step/certs/root_ca.crt</pre><pre>rm -rf ~/.step</pre><h3>TRUSTING THE CA ON WINDOWS CLIENTS</h3><ol><li>On the client install step-cli. <a href="https://smallstep.com/docs/step-cli/installation/">Follow the documentation for your OS</a>.</li></ol><pre>winget install Smallstep.step</pre><p>2. Run Powershell as an admin and bootstrap the root:</p><pre>step ca bootstrap --ca-url https://&lt;CA_HOSTNAME_OR_IP_FROM_DNS_NAMES&gt;:9000 --fingerprint &lt;ROOT_CERT_FINGERPRINT&gt; --install</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ERhL6EKEFU087sH8TEZVMA.png" /></figure><h4>CHANGES FOR FIREFOX</h4><p>If you use a Chromium-based browser then your connection should say secure. However if you use Firefox then it may still say that it’s not secure:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*LwpUihVCXd1Vi58AU8HtdQ.png" /></figure><p>This is because Firefox ignores the Windows/Linux trusted store and prefers its own.</p><p>To fix it we should import the root certificate into Firefox directly:</p><ol><li>Firefox settings</li><li>Privacy &amp; Security</li><li>Advanced Settings</li><li>Certificates</li><li>Manage Certificates</li><li>Authorities tab &gt; Import</li><li>Choose root_ca.crt ( %USERPROFILE%\.step\certs\root_ca.crt)</li><li>Tick ‘Trust this CA to identify websites”</li></ol><h3>ISSUE A CERTIFICATE FOR YOUR SERVICE</h3><p>This machine will need <strong>step-cli</strong> installed and bootstrapped to the CA (follow the above steps).</p><p><strong>On the machine running the service that needs the certificate</strong>, request one from the CA.</p><p>Create the certs directory:</p><pre>mkdir -p ~/certs</pre><p>Use the hostname that you will type into the browser to reach the service:</p><pre>step ca certificate &lt;service-hostname&gt; \<br>~/certs/&lt;container-name&gt;.crt \<br>~/certs/&lt;container-name&gt;.key \<br>--san &lt;main-service-hostname&gt; \ <br>--san &lt;any-additional-hostnames/ip-addresses&gt;</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/794/1*680QxFG2IXWqRIcl7UjK6g.png" /></figure><p>This command creates a Certificate Signing Request (CSR) and sends it to the CA, which signs it and returns the certificate.</p><p><strong>IMPORTANT — Name the certificate the same name as the service’s container. The auto-renew step uses this name to restart the container after each renewal. For example my grafana server’s container name is called “grafana”.</strong></p><p><strong>If the names don’t match, the service will keep using the old certificate until it expires.</strong></p><p>When it asks ‘What provisioner key do you want to use?’ select ‘admin’ and then enter your admin password.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*qkzWGUeVEPA5sJbj4fgVoA.png" /></figure><h3>GIVE THE SERVICE CONTAINER ACCESS TO THE CERTIFICATE</h3><p>step creates the certificate files so that only your user can read them, however the container needs to be able to read them too.</p><ol><li>On the machine hosting the container, find out which user the container runs as:</li></ol><pre>docker exec &lt;container-name&gt; id</pre><p>Look at the <strong>uid </strong>at the start of the output. If it’s <strong>uid=0(root)</strong>, the container runs as root and can already read the files, so skip to step 3.</p><p>If the <strong>uid </strong>is any other number, continue to step 2, even if the gid is 0.</p><p>For example, Grafana shows <strong>uid=472(grafana) gid=0(root)</strong>. It runs as an ordinary user, so it still needs step 2:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/701/1*4Ztpv4d0vg2hDqqKchvvaA.png" /></figure><p>2. Note the gid number. Give that group read-only access to the key and make the certificate readable:</p><pre>sudo chgrp &lt;gid&gt; ~/certs/&lt;container-name&gt;.key<br>chmod 640 ~/certs/&lt;container-name&gt;.key<br>chmod 644 ~/certs/&lt;container-name&gt;.crt</pre><p>The certificate is public (644) and the key is restricted to those who own it and the container’s group (640).</p><p>3. Finally, point your service at the certificate.</p><p>We need to update the docker-compose file for our <strong>services </strong>container (eg Vaultwarden, Grafana, Frigate, etc), not the step-ca container.</p><p>Add this line to the docker-compose file for your container to mount the certs/key files into the container read-only:</p><pre>    volumes:<br>      - ${HOME}/certs/&lt;.crt_file&gt;.crt:/certs/&lt;.crt_file&gt;.crt:ro<br>      - ${HOME}/certs/&lt;.key_file&gt;.key:/certs/&lt;.key_file&gt;.key:ro</pre><p>Then tell the service to use the certificate. Every service has its own HTTPS (or TLS) settings, so check your service’s documentation , and point them at:</p><ul><li>Certificate: /certs/&lt;container-name&gt;.crt</li><li>Key: /certs/&lt;container-name&gt;.key</li></ul><p>If you go to the official documentation page, there should be a section for SSL/TLS/HTTPS (example <a href="https://docs.frigate.video/configuration/tls">https://docs.frigate.video/configuration/tls</a>)</p><blockquote>If you are struggling to find the appropriate environment variables as I was, ask an LLM:</blockquote><blockquote>“I’m running <strong>&lt;service name&gt;</strong> in Docker using the image <strong>image:tag</strong>, set up with docker compose. I have a TLS certificate and private key mounted into the container at /certs/&lt;container-name&gt;.crt and /certs/&lt;container-name&gt;.key. What do I add to my compose file to make the service serve HTTPS using these files? Please give the exact environment variables (or config file settings, if it doesn’t support environment variables), and link to the official documentation where they’re listed.”</blockquote><p>For example, Grafana uses these environment variables, added under the service’s environment:</p><pre>  GF_SERVER_PROTOCOL: https<br>  GF_SERVER_CERT_FILE: /certs/grafana.crt<br>  GF_SERVER_CERT_KEY: /certs/grafana.key</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*kPfApI_fO1Py8TJK6VFM2g.png" /></figure><p>Check if there’s any errors with the config:</p><pre>docker compose config</pre><p>Then recreate the container:</p><pre>docker compose up -d</pre><h3>ENABLE AUTO-RENEW FOR CERTIFICATES</h3><p>Certificates issued by the CA are intentionally short-lived. This is because if a key is ever stolen then it stops being useful after the certificate expires (24hr by default).</p><p>The CA doesn’t renew certificates itself. Each machine holding a certificate is responsible for renewing its own.</p><p>Here is the <a href="https://smallstep.com/docs/step-ca/renewal/#automated-renewal">official documentation for more detail</a>.</p><p>Create the <strong>service </strong>on the machine that hosts the service whose certificate you want to auto-renew.</p><p>Run this as your normal user (not root):</p><pre>sudo tee /etc/systemd/system/cert-renewer@.service &gt; /dev/null &lt;&lt; EOF<br>[Unit]<br>Description=Renew certificate %i<br>After=network-online.target<br><br>[Service]<br>Type=oneshot<br>User=$USER<br>Environment=STEPPATH=$HOME/.step<br>Environment=CERT=$HOME/certs/%i.crt<br>Environment=KEY=$HOME/certs/%i.key<br>ExecCondition=/usr/bin/step certificate needs-renewal \${CERT}<br>ExecStart=/usr/bin/step ca renew --force \${CERT} \${KEY}<br>ExecStartPost=-/bin/chmod 644 \${CERT}<br>ExecStartPost=-/bin/sh -c &#39;docker inspect -f &quot;{{.State.Running}}&quot; %i 2&gt;/dev/null | grep -qx true &amp;&amp; docker restart %i&#39;<br>EOF</pre><ul><li>The first ExecStartPost line keeps the certificate readable by the container after each renewal, since renewing rewrites the certificate file.</li><li>The second ExecStartPost line checks if the container is running, and only restarts it if it’s already up (this is because most services read their certs when they start).</li></ul><p>Confirm it looks good:</p><pre>cat /etc/systemd/system/cert-renewer@.service</pre><p>Create the <strong>timer </strong>on the machine that hosts the service whose certificate you want to auto-renew:</p><pre>sudo tee /etc/systemd/system/cert-renewer@.timer &gt; /dev/null &lt;&lt; EOF<br>[Unit]<br>Description=Check certificate %i for renewal<br><br>[Timer]<br>OnCalendar=*:0/15<br>RandomizedDelaySec=60<br>Persistent=true<br><br>[Install]<br>WantedBy=timers.target<br>EOF</pre><p>Confirm it looks good:</p><pre>cat /etc/systemd/system/cert-renewer@.timer</pre><p>Now reload systemd:</p><pre>sudo systemctl daemon-reload</pre><p>Find the certificate (first you have to copy the certificate name):</p><pre>basename -s .crt ~/certs/*.crt</pre><p>Note the certificate name:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/779/1*_dpwnDwG0UZU-SuSDhwTiQ.png" /></figure><p>Enable the timer:</p><p><strong>IMPORTANT — YOU HAVE TO RUN THIS PART EACH TIME YOU MAKE A NEW CERTIFICATE</strong></p><pre>sudo systemctl enable --now cert-renewer@&lt;cert-name&gt;.timer</pre><p>In this example this becomes:</p><pre>sudo systemctl enable --now cert-renewer@grafana.timer</pre><p>List the active timers that start with cert-renewer:</p><pre>systemctl list-timers &#39;cert-renewer*&#39;</pre><p>You can close out of it by pressing ‘q’.</p><p>Confirm that the service itself works:</p><pre>sudo systemctl start cert-renewer@grafana.service<br>journalctl -u cert-renewer@grafana.service -n 20</pre><p>The logs may say that it was skipped, this is fine since it’s not time to renew the certificate.</p><p>However if you see a ‘file not found’ error instead, then that may be due to a misconfiguration.</p><h3>CONCLUSION</h3><p>You now have a private Certificate Authority running in Docker, with its root certificate trusted by your clients, ready to issue HTTPS and SSH certificates to your home lab.</p><p>From here, the next step is setting up SSH certificates so that you don’t have to copy keys into authorized_keys any more.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=f39681671068" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Exchange Online: Find a User’s Distribution Lists]]></title>
            <link>https://medium.com/@lumenyx/exchange-online-find-a-users-distribution-lists-9c3e9acb0e81?source=rss-322c8ff982a8------2</link>
            <guid isPermaLink="false">https://medium.com/p/9c3e9acb0e81</guid>
            <category><![CDATA[microsoft]]></category>
            <dc:creator><![CDATA[Lumenyx]]></dc:creator>
            <pubDate>Wed, 30 Sep 2026 07:48:38 GMT</pubDate>
            <atom:updated>2026-10-19T02:58:44.218Z</atom:updated>
            <content:encoded><![CDATA[<p>Exchange Online: Find a User’s Distribution Lists</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/860/0*v7sGTW3Xfs4ErGAy.jpg" /></figure><p>Unfortunately there isn’t a simple way to identify what Distribution Lists a user is a part of in Microsoft Exchange.</p><p>Thankfully we can use this Powershell script.</p><p>Disclaimer: This is vibe-coded. This is just for my internal notes so I can copy and use it whenever I need.</p><h3>FINDING A SINGLE USER’S DISTRIBUTION LISTS</h3><p>Connect to Exchange:</p><pre>Connect-ExchangeOnline</pre><p>Declare the user’s email address:</p><pre>$userEmail = &quot;user1@company.com&quot;</pre><p>Run the script. This is in one line so we can paste it without worrying about line-break issues:</p><pre>$dn = (Get-Recipient -Identity $userEmail -ErrorAction Stop).DistinguishedName -replace &quot;&#39;&quot;,&quot;&#39;&#39;&quot;; $results = Get-DistributionGroup -ResultSize Unlimited -Filter &quot;Members -eq &#39;$dn&#39;&quot; | Select-Object @{n=&#39;DistributionList&#39;;e={$_.DisplayName}}, Alias, @{n=&#39;PrimarySMTP&#39;;e={$_.PrimarySmtpAddress}}; $results | Format-Table -AutoSize; Write-Host &quot;Found $(@($results).Count) distribution list(s).&quot; -ForegroundColor Green</pre><h3>FINDING MULTIPLE USERS DISTRIBUTION LISTS</h3><p>Connect to Exchange:</p><pre>Connect-ExchangeOnline</pre><p>Declare the email addresses you want to query:</p><pre>$userEmails = @(&quot;user1@company.com&quot;, &quot;user2@company.com&quot;)</pre><p>Run the script:</p><pre>$results = foreach ($u in $userEmails) { try { $dn = (Get-Recipient -Identity $u -ErrorAction Stop).DistinguishedName -replace &quot;&#39;&quot;,&quot;&#39;&#39;&quot;; Get-DistributionGroup -ResultSize Unlimited -Filter &quot;Members -eq &#39;$dn&#39;&quot; | Select-Object @{n=&#39;User&#39;;e={$u}}, @{n=&#39;DistributionList&#39;;e={$_.DisplayName}}, Alias, @{n=&#39;PrimarySMTP&#39;;e={$_.PrimarySmtpAddress}} } catch { Write-Warning &quot;Could not find recipient $u&quot; } }; $results | Sort-Object User | Format-Table -AutoSize; Write-Host &quot;Found $(@($results).Count) membership(s).&quot; -ForegroundColor Green</pre><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=9c3e9acb0e81" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[The Speed Runner’s Beginner Lab to the Azure Cloud: Lab 07 — Manage Azure Storage]]></title>
            <link>https://medium.com/@lumenyx/the-speed-runners-beginner-lab-to-the-azure-cloud-lab-07-manage-azure-storage-c293a22fd1e0?source=rss-322c8ff982a8------2</link>
            <guid isPermaLink="false">https://medium.com/p/c293a22fd1e0</guid>
            <category><![CDATA[az-104]]></category>
            <category><![CDATA[azure]]></category>
            <dc:creator><![CDATA[Lumenyx]]></dc:creator>
            <pubDate>Sat, 05 Sep 2026 23:06:41 GMT</pubDate>
            <atom:updated>2026-09-26T23:15:21.049Z</atom:updated>
            <content:encoded><![CDATA[<p><em>Your organization is currently storing data in on-premises data stores. Most of these files are not accessed frequently.</em></p><p><em>You would like to minimize the cost of storage by placing infrequently accessed files in lower-priced storage tiers. You also plan to explore different protection mechanisms that Azure Storage offers, including network access, authentication, authorization, and replication.</em></p><p><em>Finally, you want to determine to what extent Azure Files is suitable for hosting your on-premises file shares.</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/788/0*BbYC50uHFxpbCmyN.png" /><figcaption><a href="https://microsoftlearning.github.io/AZ-104-MicrosoftAzureAdministrator/Instructions/Labs/LAB_07-Manage_Azure_Storage.html">https://microsoftlearning.github.io/AZ-104-MicrosoftAzureAdministrator/Instructions/Labs/LAB_07-Manage_Azure_Storage.html</a></figcaption></figure><h3>TASK 1: CREATE AND CONFIGURE A STORAGE ACCOUNT</h3><p>Within the Azure Portal go to Storage accounts &gt; Blob Storage &gt; Create:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/824/1*XP40DBNFgRml3XB-pRRkGQ.png" /></figure><p>We want to create it with the following settings (you can change the region if you want):</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/842/1*EI37pYFYUPgZ7E7KPOgeiQ.png" /></figure><ul><li>Resource group: az104-rg7</li><li>Storage account name: Choose your own</li><li>Primary service: Azure Blob Storage or Azure Data Lake Storage</li><li>Performance: Standard</li><li>Redundancy: Geo-redundant storage (GRS)</li><li>Enable ‘Make read access to data available in the event of regional unavailability’.</li></ul><p>Accept the defaults for the <strong>Advanced </strong>tab.</p><p>Within the <strong>Networking</strong> tab, <strong>disable public access</strong>:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/822/1*oObD_4-y8zIIXM0YJZYPAw.png" /></figure><p>Regarding <strong>Data protection</strong>, accept the defaults but take note of the 7 day soft delete policy. Knowing about this could come in handy further in your career:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/869/1*Z3zhK5Tqz5QMMQLZbKoV1Q.png" /></figure><p>Accept the defaults for the <strong>Security, Encryption and</strong> <strong>Tags</strong> tab.</p><p>Review and create the storage account</p><h4>WHITELIST OUR PUBLIC IP ADDRESS</h4><p>Once the Storage account is created, we want to whitelist our public IP address.</p><p>Once your Storage account is deployed, click on <strong>Go to resource</strong>:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*JMTG-X1xkqnPSDyJXjbzoQ.png" /></figure><p>Click on <strong>Networking</strong> and click on <strong>Manage </strong>which is underneath public network access:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ysVnpPCRZn2gkec83EpUbg.png" /></figure><p>We want to <strong>Enable public access </strong>and select <strong>Enabled from selected networks</strong>.</p><p>Then we can add our public IPv4 address:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*YgcRgonEmdSDl1nuY982RA.png" /></figure><h4>ENABLE LIFECYCLE MANAGEMENT</h4><p>Now we’ll enable lifecycle management so that any files that haven’t been modified in 30 days get moved to another tier.</p><p>What is lifecycle management?</p><p>Lifecycle management automatically moves data between tiers based on the rules you define, such as how long it’s been since the data was accessed.</p><p>The first thing to note is that there are two main costs:</p><ul><li>There’s the fixed cost, which is the amount that you have to pay Azure for them just to hold your data (regardless of if you access it or not).</li><li>The access cost, which is applied every time you access the data.</li></ul><p>Azure has numerous tiers for storage. There’s tiers are Hot, Cool, Cold and Archive.</p><p>Different tiers are used for different purposes. The Hot tier is used for frequently accessed data. It has the highest fixed cost (the cost just to hold it) but a lower access/transaction cost.</p><p>Compared to the Cold tier which has a lower fixed cost but is pricier than Hot and Cool each time it’s accessed.</p><p>Below is a table provided by Microsoft with more information:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/688/1*yb4WruLd32YJkrsgQn9QxQ.png" /><figcaption><a href="https://learn.microsoft.com/en-us/azure/storage/blobs/access-tiers-overview#summary-of-access-tier-options">https://learn.microsoft.com/en-us/azure/storage/blobs/access-tiers-overview#summary-of-access-tier-options</a></figcaption></figure><p>Based on this knowledge, which tier should website images be stored in? They should be stored in the Hot tier.</p><p>Which tier should website logs be stored in? Depending on the types of logs they could be anywhere between Cool and Archive.</p><p>Let’s set up a lifecycle management rule.</p><p>Click on <strong>Lifecycle management</strong> &gt; <strong>Add a rule</strong>:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/895/1*nhoT7ZCZrPMYfwVuOeAbfA.png" /></figure><p>Configure the rule name to be <strong>MoveToCool </strong>and configure the same settings as the screenshots below:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/735/1*caSX3lAfKzqbsQJwcezsDg.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/767/1*VtZ899IC5XvsyT2nQIYh8A.png" /></figure><h3>TASK 2: CREATE AND CONFIGURE SECURE BLOB STORAGE</h3><p>Now we’re going to set up storage that becomes immutable for a time limit of 180 days.</p><p>Within the Storage account go to Containers &gt; Add container:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*pi8H0otZ4_E3HkUNw56nvA.png" /></figure><ul><li>Name: data</li><li>Anonymous access level: Private (no anonymous access)</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/432/1*0se6ft8Oln3b_jjIpDRlnQ.png" /></figure><p>What is anonymous access?</p><p>In this context it’s the ability to view files and photos without needing an account.</p><p>Now that the ‘data’ container has been created, click on the ellipsis (…) and select ‘Access policy’:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*w4314433xXvh9lgKmPhvQA.png" /></figure><p>Now to add the policy:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/579/1*3b9gXcAkQstPtz2VlgL9OQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/573/1*x8qTB_Lg04L-oWNGHZz3_A.png" /></figure><ul><li>Policy type: Time-based retention</li><li>Set retention period for: 180</li></ul><p>What are we actually doing? What is ‘Time-based retention’?</p><blockquote>We’re locking every blob written to this container so that it can’t be modified or deleted for 180 days, regardless of who it is.</blockquote><blockquote>This can be used for compliance/legal reasons but can also be used to protect against ransomware/accidental deletion.</blockquote><p>What about the other option? What is ‘Legal hold’?</p><blockquote>Legal hold is when we want to apply the same lock as time-based retention indefinitely.</blockquote><blockquote>The lock can also be removed manually by someone with permission like an administrator.</blockquote><blockquote>It’s used for when data needs to be preserved for an investigation or an audit that has no predetermined end date.</blockquote><h4>MANAGE BLOB UPLOADS</h4><p>Give yourself the ability to access the storage account using its access key (as opposed to using Entra).</p><p>Within the storage account go to Configuration &gt; Allow storage account key access &gt; Enabled</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/878/1*MDoY4YKwmL8ZfJFWw5YvZg.png" /></figure><p>Access Control (IAM) &gt; Add &gt; Add role assignment:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/971/1*YPBPCIgVtYmhLVMlFjz6UA.png" /></figure><p>Add the <strong>Storage Blob Data Contributor</strong> role:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/991/1*QQ_FGJtGt3do-XrG_4LJUA.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/712/1*wBc3iwoj-PlEyJIUSEDSvQ.png" /></figure><p>Also assign the <strong>Storage File Data Privileged Contributor </strong>Role as well:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*w_r2UzxmzwU2ZRI88p5yZg.png" /></figure><p>Go back to the data container we created earlier and click on upload:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/782/1*qCVEPK4fc7r0nGdt-SnaiQ.png" /></figure><p>Add a random file:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/581/1*QHPtK3ehZpHE_IKY3Y3BKA.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/85/1*tijQA1nPv3LlqFyQ46EAVg.png" /></figure><p>Confirm that you have a new folder called ‘securitytest’ and that your file was uploaded:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/402/1*9WDG4h-G1dNIfbyiHGlmdg.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/455/1*dD7p0_PMroRsUhaef6RDEA.png" /></figure><p>Now we’re going to check out our photo. If it comes back with an error then that means that our security settings are correctly set up:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1014/1*5XFmHklUMkVWS2bUVnTTIA.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*7DEDmlsAxKQhdyzw7YhfzA.png" /></figure><p>The error shown is because we have set the setting to be <strong>Private (no anonymous access).</strong></p><p>(See <a href="https://medium.com/@lumenyx/troubleshooting-azure-storage-account-permissions-cab0741820f6">Troubleshooting Azure Storage Account Permissions</a>)</p><p>Here are the relevant settings (don’t change them yet):</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/931/1*FLHQCW_lo4cvAKZCy1Tmbg.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/613/1*ag7sCkfhmRxs3Pf8zcx_Rg.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/599/1*tiDmtx_34Yq-tJ_fACzNYw.png" /></figure><h4>CONFIGURE LIMITED ACCESS TO THE BLOB STORAGE</h4><p>We’re going to generate a SAS URL to view the file. This file can only be viewed via this URL with the settings that we set.</p><p>Follow the screenshot below:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*habrFnIh1P1SOyqkMAscSg.png" /></figure><p>Confirm that it works:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/969/1*5DMD6qFvh8BPQcf33_lWHA.png" /></figure><h3>TASK 3: CREATE AND CONFIGURE SECURE AZURE FILE STORAGE</h3><p>In this phase we’ll create and configure Azure File shares, then use the Storage Browser to manage the file share.</p><h4>CREATE THE FILE SHARE AND UPLOAD A FILE</h4><p>Storage accounts &gt; Blob Storage &gt; Classic file shares &gt; + Classic file share</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*9mgwERK_avuABqPc5Ivwlg.png" /></figure><ul><li>Name: share1</li><li>Access tier: Transaction optimized</li><li>Disable backup for lab simplicity</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/803/1*CtloAqsl0c4OOc_eZMQcUw.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/762/1*z2xcekV6MToG5rQ0nBxqmA.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/136/1*HKGe0f1BTmAVKIS9rnPSKw.png" /></figure><h4>EXPLORE STORAGE BROWSER AND UPLOAD A FILE</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*surPoFZ3n8utkLXRjvqm9g.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/553/1*_NaNLeWLUjV9KSXcss97Ng.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/580/1*jWcAPpdRFqnwHmYDn2uD2g.png" /></figure><h4>RESTRICT ACCESS TO THE STORAGE ACCOUNT</h4><p>In this phase we’ll set up a VNet and de-list our IP address so we can test if unauthorised IP addresses will be denied.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*D1Jqll_Px4gEUkKExBBVsg.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/841/1*ib_rt6qLOrpWEXJKlraUTA.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/523/1*V63zUMSmTwH6FhwECs44cg.png" /></figure><p>Now we’re going to set up a service endpoint.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*u7DoV-31F869Uq_i3mhkug.png" /></figure><p>What is a Service Endpoint?</p><blockquote>A Service Endpoint tags your traffic from your VNet’s subnet with that subnet’s source info as it leaves toward an Azure PaaS service (like a storage account), so the service can allow or deny this access.</blockquote><blockquote>The traffic also routes through Microsoft’s backbone network instead of the public internet.</blockquote><figure><img alt="" src="https://cdn-images-1.medium.com/max/576/1*h9YySczQwY9Z3LCV_PSqaw.png" /></figure><p>The Service Endpoint attaches a tag to traffic leaving 10.0.0.0/24 and sends it to Azure Storage (Microsoft.Storage) over Microsoft’s backbone network instead of the public internet.</p><p>This improves security as it goes over the Microsoft backbone network, therefore removing the need for the traffic to go over the public internet at all.</p><p>It also improves speed as using the Microsoft backbone network utilises a more optimised route than a public internet path.</p><p>Now we’re going to remove our whitelisted IP address and replace it with the VNet we just created.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*K_qCH45mi6RrhAdfu9WO8g.png" /></figure><p>Add it to our whitelisted networks:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/841/1*9GExsplhJmLMaR_Yi0nFKA.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/301/1*GBX2Ayomhvp-m0U1JZfbzw.png" /></figure><p>Remove our public IP:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/483/1*rZu9o1umnCc0RywM-HNM9Q.png" /></figure><p>Confirm that your access to view the file share has been revoked:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*qSTp0bVkg8b_vXFRZDY8iQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*H42ScwFgzjngzIMwbP2BKQ.png" /></figure><h3>CLEANUP YOUR RESOURCES</h3><p>We can remove all our resources by deleting our Resource Group.</p><p>Open Cloud Shell in Powershell and run this command:</p><pre>Remove-AzResourceGroup az104-rg7 -Force</pre><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=c293a22fd1e0" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Troubleshooting Azure Storage Account Permissions]]></title>
            <link>https://medium.com/@lumenyx/troubleshooting-azure-storage-account-permissions-cab0741820f6?source=rss-322c8ff982a8------2</link>
            <guid isPermaLink="false">https://medium.com/p/cab0741820f6</guid>
            <category><![CDATA[azure]]></category>
            <category><![CDATA[azure-storage-account]]></category>
            <dc:creator><![CDATA[Lumenyx]]></dc:creator>
            <pubDate>Sat, 05 Sep 2026 05:52:53 GMT</pubDate>
            <atom:updated>2026-09-26T01:01:00.970Z</atom:updated>
            <content:encoded><![CDATA[<p>Here’s a quick cheat sheet on narrowing down Azure Storage account permission issues as well as troubleshooting blob storage errors.</p><h3>TROUBLESHOOTING ISSUES WITH ACCESSING THE STORAGE ACCOUNT ITSELF</h3><h4>COMMON CAUSE 1: YOUR NETWORK HASN’T BEEN WHITELISTED</h4><p>When you try to access the Container (Storage account &gt; Data storage &gt; Containers &gt; Your Container) it gives you an error 403:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*PSYR6dD2V-Nxp0yfq7fk1w.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*4HbsiURa39jlEaAvdmngKg.png" /></figure><p>To check and confirm if your IP address is whitelisted go to:</p><p><strong>Storage account &gt; Security + Networking &gt; Networking &gt; Public network access &gt; Manage</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*xmAVBV2Ko0Dduk06AIbeQQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*pMCybpwu6ery45mH-b97gA.png" /></figure><p>For this example we could configure a Private endpoint to access the storage account, however we’ll instead change the setting to ‘Enable’ and add our public IP address:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*CgYfVJO-K_FxSITgRS1gjg.png" /></figure><h4>COMMON CAUSE 2: INSUFFICIENT ROLE PRIVILEGES</h4><p>This error message below is due to insufficient privileges.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Gip9Ox7p-CE2HFsE9NaRUg.png" /></figure><p>We can check what roles the user has and assign them as necessary:</p><pre>EMAIL=&quot;UserEmailAddress@domain.com&quot;</pre><pre>az role assignment list --assignee $EMAIL --all --output table</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*54CiAOnovppzW6e7VXpNWA.png" /></figure><p>We’ll have to add the necessary roles for what access is needed.</p><p>Examples of roles to add:</p><ul><li>Reader</li><li>Storage Blob Data Contributor</li><li>Storage Blob Data Owner</li></ul><h3>TROUBLESHOOTING ISSUES WITH ACCESSING FILES IN STORAGE ACCOUNTS</h3><h4>COMMON CAUSE 1: BLOB ANONYMOUS ACCESS IS DISABLED</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*wDzlNRuoWxl2qwyydIdRdw.png" /></figure><p>The issue may be that Blob anonymous access is disabled. Whether it should or shouldn’t be disabled depends on the organisation, however this is something to check.</p><p>Check:</p><p><strong>Storage Account &gt; Settings &gt; Configuration &gt; Allow Blob anonymous access</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*PXg8LGlv-c9QtxmjQChwLw.png" /></figure><p><strong>COMMON CAUSE 2: THE ACCESS LEVEL ON THE CONTAINER IS SET TO PRIVATE</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*6WLJwdnMmMllgryyrA8HIA.png" /></figure><p>Go to:</p><p><strong>Storage account &gt; Storage browser &gt; Blob containers &gt; Change access level</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/936/1*o5lFFMd282ZqFJF0_K-ECw.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/490/1*CUZ1M8L9Azz4IbN1DhJzjA.png" /></figure><p>Changing the setting as a test has confirmed that this was the issue:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ZWzQUBWMARRP9tr3HJ0ivQ.png" /></figure><p><strong>COMMON CAUSE 3: THE STORAGE ACCOUNT KEY ACCESS IS DISABLED</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/885/1*bVJT4b7wNoWbvMzI1kLZHA.png" /></figure><p>When “Allow storage account key access” is disabled, anything that relies on the storage account keys stops working.</p><p>Roles like the Storage Account Contributor role or even the Owner role can retrieve the account keys and use them to reach the data, however if the storage account key access is disabled, then anyone using this role will be denied.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=cab0741820f6" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Checking Azure Role Assignments with Azure Cloud Shell]]></title>
            <link>https://medium.com/@lumenyx/checking-azure-role-assignments-with-azure-cloud-shell-3dfb7347995a?source=rss-322c8ff982a8------2</link>
            <guid isPermaLink="false">https://medium.com/p/3dfb7347995a</guid>
            <category><![CDATA[rbac]]></category>
            <category><![CDATA[azure]]></category>
            <dc:creator><![CDATA[Lumenyx]]></dc:creator>
            <pubDate>Fri, 04 Sep 2026 10:21:17 GMT</pubDate>
            <atom:updated>2026-09-04T10:21:17.173Z</atom:updated>
            <content:encoded><![CDATA[<p>Do you need to find out which Azure RBAC roles are assigned to specific user accounts without the hassle of going through each subscription, resource group and resource one-by-one in Azure portal?</p><p>Use this quick guide to save yourself some headache.</p><p>Open CloudShell:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/593/1*UgIuhiPnEdD2WDwHX1loJw.png" /></figure><p>Switch to Bash and define the email address you want:</p><pre>EMAIL=&quot;EmailAddress@domain.com&quot;</pre><p>Use this command:</p><pre>az role assignment list --assignee $EMAIL --all --output table</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*4JjBuOMhNU8f05cPFUJ3Ew.png" /></figure><p>If you want to include multiple people then you can use this script:</p><pre>for user in &quot;EmailAddress01@domain.com&quot; &quot;EmailAddres02@domain.com&quot;; do<br>  echo &quot;=== $user ===&quot;<br>  az role assignment list --assignee &quot;$user&quot; --all --output table<br>done</pre><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=3dfb7347995a" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Autobanning Unauthorised IPs Using Fail2Ban]]></title>
            <link>https://medium.com/@lumenyx/autobanning-unauthorised-ips-using-fail2ban-bd28b3f65c4c?source=rss-322c8ff982a8------2</link>
            <guid isPermaLink="false">https://medium.com/p/bd28b3f65c4c</guid>
            <category><![CDATA[linux]]></category>
            <category><![CDATA[fail2ban]]></category>
            <dc:creator><![CDATA[Lumenyx]]></dc:creator>
            <pubDate>Sun, 23 Aug 2026 00:21:52 GMT</pubDate>
            <atom:updated>2026-08-23T00:21:52.797Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*CsQNEa5d7Iy1p78jyOz0-g.png" /><figcaption><a href="https://youtu.be/40SnEd1RWUU?si=WYBF1ctdipBm4uN2&amp;t=165">https://youtu.be/40SnEd1RWUU?si=WYBF1ctdipBm4uN2&amp;t=165</a></figcaption></figure><p>“Fail2Ban v1.1.0 reads log file that contains password failure report and bans the corresponding IP addresses using firewall rules.”</p><p>Here’s how to configure it onto your Linux system.</p><p>Install it:</p><pre>sudo apt install fail2ban -y</pre><p>Start and enable fail2ban:</p><pre>sudo systemctl enable --now fail2ban</pre><p>Go to the config directory:</p><pre>cd /etc/fail2ban</pre><p>The main configuration file is called fail2ban.conf.</p><p>We can copy that to become fail2ban.local if we want to make any changes:</p><pre>cp fail2ban.conf fail2ban.local</pre><p>The .local file will take precedence over the .conf file.</p><p>The .conf file comes installed with the rest of the package but can be overwritten during upgrades. This is why we copy it to .local which will take precedence over .conf.</p><p>We can also do this with jail.conf:</p><pre>cp jail.conf jail.local</pre><h3>APPLY CONFIGURATIONS</h3><p>Now to edit the line with ignoreip to allow our own public IP. This is so we don’t accidentally ban ourselves:</p><pre>sudo vim jail.local</pre><p>Find this line, uncomment it and add your public IP address at the end:</p><pre>ignoreip = 127.0.0.1/8 ::1 &lt; YOUR-PUBLIC-IP-ADDRESS &gt;</pre><p>NOTE: This is only if you have a static public IP address.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/796/1*YKN2ea6x5Y-bk0ucBAfoFQ.png" /></figure><p>Now we can set the default values all of the jails.</p><p>These are the values within the file that we may want to change:</p><ul><li><strong>bantime</strong> — How long is an IP address banned for? If you want them permanently banned you would specify -1.</li><li><strong>findtime</strong> — The time window fail2ban looks back over to count failed login attempts before issuing a ban.</li><li><strong>maxretry</strong> — How many failed login attempts before an IP address gets banned.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/975/1*wDs-tfJWnzQ0v2gRAnx5Gw.png" /></figure><p>Now to configure the SSH Daemon settings. We want to enable the SSHD jail:</p><p>Within [sshd] we want to add this line:</p><pre>enabled = true</pre><p>If you use an ssh port that’s not 22, you can update port to be the correct SSH port.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/810/1*85B3oJPByvOWC_muxlUduA.png" /></figure><p>If we want to, we can set up maxretry, bantime, and findtime specifically for each jail. Configuring this per-jail will take precedence over the settings we configured earlier:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/381/1*alK0-93AgrjYWG_VErEQfA.png" /><figcaption><a href="https://youtu.be/40SnEd1RWUU?si=kQAs7bWspV5GwEOn&amp;t=174">https://youtu.be/40SnEd1RWUU?si=kQAs7bWspV5GwEOn&amp;t=174</a></figcaption></figure><p>Restart fail2ban so it picks up on the changes:</p><pre>sudo systemctl restart fail2ban</pre><h3>VIEWING FAIL2BAN</h3><p>We can use this command to view our options with Fail2Ban:</p><pre>fail2ban-client</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1016/1*OoZaTYejYxqH3VVwLbrTQw.png" /></figure><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=bd28b3f65c4c" width="1" height="1" alt="">]]></content:encoded>
        </item>
    </channel>
</rss>