<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Shoban Chiddarth</title>
    <description>The latest articles on DEV Community by Shoban Chiddarth (@shobanchiddarth).</description>
    <link>https://dev.to/shobanchiddarth</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3764440%2Fd3ec9df1-b9a8-4982-bce2-0a6e52ec602f.jpg</url>
      <title>DEV Community: Shoban Chiddarth</title>
      <link>https://dev.to/shobanchiddarth</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/shobanchiddarth"/>
    <language>en</language>
    <item>
      <title>AWS Terraform infrastructure for a FastAPI+PostgreSQL backend</title>
      <dc:creator>Shoban Chiddarth</dc:creator>
      <pubDate>Mon, 23 Mar 2026 14:47:31 +0000</pubDate>
      <link>https://dev.to/shobanchiddarth/aws-terraform-infrastructure-for-a-fastapipostgresql-backend-3dip</link>
      <guid>https://dev.to/shobanchiddarth/aws-terraform-infrastructure-for-a-fastapipostgresql-backend-3dip</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;This blog post showcases the AWS infrastructure written in Terraform for the backend of &lt;code&gt;alumni-connect&lt;/code&gt;, a MVP of a college project written in FastAPI with a PostgreSQL database.&lt;/p&gt;

&lt;p&gt;The terraform repo: &lt;a href="https://github.com/ShobanChiddarth/alumni-connect-terraform" rel="noopener noreferrer"&gt;https://github.com/ShobanChiddarth/alumni-connect-terraform&lt;/a&gt;&lt;br&gt;
The backend repo: &lt;a href="https://github.com/ShobanChiddarth/alumni-connect-backend" rel="noopener noreferrer"&gt;https://github.com/ShobanChiddarth/alumni-connect-backend&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;

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

&lt;p&gt;Since this is an MVP, I went with the absolute minimal for everything. 1 VPC exists on &lt;code&gt;ap-south-1&lt;/code&gt;. It has an internet gateway. Frontend is hosted in netlify, so API requests as well as management traffic (SSH) will enter through the internet gateway.&lt;/p&gt;
&lt;h3&gt;
  
  
  Subnet Design
&lt;/h3&gt;

&lt;p&gt;There are 4 subnets in total.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A public subnet to handle management traffic (&lt;code&gt;ap-south-1a&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;2 private subnets for backend and database EC2 instances (&lt;code&gt;ap-south-1a&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Another public subnet to satisfy AWS's hard requirement of multi AZ for load balancers (&lt;code&gt;ap-south-1b&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The management subnet:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Contains the bastion EC2&lt;/li&gt;
&lt;li&gt;Contains a NAT gateway for EC2 instances in the private subnet to reach the internet&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  Load Balancer
&lt;/h3&gt;

&lt;p&gt;The backend EC2 has no public IP and sits in a private subnet. Its security group only accepts port 8000 from the ALB security group - there is no direct path from the internet to the backend. The ALB is the only entry point.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;listens on port 80 for HTTP traffic from the public internet&lt;/li&gt;
&lt;li&gt;spans across &lt;code&gt;ap-south-1a&lt;/code&gt; (management subnet) and &lt;code&gt;ap-south-1b&lt;/code&gt; (empty subnet) - AWS requires an internet-facing ALB to span at least 2 AZs&lt;/li&gt;
&lt;li&gt;forwards the traffic from the internet to port 8000 in the backend EC2&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  Security Groups Design
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;SSH traffic from the internet is allowed only to the bastion EC2&lt;/li&gt;
&lt;li&gt;SSH traffic to the backend and database EC2s are allowed only from the bastion EC2&lt;/li&gt;
&lt;li&gt;HTTP traffic from the internet is allowed only to the load balancer&lt;/li&gt;
&lt;li&gt;HTTP traffic to the backend is allowed only from the load balancer&lt;/li&gt;
&lt;li&gt;Database traffic (port 5432) to the database EC2 is allowed only from the backend EC2&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  SSH Keys
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Bastion EC2's SSH private key stays on the local computer of the person deploying this&lt;/li&gt;
&lt;li&gt;Other EC2s SSH private key stays in the bastion&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  EC2 instances
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Backend EC2 is set to pull and run docker image &lt;code&gt;shobanchiddarth/alumni-connect-backend:0.0.2&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Database EC2 is set to pull and run docker image &lt;code&gt;postgres:16&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  NAT Gateway Cost Problem
&lt;/h2&gt;

&lt;p&gt;I wrote a LinkedIn post about this. &lt;a href="https://www.linkedin.com/posts/shobanchiddarth_terraform-aws-vpc-activity-7441719825703366656-8_15" rel="noopener noreferrer"&gt;Click here to view it&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you are not able to see it, here are the contents of the post:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;I recently faced a problem with making EC2 instances communicate with the internet &lt;span class="k"&gt;while &lt;/span&gt;deploying a FastAPI+Postgres backend architecture on AWS using Terraform. NAT Gateways exist to solve this - a NAT gateway can be placed &lt;span class="k"&gt;in &lt;/span&gt;a public subnet and traffic can be routed through it.

But NAT Gateways are costly. ~&lt;span class="nv"&gt;$0&lt;/span&gt;.045/hr just to exist, plus data transfer charges.

The solution I used: keep the NAT gateway &lt;span class="k"&gt;in &lt;/span&gt;the Terraform code, but destroy it immediately after deployment. Private instances lose internet access after packages are updated, Docker images are pulled, and containers are running.

terraform destroy &lt;span class="nt"&gt;-target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;aws_nat_gateway.alumni-nat-gw &lt;span class="nt"&gt;-target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;aws_eip.nat-gateway-elastic-ip

I also tried a NAT instance - an EC2 that acts as a router - but ran into configuration issues getting it to actually forward traffic. Ended up not going down that path &lt;span class="k"&gt;for &lt;/span&gt;this project.

This temporary NAT gateway approach is good enough &lt;span class="k"&gt;for &lt;/span&gt;a college project MVP with almost no &lt;span class="nb"&gt;users &lt;/span&gt;that gets destroyed &lt;span class="k"&gt;in &lt;/span&gt;a few hours anyway.

Has anyone dealt with this &lt;span class="k"&gt;in &lt;/span&gt;production? Is there a cleaner pattern &lt;span class="k"&gt;for &lt;/span&gt;private subnet internet access beyond NAT gateway or NAT instance?

&lt;span class="c"&gt;#Terraform #AWS #VPC #CloudComputing #IaC #CloudCost&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Steps to Deploy
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;Clone the repo&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/ShobanChiddarth/alumni-connect-terraform
&lt;span class="nb"&gt;cd &lt;/span&gt;alumni-connect-terraform/infrastructure
&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Set AWS credentials&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;AWS_ACCESS_KEY_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;AWS_SECRET_ACCESS_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Set the database password&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;TF_VAR_db_password&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Deploy&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform init
terraform apply
&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Destroy the NAT gateway after apply&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform destroy &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="nt"&gt;-target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;aws_nat_gateway.alumni-nat-gw &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="nt"&gt;-target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;aws_eip.nat-gateway-elastic-ip
&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;To tear down everything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform destroy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Proof of Deployment
&lt;/h2&gt;

&lt;p&gt;These are screenshots I took when it was live. I then destroyed the whole infrastructure.&lt;/p&gt;

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

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

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

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

&lt;p&gt;I archived the API landing URL (first image) in Wayback Machine. Here is the link: &lt;a href="https://web.archive.org/web/20260322130840/http://alumni-alb-backend-83642402.ap-south-1.elb.amazonaws.com/" rel="noopener noreferrer"&gt;https://web.archive.org/web/20260322130840/http://alumni-alb-backend-83642402.ap-south-1.elb.amazonaws.com/&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;This is the full Terraform infrastructure for the &lt;code&gt;alumni-connect&lt;/code&gt; backend.&lt;/p&gt;

</description>
      <category>terraform</category>
      <category>aws</category>
      <category>cloud</category>
      <category>cloudcomputing</category>
    </item>
    <item>
      <title>IAM Development Lab in Keycloak</title>
      <dc:creator>Shoban Chiddarth</dc:creator>
      <pubDate>Fri, 20 Mar 2026 17:17:13 +0000</pubDate>
      <link>https://dev.to/shobanchiddarth/iam-development-lab-in-keycloak-19i7</link>
      <guid>https://dev.to/shobanchiddarth/iam-development-lab-in-keycloak-19i7</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;This is the ultimate IAM development and employee lifecycle management lab I am doing where I will be making use of the skills I learnt from the &lt;a href="https://www.theforage.com/completion-certificates/ifobHAoMjQs9s6bKS/gmf3ypEXBj2wvfQWC_ifobHAoMjQs9s6bKS_69a0eaeeb1f8e4b8685709b8_1772548764230_completion_certificate.pdf" rel="noopener noreferrer"&gt;TATA virtual internship via Forage: Cybersecurity Analyst - IAM Developer&lt;/a&gt; to develop and implement an IAM solution for a fictional organization.&lt;/p&gt;

&lt;p&gt;I chose Keycloak for this lab instead of SailPoint with Oracle Identity Manager because Keycloak is fully open source. This is a standalone lab independent of OpenLDAP.&lt;/p&gt;

&lt;p&gt;The internship covered&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;IAM fundamentals&lt;/li&gt;
&lt;li&gt;Digital identity&lt;/li&gt;
&lt;li&gt;Authentication&lt;/li&gt;
&lt;li&gt;Authorization&lt;/li&gt;
&lt;li&gt;SSO&lt;/li&gt;
&lt;li&gt;least privilege principle
And then walked through designing and planning a full IAM implementation for a fictional enterprise called TechCorp (but the fictional org we will be representing is called Acme). The proposed solution used &lt;/li&gt;
&lt;li&gt;SailPoint for automated user lifecycle management and&lt;/li&gt;
&lt;li&gt;Oracle Identity Manager for RBAC
with a four-phase implementation plan covering deployment, testing, training, and ongoing monitoring.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This lab takes those same requirements and implements them end-to-end:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A structured org with departments and roles&lt;/li&gt;
&lt;li&gt;RBAC enforced at the role level&lt;/li&gt;
&lt;li&gt;Full user lifecycle management from provisioning to offboarding&lt;/li&gt;
&lt;li&gt;MFA&lt;/li&gt;
&lt;li&gt;SSO via OIDC&lt;/li&gt;
&lt;li&gt;Email-based verification flows&lt;/li&gt;
&lt;li&gt;Brute force protection&lt;/li&gt;
&lt;li&gt;Audit logging 
All running on-premises in VirtualBox with no external dependencies.
## Pre-Requisites&lt;/li&gt;
&lt;/ul&gt;

&lt;ol&gt;
&lt;li&gt;&lt;a href="https://dev.to/shobanchiddarth/the-superior-way-to-make-vms-communicate-with-each-other-as-well-as-host-with-internet-access-42m1"&gt;Superior VM Intercommunication setup&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/shobanchiddarth/setting-up-pi-hole-as-a-custom-dns-server-on-my-home-lab-4jd7"&gt;Pi-Hole&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/shobanchiddarth/setting-up-ssl-https-on-my-home-lab-g45"&gt;Mkcert Local CA TLS (for HTTPS)&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Build Log
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Keycloak Server Initialization
&lt;/h3&gt;

&lt;p&gt;I cloned a debian server VM, put it in the current VirtualBox host only network I have, assigned a static IP (&lt;code&gt;192.168.57.8&lt;/code&gt;), Then I edited its hostname, and mapped DNS record &lt;code&gt;keycloak.acme.internal -&amp;gt; 192.168.57.8&lt;/code&gt; in Pi-Hole.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;debian@keycloak:~$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ifconfig enp0s3
&lt;span class="gp"&gt;enp0s3: flags=4163&amp;lt;UP,BROADCAST,RUNNING,MULTICAST&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;mtu 1500
&lt;span class="go"&gt;        inet 192.168.57.8  netmask 255.255.255.0  broadcast 192.168.57.255
&lt;/span&gt;&lt;span class="gp"&gt;        inet6 fe80::43b5:ca52:a96d:134a  prefixlen 64  scopeid 0x20&amp;lt;link&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="go"&gt;        ether 08:00:27:8a:4a:29  txqueuelen 1000  (Ethernet)
        RX packets 28569  bytes 3772704 (3.5 MiB)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 28550  bytes 2712767 (2.5 MiB)
        TX errors 0  dropped 2 overruns 0  carrier 0  collisions 0

&lt;/span&gt;&lt;span class="gp"&gt;debian@keycloak:~$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;nslookup keycloak.acme.internal
&lt;span class="go"&gt;Server:     192.168.57.3
&lt;/span&gt;&lt;span class="gp"&gt;Address:    192.168.57.3#&lt;/span&gt;53
&lt;span class="go"&gt;
Name:   keycloak.acme.internal
Address: 192.168.57.8

&lt;/span&gt;&lt;span class="gp"&gt;debian@keycloak:~$&lt;/span&gt;&lt;span class="w"&gt; 
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I installed java on it because Keycloak requires it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;default-jre &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Java version 21 btw&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;debian@keycloak:~$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;java &lt;span class="nt"&gt;--version&lt;/span&gt;
&lt;span class="go"&gt;openjdk 21.0.10 2026-01-20
OpenJDK Runtime Environment (build 21.0.10+7-Debian-1deb13u1)
OpenJDK 64-Bit Server VM (build 21.0.10+7-Debian-1deb13u1, mixed mode, sharing)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I downloaded Keycloak from GitHub, extracted it and put it in &lt;code&gt;/opt&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;debian@keycloak:~/Downloads$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt;
&lt;span class="go"&gt;keycloak-26.5.6.tar.gz
&lt;/span&gt;&lt;span class="gp"&gt;debian@keycloak:~/Downloads$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;tar&lt;/span&gt; &lt;span class="nt"&gt;-xzf&lt;/span&gt; keycloak-26.5.6.tar.gz 
&lt;span class="gp"&gt;debian@keycloak:~/Downloads$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt;
&lt;span class="go"&gt;keycloak-26.5.6  keycloak-26.5.6.tar.gz
&lt;/span&gt;&lt;span class="gp"&gt;debian@keycloak:~/Downloads$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;sudo mv &lt;/span&gt;keycloak-26.5.6 /opt/keycloak
&lt;span class="gp"&gt;debian@keycloak:~/Downloads$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; /opt
&lt;span class="gp"&gt;debian@keycloak:/opt$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt;
&lt;span class="go"&gt;keycloak
&lt;/span&gt;&lt;span class="gp"&gt;debian@keycloak:/opt$&lt;/span&gt;&lt;span class="w"&gt; 
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And created a new user and set appropriate permissions.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;useradd &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; /sbin/nologin keycloak
&lt;span class="nb"&gt;sudo chown&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; keycloak:keycloak /opt/keycloak
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Local CA certificates for Keycloak
&lt;/h3&gt;

&lt;p&gt;I did the usual, created certs for &lt;code&gt;keycloak.acme.internal&lt;/code&gt; and moved them to the Keycloak server and pointed Keycloak to it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;debian@keycloak:~/acme-certs$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt;
&lt;span class="go"&gt;keycloak.acme.internal-key.pem  keycloak.acme.internal.pem
&lt;/span&gt;&lt;span class="gp"&gt;debian@keycloak:~/acme-certs$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /opt/keycloak/conf/certs
&lt;span class="gp"&gt;debian@keycloak:~/acme-certs$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;sudo cp &lt;/span&gt;keycloak.acme.internal.pem /opt/keycloak/conf/certs/keycloak.crt
&lt;span class="gp"&gt;debian@keycloak:~/acme-certs$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;sudo cp &lt;/span&gt;keycloak.acme.internal-key.pem /opt/keycloak/conf/certs/keycloak.key
&lt;span class="gp"&gt;debian@keycloak:~/acme-certs$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;sudo chown&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; keycloak:keycloak /opt/keycloak/conf/certs
&lt;span class="gp"&gt;debian@keycloak:~/acme-certs$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;640 /opt/keycloak/conf/certs/keycloak.key
&lt;span class="gp"&gt;debian@keycloak:~/acme-certs$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;nano /opt/keycloak/conf/keycloak.conf 
&lt;span class="gp"&gt;debian@keycloak:~/acme-certs$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /opt/keycloak/conf/
&lt;span class="go"&gt;cache-ispn.xml  certs/          keycloak.conf   README.md       truststores/    
&lt;/span&gt;&lt;span class="gp"&gt;debian@keycloak:~/acme-certs$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /opt/keycloak/conf/keycloak.conf | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-5&lt;/span&gt;
&lt;span class="go"&gt;hostname=keycloak.acme.internal
https-certificate-file=/opt/keycloak/conf/certs/keycloak.crt
https-certificate-key-file=/opt/keycloak/conf/certs/keycloak.key
http-enabled=false

&lt;/span&gt;&lt;span class="gp"&gt;debian@keycloak:~/acme-certs$&lt;/span&gt;&lt;span class="w"&gt; 
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Keycloak initial build
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; keycloak &lt;span class="nv"&gt;KEYCLOAK_ADMIN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;admin &lt;span class="nv"&gt;KEYCLOAK_ADMIN_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;admin123 /opt/keycloak/bin/kc.sh build
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;debian@keycloak:~/acme-certs$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;sudo&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; keycloak &lt;span class="nv"&gt;KEYCLOAK_ADMIN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;admin &lt;span class="nv"&gt;KEYCLOAK_ADMIN_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;password /opt/keycloak/bin/kc.sh build
&lt;span class="go"&gt;WARNING: Usage of the default value for the db option in the production profile is deprecated. Please explicitly set the db instead.
INFO: The following run time options were found, but will be ignored during build time: kc.https-certificate-key-file, kc.http-enabled, kc.hostname, kc.https-certificate-file

Updating the configuration and installing your custom providers, if any. Please wait.
2026-03-19 20:00:27,827 INFO  [io.quarkus.deployment.QuarkusAugmentor] (main) Quarkus augmentation completed in 10529ms
Server configuration updated and persisted. Run the following command to review the configuration:

    kc.sh show-config

&lt;/span&gt;&lt;span class="gp"&gt;debian@keycloak:~/acme-certs$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;/opt/keycloak/bin/kc.sh show-config
&lt;span class="go"&gt;Current Mode: production
Current Configuration:
    kc.log-level-org.infinispan.transaction.lookup.JBossStandaloneJTAManagerLookup =  null (Derived)
    kc.log-level-io.quarkus.config =  null (Derived)
    kc.hostname =  keycloak.acme.internal (keycloak.conf)
    kc.log-console-output =  default (classpath application.properties)
    kc.log-level-io.quarkus.hibernate.orm.deployment.HibernateOrmProcessor =  null (Derived)
    kc.log-level-liquibase.database.core.PostgresDatabase =  null (Derived)
    kc.optimized =  true (Persisted)
    kc.version =  26.5.6 (SysPropConfigSource)
    kc.log-level-io.smallrye.openapi.runtime.scanner.dataobject =  null (Derived)
    kc.https-certificate-file =  /opt/keycloak/conf/certs/keycloak.crt (keycloak.conf)
    kc.log-level-org.jboss.resteasy.resteasy_jaxrs.i18n =  null (Derived)
    kc.log-level-io.quarkus.arc.processor.BeanArchives =  null (Derived)
    kc.log-level-io.quarkus.deployment.steps.ReflectiveHierarchyStep =  null (Derived)
    kc.http-enabled =  false (keycloak.conf)
    kc.log-level-org.hibernate.SQL_SLOW =  null (Derived)
    kc.https-certificate-key-file =  /opt/keycloak/conf/certs/keycloak.key (keycloak.conf)
    kc.log-level-io.quarkus.arc.processor.IndexClassLookupUtils =  null (Derived)
    kc.log-file =  /opt/keycloak/bin/../data/log/keycloak.log (classpath application.properties)
    kc.log-level-org.hibernate.engine.jdbc.spi.SqlExceptionHelper =  null (Derived)
&lt;/span&gt;&lt;span class="gp"&gt;debian@keycloak:~/acme-certs$&lt;/span&gt;&lt;span class="w"&gt; 
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Keycloak daemon build (systemd)
&lt;/h3&gt;

&lt;p&gt;I created a file &lt;code&gt;/etc/systemd/system/keycloak.service&lt;/code&gt; with contents&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight systemd"&gt;&lt;code&gt;&lt;span class="k"&gt;[Unit]&lt;/span&gt;
&lt;span class="nt"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;Keycloak Identity and Access Management
&lt;span class="nt"&gt;After&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;network.target

&lt;span class="k"&gt;[Service]&lt;/span&gt;
&lt;span class="nt"&gt;User&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;keycloak
&lt;span class="nt"&gt;Group&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;keycloak
&lt;span class="nt"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;KEYCLOAK_ADMIN=admin
&lt;span class="nt"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;KEYCLOAK_ADMIN_PASSWORD=admin123
&lt;span class="nt"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;/opt/keycloak/bin/kc.sh start
&lt;span class="nt"&gt;Restart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;on-failure
&lt;span class="nt"&gt;RestartSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;10

&lt;span class="k"&gt;[Install]&lt;/span&gt;
&lt;span class="nt"&gt;WantedBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;multi-user.target
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And enabled the service&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;debian@keycloak:~/acme-certs$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl daemon-reload
&lt;span class="gp"&gt;debian@keycloak:~/acme-certs$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; keycloak
&lt;span class="go"&gt;Created symlink '/etc/systemd/system/multi-user.target.wants/keycloak.service' → '/etc/systemd/system/keycloak.service'.
&lt;/span&gt;&lt;span class="gp"&gt;debian@keycloak:~/acme-certs$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl status keycloak
&lt;span class="go"&gt;● keycloak.service - Keycloak Identity and Access Management
&lt;/span&gt;&lt;span class="gp"&gt;     Loaded: loaded (/etc/systemd/system/keycloak.service;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;enabled&lt;span class="p"&gt;;&lt;/span&gt; preset: enabled&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="gp"&gt;     Active: active (running) since Thu 2026-03-19 20:05:40 IST;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;6s ago
&lt;span class="go"&gt; Invocation: cde0a8f017a24bac8dd23934aa7a5bf2
   Main PID: 3250 (kc.sh)
      Tasks: 29 (limit: 2301)
     Memory: 269.9M (peak: 270.2M)
        CPU: 6.151s
     CGroup: /system.slice/keycloak.service
             ├─3250 /bin/sh /opt/keycloak/bin/kc.sh start
&lt;/span&gt;&lt;span class="gp"&gt;             └─3322 java -Djava.util.concurrent.ForkJoinPool.common.threadFactory=io.quarkus.bootstrap.forkjoin.QuarkusForkJoinW&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="go"&gt;
Mar 19 20:05:40 keycloak.acme.internal systemd[1]: Started keycloak.service - Keycloak Identity and Access Management.
&lt;/span&gt;&lt;span class="gp"&gt;Mar 19 20:05:46 keycloak.acme.internal kc.sh[3322]: 2026-03-19 20:05:46,328 INFO  [org.hibernate.orm.jdbc.batch] (JPA Startup Th&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="gp"&gt;debian@keycloak:~/acme-certs$&lt;/span&gt;&lt;span class="w"&gt; 
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Keycloak admin dashboard
&lt;/h3&gt;

&lt;p&gt;I visited &lt;code&gt;https://keycloak.acme.internal:8443&lt;/code&gt; from where the &lt;code&gt;rootCA.pem&lt;/code&gt; was trusted in the OS as well as browser certificate store.&lt;/p&gt;

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

&lt;p&gt;Then signed in.&lt;/p&gt;

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

&lt;p&gt;And then created a permanent admin account as it says. I created a new user with username &lt;code&gt;administrator&lt;/code&gt;, set a password, and then assigned &lt;code&gt;admin&lt;/code&gt; role to it. Then logged in with that user and deleted the temporary admin user.&lt;/p&gt;

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

&lt;p&gt;Then I created a realm for our fictional org - &lt;code&gt;acme&lt;/code&gt; - with display name "Acme Corp", user self-registration disabled (admin-provisioned org), and email verification enabled.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mailpit initial setup
&lt;/h3&gt;

&lt;p&gt;Since this is a local lab with no real mail infrastructure, I used Mailpit - a lightweight fake SMTP server that catches all outbound emails and displays them in a web UI instead of actually delivering them. This lets me test Keycloak's email flows (verification, password reset, OTP) without setting up a real mail server or domain. I ran it on the same VM as Keycloak since it's a single binary with minimal resource usage.&lt;/p&gt;

&lt;p&gt;I installed mailpit from GitHub, on the same VM as Keycloak.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wget https://github.com/axllent/mailpit/releases/download/v1.29.3/mailpit-linux-amd64.tar.gz
&lt;span class="nb"&gt;mkdir &lt;/span&gt;mailpit
&lt;span class="nb"&gt;tar&lt;/span&gt; &lt;span class="nt"&gt;-xzf&lt;/span&gt; mailpit-linux-amd64.tar.gz &lt;span class="nt"&gt;-C&lt;/span&gt; mailpit
&lt;span class="nb"&gt;cd &lt;/span&gt;mailpit
&lt;span class="nb"&gt;sudo mv &lt;/span&gt;mailpit /usr/local/bin/
&lt;span class="nb"&gt;sudo chmod&lt;/span&gt; +x /usr/local/bin/mailpit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And then verified it&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;mailpit version
&lt;span class="go"&gt;mailpit v1.29.3 compiled with go1.26.1 on linux/amd64
Error checking for latest release: failed to fetch releases: received status code 403
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And I created a new user for it&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;useradd &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; /sbin/nologin mailpit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Mailpit daemon build (systemd)
&lt;/h3&gt;

&lt;p&gt;I put the following contents on &lt;code&gt;/etc/systemd/system/mailpit.service&lt;/code&gt; (copied the previously generated keys to &lt;code&gt;/etc/mailpit/certs&lt;/code&gt; and changed ownership of that folder to &lt;code&gt;mailpit&lt;/code&gt;)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight systemd"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/systemd/system/mailpit.service&lt;/span&gt;
&lt;span class="k"&gt;[Unit]&lt;/span&gt;
&lt;span class="nt"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;Mailpit SMTP and Web UI
&lt;span class="nt"&gt;After&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;network.target

&lt;span class="k"&gt;[Service]&lt;/span&gt;
&lt;span class="nt"&gt;User&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;mailpit
&lt;span class="nt"&gt;Group&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;mailpit
&lt;span class="nt"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;/usr/local/bin/mailpit --smtp 0.0.0.0:1025 --listen 0.0.0.0:8025 --ui-tls-cert /etc/mailpit/certs/keycloak.acme.internal.pem --ui-tls-key /etc/mailpit/certs/keycloak.acme.internal-key.pem
&lt;span class="nt"&gt;Restart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;on-failure
&lt;span class="nt"&gt;RestartSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;10

&lt;span class="k"&gt;[Install]&lt;/span&gt;
&lt;span class="nt"&gt;WantedBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;multi-user.target
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And ran&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl daemon-reload
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; mailpit
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl status mailpit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;debian@keycloak:~/Downloads/mailpit$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl daemon-reload
&lt;span class="go"&gt;sudo systemctl enable --now mailpit
sudo systemctl status mailpit
Created symlink '/etc/systemd/system/multi-user.target.wants/mailpit.service' → '/etc/systemd/system/mailpit.service'.
● mailpit.service - Mailpit SMTP and Web UI
&lt;/span&gt;&lt;span class="gp"&gt;     Loaded: loaded (/etc/systemd/system/mailpit.service;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;enabled&lt;span class="p"&gt;;&lt;/span&gt; preset: enab&amp;gt;
&lt;span class="gp"&gt;     Active: active (running) since Fri 2026-03-20 15:37:36 IST;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;67ms ago
&lt;span class="go"&gt; Invocation: 1046374b8fb24c4f92c8a6fe9683cb5f
   Main PID: 2010 (mailpit)
      Tasks: 3 (limit: 2301)
     Memory: 8.7M (peak: 8.7M)
        CPU: 13ms
     CGroup: /system.slice/mailpit.service
&lt;/span&gt;&lt;span class="gp"&gt;             └─2010 /usr/local/bin/mailpit --smtp 0.0.0.0:1025 --listen 0.0.0.0&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="go"&gt;
&lt;/span&gt;&lt;span class="gp"&gt;Mar 20 15:37:36 keycloak.acme.internal systemd[1]: Started mailpit.service - Ma&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="gp"&gt;Mar 20 15:37:36 keycloak.acme.internal mailpit[2010]: time="2026/03/20 15:37:36&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="gp"&gt;Mar 20 15:37:36 keycloak.acme.internal mailpit[2010]: time="2026/03/20 15:37:36&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="gp"&gt;Mar 20 15:37:36 keycloak.acme.internal mailpit[2010]: time="2026/03/20 15:37:36&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="gp"&gt;Mar 20 15:37:36 keycloak.acme.internal mailpit[2010]: time="2026/03/20 15:37:36&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="gp"&gt;debian@keycloak:~/Downloads/mailpit$&lt;/span&gt;&lt;span class="w"&gt; 
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Keycloak to use Mailpit for email
&lt;/h3&gt;

&lt;p&gt;After I created the ACME Corp realm on Keycloak, I went to Realm Settings, email, entered these values to make use of Mailpit as email service.&lt;/p&gt;

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

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

&lt;p&gt;Then I visited Mailpit dashboard at &lt;code&gt;https://keycloak.acme.internal:8025/&lt;/code&gt;&lt;/p&gt;

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

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

&lt;p&gt;It works.&lt;/p&gt;

&lt;h3&gt;
  
  
  Password Policy in Keycloak
&lt;/h3&gt;

&lt;p&gt;I went to Authentication -&amp;gt; Password Policy -&amp;gt; Password Policies and set the password requirements to have&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Minimum 10 characters&lt;/li&gt;
&lt;li&gt;Minimum 1 Uppercase&lt;/li&gt;
&lt;li&gt;Minimum 1 Lowercase&lt;/li&gt;
&lt;li&gt;Minimum 1 Digit&lt;/li&gt;
&lt;li&gt;Minimum 1 Special Character&lt;/li&gt;
&lt;li&gt;Password history: 3 (prevents reuse of last 3 passwords)&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;I also configured brute force detection under Realm Settings -&amp;gt; Security Defenses -&amp;gt; Brute force detection:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Brute Force Mode: Lockout permanently after temporary lockout&lt;/li&gt;
&lt;li&gt;Max login failures: 5&lt;/li&gt;
&lt;li&gt;Maximum temporary lockouts: 1&lt;/li&gt;
&lt;li&gt;Strategy to increase wait time: Multiple&lt;/li&gt;
&lt;li&gt;Wait increment: 1 minute&lt;/li&gt;
&lt;li&gt;Max wait: 15 minutes&lt;/li&gt;
&lt;li&gt;Failure reset time: 12 hours&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After 5 failed login attempts the account locks out temporarily. If login failures continue through the temporary lockout, the account gets permanently locked and requires an admin to manually re-enable it. This will be demonstrated later in the user lifecycle section using Burp Suite to capture the login request and a Python script to simulate a brute force attack with a wordlist.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Organization Structure
&lt;/h3&gt;

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

&lt;p&gt;I created the above organization structure in Keycloak - four groups for departments, three realm roles (&lt;code&gt;employee&lt;/code&gt;, &lt;code&gt;manager&lt;/code&gt;, &lt;code&gt;it-admin&lt;/code&gt;), and seven users provisioned and assigned accordingly.&lt;/p&gt;

&lt;p&gt;All users:&lt;/p&gt;

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

&lt;p&gt;Group Engineering:&lt;/p&gt;

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

&lt;p&gt;Group Finance:&lt;/p&gt;

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

&lt;p&gt;Group HR:&lt;/p&gt;

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

&lt;p&gt;Group IT:&lt;/p&gt;

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

&lt;p&gt;Role employee:&lt;/p&gt;

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

&lt;p&gt;Role manager:&lt;/p&gt;

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

&lt;p&gt;Role IT Admin:&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Enforcing MFA via TOTP (Google Authenticator)
&lt;/h3&gt;

&lt;p&gt;I set up password based logins for users (Something You Know). Now I will set up Google Authenticator based 2FA (Something You Have). For this lab, I will be using &lt;a href="https://gauth.apps.gbraad.nl" rel="noopener noreferrer"&gt;gauth.apps.gbraad.nl&lt;/a&gt;, a browser based Google Authenticator app implementation, on the host machine, as my Google Authenticator client.&lt;/p&gt;

&lt;p&gt;I duplicated the default &lt;code&gt;browser&lt;/code&gt; authentication flow in Authentication -&amp;gt; Flows, named it &lt;code&gt;acme browser&lt;/code&gt;, and set the &lt;strong&gt;Browser - Conditional OTP&lt;/strong&gt; step from &lt;code&gt;Conditional&lt;/code&gt; to &lt;code&gt;Required&lt;/code&gt;. Then under Authentication -&amp;gt; Bindings, I set the Browser flow to &lt;code&gt;acme browser&lt;/code&gt;. This forces TOTP on every login for all users in the &lt;code&gt;acme&lt;/code&gt; realm.&lt;/p&gt;

&lt;p&gt;I also enabled the following required actions under Authentication -&amp;gt; Required actions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Update Password&lt;/li&gt;
&lt;li&gt;Verify Email&lt;/li&gt;
&lt;li&gt;Configure OTP&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are applied to all users, so on first login every user is forced to change their temporary password, verify their email via Mailpit, and set up TOTP before they can access anything.&lt;/p&gt;

&lt;p&gt;jsmith is forced to setup Google Authenticator login in next successful login:&lt;/p&gt;

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

&lt;p&gt;jsmith added key to Google Authenticator&lt;/p&gt;

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

&lt;p&gt;jsmith enters the OTP value and logs in successfully. Every other account did the same.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  OIDC Client and SSO Demo
&lt;/h3&gt;

&lt;p&gt;To demonstrate SSO, I registered a client in the &lt;code&gt;acme&lt;/code&gt; realm pointing to the official Keycloak demo app hosted at &lt;code&gt;https://www.keycloak.org/app/&lt;/code&gt;. Since it's a static page that runs entirely in the browser, it talks directly to my local Keycloak instance - no server-side component needed.&lt;/p&gt;

&lt;p&gt;Client settings:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Client type: OpenID Connect&lt;/li&gt;
&lt;li&gt;Client authentication: Off (public client)&lt;/li&gt;
&lt;li&gt;Valid redirect URIs: &lt;code&gt;https://www.keycloak.org/app/*&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Valid post logout redirect URIs: &lt;code&gt;https://www.keycloak.org/app/*&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Web origins: &lt;code&gt;https://www.keycloak.org&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;On the demo app, I pointed it at my Keycloak instance by entering the server URL, realm, and client ID.&lt;/p&gt;

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

&lt;p&gt;Clicking Sign In redirected to the Keycloak login page, went through the full authentication flow - password then TOTP - and landed back on the demo app showing jsmith's authenticated session with the decoded token.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Employee lifecycle management
&lt;/h3&gt;

&lt;p&gt;To demonstrate the full employee lifecycle, I walked through four real-world IAM events using &lt;code&gt;mclark&lt;/code&gt; from the HR department.&lt;/p&gt;

&lt;h4&gt;
  
  
  1. mclark gets promoted to HR manager
&lt;/h4&gt;

&lt;p&gt;mclark was promoted from HR employee to HR manager. In Keycloak, this meant removing the &lt;code&gt;employee&lt;/code&gt; role and assigning &lt;code&gt;manager&lt;/code&gt; under the user's Role Mappings tab.&lt;/p&gt;

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

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

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

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

&lt;h4&gt;
  
  
  2. mclark takes a vacation (account disabled)
&lt;/h4&gt;

&lt;p&gt;mclark went on an extended leave of absence. Rather than deleting the account, it was disabled - access is immediately revoked but the account and its data remain intact for when the employee returns.&lt;/p&gt;

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

&lt;h4&gt;
  
  
  3. mclark returns (account enabled)
&lt;/h4&gt;

&lt;p&gt;mclark returned from leave. The account was re-enabled, restoring access with all roles and group memberships unchanged.&lt;/p&gt;

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

&lt;h4&gt;
  
  
  4. mclark quits (account deleted)
&lt;/h4&gt;

&lt;p&gt;mclark resigned. The account was permanently deleted from the realm.&lt;/p&gt;

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

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

&lt;p&gt;&lt;strong&gt;mclark is no more&lt;/strong&gt;:&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Brute Force Protection
&lt;/h3&gt;

&lt;p&gt;Now I am going to do a brute force attack on jsmith's account. I am going to manually type in some random password values and hit enter multiple times to see what happens.&lt;/p&gt;

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

&lt;p&gt;As you can see from the admin dashboard, jsmith is now temporarily locked. &lt;/p&gt;

&lt;h3&gt;
  
  
  Event log
&lt;/h3&gt;

&lt;p&gt;Keycloak captures two categories of events per realm. Login events record every authentication-related action - successful logins, failed attempts, logouts, and token operations. Admin events record every administrative action performed on the realm - user creation, role assignments, deletions, client configuration changes, and so on. Both are visible under Events in the admin console.&lt;/p&gt;

&lt;h4&gt;
  
  
  Login Events
&lt;/h4&gt;

&lt;p&gt;Here is a snippet of lwilson signin related events&lt;/p&gt;

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

&lt;h4&gt;
  
  
  Admin Events
&lt;/h4&gt;

&lt;p&gt;Here is a snippet of admin events related to jsmith getting promoted&lt;/p&gt;

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

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

&lt;p&gt;The internship asked for a designed IAM solution and an implementation plan. This lab is that plan executed.&lt;/p&gt;

&lt;p&gt;Every requirement from the Tata Forage simulation is covered here in a working deployment: &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Keycloak handles identity and access management for Acme Corp the same way SailPoint and Oracle Identity Manager would handle it for TechCorp.&lt;/li&gt;
&lt;li&gt;RBAC is enforced through realm roles mapped to job functions.&lt;/li&gt;
&lt;li&gt;User provisioning and de-provisioning follow the same lifecycle model - onboarding with required actions, role changes on promotion, account suspension on leave, and full deletion on resignation.&lt;/li&gt;
&lt;li&gt;MFA via TOTP enforces the "something you know + something you have" authentication standard.&lt;/li&gt;
&lt;li&gt;SSO via OIDC demonstrates seamless access across applications from a single authenticated session.&lt;/li&gt;
&lt;li&gt;Mailpit handles all email flows locally.&lt;/li&gt;
&lt;li&gt;Brute force protection is configured and verified.&lt;/li&gt;
&lt;li&gt;Admin and login events are captured in the audit log.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The entire stack - Keycloak, Mailpit, TLS via a local CA, DNS via Pi-hole, and network routing via pfSense - runs self-contained in VirtualBox, making it fully reproducible as a home lab without any cloud dependency or paid tooling.&lt;/p&gt;

</description>
      <category>security</category>
      <category>iam</category>
      <category>keycloak</category>
      <category>cybersecurity</category>
    </item>
    <item>
      <title>Integrating a local mail server into my LDAP lab</title>
      <dc:creator>Shoban Chiddarth</dc:creator>
      <pubDate>Thu, 19 Mar 2026 07:38:19 +0000</pubDate>
      <link>https://dev.to/shobanchiddarth/integrating-a-local-mail-server-into-my-ldap-lab-4h5f</link>
      <guid>https://dev.to/shobanchiddarth/integrating-a-local-mail-server-into-my-ldap-lab-4h5f</guid>
      <description>&lt;p&gt;&lt;a href="https://github.com/ShobanChiddarth/postfix-dovecot-mailserver-in-openldap-lab" rel="noopener noreferrer"&gt;GitHub Repo: @ShobanChiddarth/postfix-dovecot-mailserver-in-openldap-lab&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;This is the second stage of my &lt;a href="https://dev.to/shobanchiddarth/openldap-home-lab-cyber-security-technical-write-up-4g42"&gt;OpenLDAP home lab&lt;/a&gt;. I set up a mail server to work with existing LDAP configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pre-Requisites
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;a href="https://dev.to/shobanchiddarth/the-superior-way-to-make-vms-communicate-with-each-other-as-well-as-host-with-internet-access-42m1"&gt;VM Intercommunication setup&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/shobanchiddarth/setting-up-pi-hole-as-a-custom-dns-server-on-my-home-lab-4jd7"&gt;Pi-hole&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/shobanchiddarth/setting-up-ssl-https-on-my-home-lab-g45"&gt;Local TLS for HTTPS&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/shobanchiddarth/openldap-home-lab-cyber-security-technical-write-up-4g42"&gt;OpenLDAP Home Lab&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Setup Process
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Mail Server Install
&lt;/h3&gt;

&lt;p&gt;In the existing setup, I cloned another debian server VM, gave it static IP &lt;code&gt;192.168.57.7&lt;/code&gt;, set its hostname to &lt;code&gt;mail-server.acme.internal&lt;/code&gt; and then added the DNS record in Pi-hole. Then I went ahead and installed the required packages for postfix and dovecot to work with LDAP.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; postfix postfix-ldap dovecot-core dovecot-imapd dovecot-ldap dovecot-lmtpd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I chose "Internet Site" in the postfix install prompt asking about which type of mail server to install and set the domain to &lt;code&gt;acme.internal&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Postfix Config
&lt;/h3&gt;

&lt;p&gt;I edited &lt;code&gt;/etc/postfix/main.cf&lt;/code&gt; to have this configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;myhostname&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;mail-server.acme.internal&lt;/span&gt;
&lt;span class="py"&gt;mydomain&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;acme.internal&lt;/span&gt;
&lt;span class="py"&gt;mydestination&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;$myhostname, $mydomain, localhost&lt;/span&gt;
&lt;span class="py"&gt;mynetworks&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;192.168.57.0/24 127.0.0.0/8&lt;/span&gt;
&lt;span class="py"&gt;inet_interfaces&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;all&lt;/span&gt;
&lt;span class="py"&gt;mailbox_transport&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;lmtp:unix:private/dovecot-lmtp&lt;/span&gt;
&lt;span class="py"&gt;smtpd_sasl_type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;dovecot&lt;/span&gt;
&lt;span class="py"&gt;smtpd_sasl_path&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;private/auth&lt;/span&gt;
&lt;span class="py"&gt;smtpd_sasl_auth_enable&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;yes&lt;/span&gt;
&lt;span class="py"&gt;local_recipient_maps&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;ldap:/etc/postfix/ldap-recipients.cf&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;mailbox_transport&lt;/code&gt; hands mail delivery off to Dovecot via LMTP. The &lt;code&gt;smtpd_sasl_*&lt;/code&gt; settings let Postfix use Dovecot for SMTP authentication. &lt;code&gt;local_recipient_maps&lt;/code&gt; tells Postfix to look up valid recipients in LDAP instead of the local user table - without this, Postfix rejects mail to LDAP users with "User unknown in local recipient table".&lt;/p&gt;

&lt;p&gt;I created &lt;code&gt;/etc/postfix/ldap-recipients.cf&lt;/code&gt; for the recipient lookup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;server_host&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;ldaps://ldap-server.acme.internal&lt;/span&gt;
&lt;span class="py"&gt;bind&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;yes&lt;/span&gt;
&lt;span class="py"&gt;bind_dn&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;cn=sssd,ou=service-accounts,dc=acme,dc=internal&lt;/span&gt;
&lt;span class="py"&gt;bind_pw&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;SSSDPass123&lt;/span&gt;
&lt;span class="py"&gt;search_base&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;ou=users,dc=acme,dc=internal&lt;/span&gt;
&lt;span class="py"&gt;query_filter&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;(&amp;amp;(objectClass=posixAccount)(mail=%s))&lt;/span&gt;
&lt;span class="py"&gt;result_attribute&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;mail&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then reloaded Postfix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl reload postfix
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Local CA Config on Mail Server
&lt;/h3&gt;

&lt;p&gt;I copied &lt;code&gt;rootCA.pem&lt;/code&gt; that I created earlier to this server and trusted it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;debian@mail-server:~/acme-certs$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt;
&lt;span class="go"&gt;rootCA.pem
&lt;/span&gt;&lt;span class="gp"&gt;debian@mail-server:~/acme-certs$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;sudo cp &lt;/span&gt;rootCA.pem /usr/local/share/ca-certificates/acme-rootCA.crt
&lt;span class="go"&gt;[sudo] password for debian: 
&lt;/span&gt;&lt;span class="gp"&gt;debian@mail-server:~/acme-certs$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;update-ca-certificates
&lt;span class="go"&gt;Updating certificates in /etc/ssl/certs...
rehash: warning: skipping ca-certificates.crt, it does not contain exactly one certificate or CRL
&lt;/span&gt;&lt;span class="gp"&gt;1 added, 0 removed;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;done&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt;
&lt;span class="go"&gt;Running hooks in /etc/ca-certificates/update.d...
done.
&lt;/span&gt;&lt;span class="gp"&gt;debian@mail-server:~/acme-certs$&lt;/span&gt;&lt;span class="w"&gt; 
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Dovecot TLS Config
&lt;/h3&gt;

&lt;p&gt;I generated a TLS cert for &lt;code&gt;mail-server.acme.internal&lt;/code&gt; using mkcert and copied it to the server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo cp &lt;/span&gt;mail-server.acme.internal.pem /etc/dovecot/private/mail-server.crt
&lt;span class="nb"&gt;sudo cp &lt;/span&gt;mail-server.acme.internal-key.pem /etc/dovecot/private/mail-server.key
&lt;span class="nb"&gt;sudo chown &lt;/span&gt;root:dovecot /etc/dovecot/private/mail-server.key
&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;640 /etc/dovecot/private/mail-server.key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then updated &lt;code&gt;/etc/dovecot/conf.d/10-ssl.conf&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;ssl_server_cert_file&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;/etc/dovecot/private/mail-server.crt&lt;/span&gt;
&lt;span class="py"&gt;ssl_server_key_file&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;/etc/dovecot/private/mail-server.key&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Dovecot LDAP Config
&lt;/h3&gt;

&lt;p&gt;I changed dovecot auth settings to use LDAP auth instead of system auth by editing &lt;code&gt;/etc/dovecot/conf.d/10-auth.conf&lt;/code&gt; - commented out &lt;code&gt;auth-system.conf.ext&lt;/code&gt; and uncommented &lt;code&gt;auth-ldap.conf.ext&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;debian@mail-server:~/acme-certs$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /etc/dovecot/conf.d/10-auth.conf | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"include auth"&lt;/span&gt;
&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;include auth-deny.conf.ext
&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;include auth-master.conf.ext
&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;include auth-oauth2.conf.ext
&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;include auth-system.conf.ext
&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;include auth-sql.conf.ext
&lt;span class="go"&gt;!include auth-ldap.conf.ext
&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;include auth-passwdfile.conf.ext
&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;include auth-static.conf.ext
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dovecot 2.4 changed the config syntax from older versions, so the standard examples online did not work. After checking the example config the package ships with, I set &lt;code&gt;/etc/dovecot/conf.d/auth-ldap.conf.ext&lt;/code&gt; to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;ldap_uris&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;ldaps://ldap-server.acme.internal&lt;/span&gt;
&lt;span class="py"&gt;ldap_auth_dn&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;cn=sssd,ou=service-accounts,dc=acme,dc=internal&lt;/span&gt;
&lt;span class="py"&gt;ldap_auth_dn_password&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;SSSDPass123&lt;/span&gt;
&lt;span class="py"&gt;ldap_base&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;ou=users,dc=acme,dc=internal&lt;/span&gt;

&lt;span class="err"&gt;passdb&lt;/span&gt; &lt;span class="err"&gt;ldap&lt;/span&gt; &lt;span class="err"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;ldap_filter&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;(&amp;amp;(objectClass=posixAccount)(uid=%{user}))&lt;/span&gt;
  &lt;span class="py"&gt;ldap_bind&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;yes&lt;/span&gt;
&lt;span class="err"&gt;}&lt;/span&gt;

&lt;span class="err"&gt;userdb&lt;/span&gt; &lt;span class="err"&gt;ldap&lt;/span&gt; &lt;span class="err"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;ldap_filter&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;(&amp;amp;(objectClass=posixAccount)(uid=%{user}))&lt;/span&gt;
  &lt;span class="err"&gt;fields&lt;/span&gt; &lt;span class="err"&gt;{&lt;/span&gt;
    &lt;span class="py"&gt;uid&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;vmail&lt;/span&gt;
    &lt;span class="py"&gt;gid&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;vmail&lt;/span&gt;
    &lt;span class="py"&gt;home&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;/var/mail/vhosts/%{user | username}&lt;/span&gt;
  &lt;span class="err"&gt;}&lt;/span&gt;
&lt;span class="err"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This reuses the same read-only service account from the LDAP lab. &lt;code&gt;ldap_bind = yes&lt;/code&gt; means Dovecot verifies passwords by actually binding to LDAP as the user rather than fetching and comparing the hash. The &lt;code&gt;userdb&lt;/code&gt; block maps all mail storage to a dedicated &lt;code&gt;vmail&lt;/code&gt; system user instead of running as the LDAP user's UID.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Note: &lt;code&gt;ldap_auth_dn_password&lt;/code&gt; is stored in plaintext, but &lt;code&gt;/etc/dovecot/conf.d/auth-ldap.conf.ext&lt;/code&gt; is owned by root and not readable by other users.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Dovecot Mail Storage and Postfix Integration
&lt;/h3&gt;

&lt;p&gt;Since LDAP users don't have home directories on the mail server, I created a dedicated &lt;code&gt;vmail&lt;/code&gt; system user to own all mailboxes and set up a central mail storage directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;adduser &lt;span class="nt"&gt;--system&lt;/span&gt; &lt;span class="nt"&gt;--no-create-home&lt;/span&gt; &lt;span class="nt"&gt;--group&lt;/span&gt; vmail
&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /var/mail/vhosts
&lt;span class="nb"&gt;sudo chown&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; vmail:vmail /var/mail/vhosts
&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;770 /var/mail/vhosts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I updated &lt;code&gt;/etc/dovecot/conf.d/10-mail.conf&lt;/code&gt; to use this path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;mail_driver&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;maildir&lt;/span&gt;
&lt;span class="py"&gt;mail_home&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;/var/mail/vhosts/%{user | username}&lt;/span&gt;
&lt;span class="py"&gt;mail_path&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;%{home}/Maildir&lt;/span&gt;
&lt;span class="py"&gt;first_valid_uid&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;100&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I configured the LMTP and auth unix sockets in &lt;code&gt;/etc/dovecot/conf.d/10-master.conf&lt;/code&gt; so Postfix can hand off incoming mail to Dovecot and use Dovecot for SMTP authentication.&lt;/p&gt;

&lt;p&gt;In the &lt;code&gt;service lmtp&lt;/code&gt; block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="err"&gt;service&lt;/span&gt; &lt;span class="err"&gt;lmtp&lt;/span&gt; &lt;span class="err"&gt;{&lt;/span&gt;
  &lt;span class="err"&gt;unix_listener&lt;/span&gt; &lt;span class="err"&gt;/var/spool/postfix/private/dovecot-lmtp&lt;/span&gt; &lt;span class="err"&gt;{&lt;/span&gt;
    &lt;span class="py"&gt;mode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;0600&lt;/span&gt;
    &lt;span class="py"&gt;user&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;postfix&lt;/span&gt;
    &lt;span class="py"&gt;group&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;postfix&lt;/span&gt;
  &lt;span class="err"&gt;}&lt;/span&gt;
&lt;span class="err"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the &lt;code&gt;service auth&lt;/code&gt; block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="err"&gt;service&lt;/span&gt; &lt;span class="err"&gt;auth&lt;/span&gt; &lt;span class="err"&gt;{&lt;/span&gt;
  &lt;span class="err"&gt;unix_listener&lt;/span&gt; &lt;span class="err"&gt;/var/spool/postfix/private/auth&lt;/span&gt; &lt;span class="err"&gt;{&lt;/span&gt;
    &lt;span class="py"&gt;mode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;0660&lt;/span&gt;
    &lt;span class="py"&gt;user&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;postfix&lt;/span&gt;
    &lt;span class="py"&gt;group&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;postfix&lt;/span&gt;
  &lt;span class="err"&gt;}&lt;/span&gt;
&lt;span class="err"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then restarted Dovecot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart dovecot
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Verification
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Sending mail
&lt;/h3&gt;

&lt;p&gt;I sent a test mail from &lt;code&gt;jsmith&lt;/code&gt; to &lt;code&gt;adoe&lt;/code&gt; over SMTP from a desktop VM on the same network:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;debian@debian:~/acme-certs&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"smtp://mail-server.acme.internal:25"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--mail-from&lt;/span&gt; &lt;span class="s2"&gt;"jsmith@acme.internal"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--mail-rcpt&lt;/span&gt; &lt;span class="s2"&gt;"adoe@acme.internal"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--upload-file&lt;/span&gt; - &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;
From: jsmith@acme.internal
To: adoe@acme.internal
Subject: Test from jsmith

Hello Alice, this is John.
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Postfix accepted it and delivered it to Dovecot via LMTP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;250 2.0.0 Ok: queued as 98B624018B
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verified the mail landed in adoe's mailbox on the server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;debian@mail-server:~&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;sudo ls&lt;/span&gt; /var/mail/vhosts/adoe/Maildir/new/
1773903163.M693787P7845.mail-server.acme.internal,S&lt;span class="o"&gt;=&lt;/span&gt;560,W&lt;span class="o"&gt;=&lt;/span&gt;576
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Retrieving mail over IMAPS
&lt;/h3&gt;

&lt;p&gt;I verified that adoe can authenticate and retrieve mail over IMAPS using their LDAP credentials:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;debian@debian:~/acme-certs&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"imaps://mail-server.acme.internal/INBOX"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--user&lt;/span&gt; &lt;span class="s2"&gt;"adoe:Password456"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;* SSL certificate verify ok.
* Connected to mail-server.acme.internal (192.168.57.7) port 993
&amp;lt; A002 OK Logged in
&amp;lt; * LIST (\HasNoChildren) "." INBOX
&amp;lt; A003 OK List completed (0.001 + 0.000 secs).
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;TLS handshake succeeded using the mkcert CA, LDAP authentication worked, and the INBOX is accessible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Thunderbird Verification
&lt;/h2&gt;

&lt;p&gt;Thunderbird has its own certificate store separate from the system trust store, so I had to manually import &lt;code&gt;rootCA.pem&lt;/code&gt; into Thunderbird settings before it would trust the IMAPS connection.&lt;/p&gt;

&lt;h3&gt;
  
  
  adoe sends an email to jsmith
&lt;/h3&gt;

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

&lt;h3&gt;
  
  
  jsmith opens it
&lt;/h3&gt;

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

&lt;h3&gt;
  
  
  jsmith sends a reply email to adoe
&lt;/h3&gt;

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

&lt;h3&gt;
  
  
  adoe views the reply email
&lt;/h3&gt;

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

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

&lt;p&gt;Postfix and Dovecot are now running as a mail server for &lt;code&gt;acme.internal&lt;/code&gt;. Users defined in the OpenLDAP directory can send and receive mail using their LDAP credentials. Postfix looks up valid recipients in LDAP so it accepts mail for LDAP users. Dovecot authenticates over LDAPS using the same read-only service account from the LDAP lab and stores mail in a central directory owned by a dedicated &lt;code&gt;vmail&lt;/code&gt; user. IMAP access is over TLS only.&lt;/p&gt;

&lt;p&gt;This mail server will be used in the next part of this series as the SMTP backend for Keycloak notifications.&lt;/p&gt;

</description>
      <category>homelab</category>
      <category>linux</category>
      <category>cybersecurity</category>
      <category>mailserver</category>
    </item>
    <item>
      <title>OpenLDAP home lab - Cyber Security technical write up</title>
      <dc:creator>Shoban Chiddarth</dc:creator>
      <pubDate>Tue, 17 Mar 2026 07:55:15 +0000</pubDate>
      <link>https://dev.to/shobanchiddarth/openldap-home-lab-cyber-security-technical-write-up-4g42</link>
      <guid>https://dev.to/shobanchiddarth/openldap-home-lab-cyber-security-technical-write-up-4g42</guid>
      <description>&lt;p&gt;&lt;a href="https://github.com/ShobanChiddarth/openldap-iam-lab" rel="noopener noreferrer"&gt;GitHub Repo: @ShobanChiddarth/openldap-iam-lab&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;This home lab is a part of my setup for an IAM lab using KeyCloak. In this lab I set up OpenLDAP in a Virtualbox lab.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pre-Requisites
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;a href="https://dev.to/shobanchiddarth/the-superior-way-to-make-vms-communicate-with-each-other-as-well-as-host-with-internet-access-42m1"&gt;VM Intercommunication setup&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/shobanchiddarth/setting-up-pi-hole-as-a-custom-dns-server-on-my-home-lab-4jd7"&gt;Pi-hole&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/shobanchiddarth/setting-up-ssl-https-on-my-home-lab-g45"&gt;Local TLS for HTTPS&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Setup Process
&lt;/h2&gt;

&lt;p&gt;Let's say the fictional organization I am working in is Acme Inc. The domain name would be &lt;code&gt;acme.internal&lt;/code&gt;. I have the VM intercommunication setup that I linked earlier ready. I also setup custom DNS records in the Pi-hole (&lt;code&gt;ldap-server.acme.internal&lt;/code&gt; points to &lt;code&gt;192.168.57.5&lt;/code&gt; and &lt;code&gt;ldap-client.acme.internal&lt;/code&gt; points to &lt;code&gt;192.168.57.6&lt;/code&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  Setting up LDAP server
&lt;/h3&gt;

&lt;p&gt;I installed &lt;code&gt;slapd&lt;/code&gt; and &lt;code&gt;ldap-utils&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;slapd ldap-utils
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And set the admin password mid installation, then configured it with&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;dpkg-reconfigure slapd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It asked me for organization name along with domain, I filled in with the appropriate details. And verified it with&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;debian@ldap-server:~$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;slapcat
&lt;span class="go"&gt;dn: dc=acme,dc=internal
objectClass: top
objectClass: dcObject
objectClass: organization
o: Acme
dc: acme
structuralObjectClass: organization
entryUUID: 2938c77a-b601-1040-8564-d9abd6bd9b70
creatorsName: cn=admin,dc=acme,dc=internal
createTimestamp: 20260317035728Z
&lt;/span&gt;&lt;span class="gp"&gt;entryCSN: 20260317035728.729122Z#&lt;/span&gt;000000#000#000000
&lt;span class="go"&gt;modifiersName: cn=admin,dc=acme,dc=internal
modifyTimestamp: 20260317035728Z

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Creating Directory structure
&lt;/h3&gt;

&lt;p&gt;I created &lt;code&gt;structure.ldif&lt;/code&gt; to add two organizational units - one for users and one for groups, and loaded it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;debian@ldap-server:~$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;nano structure.ldif
&lt;span class="gp"&gt;debian@ldap-server:~$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;cat &lt;/span&gt;structure.ldif 
&lt;span class="go"&gt;dn: ou=users,dc=acme,dc=internal
objectClass: organizationalUnit
ou: users

dn: ou=groups,dc=acme,dc=internal
objectClass: organizationalUnit
ou: groups
&lt;/span&gt;&lt;span class="gp"&gt;debian@ldap-server:~$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ldapadd &lt;span class="nt"&gt;-x&lt;/span&gt; &lt;span class="nt"&gt;-D&lt;/span&gt; &lt;span class="s2"&gt;"cn=admin,dc=acme,dc=internal"&lt;/span&gt; &lt;span class="nt"&gt;-W&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; structure.ldif
&lt;span class="go"&gt;Enter LDAP Password: 
adding new entry "ou=users,dc=acme,dc=internal"

adding new entry "ou=groups,dc=acme,dc=internal"

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Adding users
&lt;/h3&gt;

&lt;p&gt;I will be creating 2 users "John Smith" and "Alice Doe" with passwords &lt;code&gt;Password123&lt;/code&gt; and &lt;code&gt;Password456&lt;/code&gt; respectively. I generated the password hashes using&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;debian@ldap-server:~$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;sudo&lt;/span&gt; /usr/sbin/slappasswd &lt;span class="nt"&gt;-s&lt;/span&gt; Password123
&lt;span class="go"&gt;{SSHA}2sIS9koZfMFfwEjWoglS4Iu8XpCvamLk
&lt;/span&gt;&lt;span class="gp"&gt;debian@ldap-server:~$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;sudo&lt;/span&gt; /usr/sbin/slappasswd &lt;span class="nt"&gt;-s&lt;/span&gt; Password456
&lt;span class="go"&gt;{SSHA}QlqSwZdrWeINW5PS54vTx4Bpqt+6PLdi
&lt;/span&gt;&lt;span class="gp"&gt;debian@ldap-server:~$&lt;/span&gt;&lt;span class="w"&gt; 
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then created 2 users in &lt;code&gt;users.ldif&lt;/code&gt; and loaded them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;debian@ldap-server:~$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;nano users.ldif
&lt;span class="gp"&gt;debian@ldap-server:~$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;cat &lt;/span&gt;users.ldif 
&lt;span class="go"&gt;dn: uid=jsmith,ou=users,dc=acme,dc=internal
objectClass: inetOrgPerson
objectClass: posixAccount
objectClass: shadowAccount
uid: jsmith
sn: Smith
givenName: John
cn: John Smith
displayName: John Smith
uidNumber: 10001
gidNumber: 10001
userPassword: {SSHA}2sIS9koZfMFfwEjWoglS4Iu8XpCvamLk
loginShell: /bin/bash
homeDirectory: /home/jsmith
mail: jsmith@acme.internal

dn: uid=adoe,ou=users,dc=acme,dc=internal
objectClass: inetOrgPerson
objectClass: posixAccount
objectClass: shadowAccount
uid: adoe
sn: Doe
givenName: Alice
cn: Alice Doe
displayName: Alice Doe
uidNumber: 10002
gidNumber: 10002
userPassword: {SSHA}QlqSwZdrWeINW5PS54vTx4Bpqt+6PLdi
loginShell: /bin/bash
homeDirectory: /home/adoe
mail: adoe@acme.internal
&lt;/span&gt;&lt;span class="gp"&gt;debian@ldap-server:~$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ldapadd &lt;span class="nt"&gt;-x&lt;/span&gt; &lt;span class="nt"&gt;-D&lt;/span&gt; &lt;span class="s2"&gt;"cn=admin,dc=acme,dc=internal"&lt;/span&gt; &lt;span class="nt"&gt;-W&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; users.ldif
&lt;span class="go"&gt;Enter LDAP Password: 
adding new entry "uid=jsmith,ou=users,dc=acme,dc=internal"

adding new entry "uid=adoe,ou=users,dc=acme,dc=internal"

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Adding groups
&lt;/h3&gt;

&lt;p&gt;I created groups engineering and HR, added jsmith to engineering and Alice to HR, and loaded them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;debian@ldap-server:~$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;nano groups.ldif
&lt;span class="gp"&gt;debian@ldap-server:~$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;cat &lt;/span&gt;groups.ldif 
&lt;span class="go"&gt;dn: cn=engineers,ou=groups,dc=acme,dc=internal
objectClass: posixGroup
cn: engineers
gidNumber: 10001
memberUid: jsmith

dn: cn=hr,ou=groups,dc=acme,dc=internal
objectClass: posixGroup
cn: hr
gidNumber: 10002
memberUid: adoe
&lt;/span&gt;&lt;span class="gp"&gt;debian@ldap-server:~$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ldapadd &lt;span class="nt"&gt;-x&lt;/span&gt; &lt;span class="nt"&gt;-D&lt;/span&gt; &lt;span class="s2"&gt;"cn=admin,dc=acme,dc=internal"&lt;/span&gt; &lt;span class="nt"&gt;-W&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; groups.ldif
&lt;span class="go"&gt;Enter LDAP Password: 
adding new entry "cn=engineers,ou=groups,dc=acme,dc=internal"

adding new entry "cn=hr,ou=groups,dc=acme,dc=internal"

&lt;/span&gt;&lt;span class="gp"&gt;debian@ldap-server:~$&lt;/span&gt;&lt;span class="w"&gt; 
&lt;/span&gt;&lt;span class="go"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Setting up LDAPS (using mkcert)
&lt;/h3&gt;

&lt;p&gt;I created TLS certificates for &lt;code&gt;ldap-server.acme.internal&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;debian@debian:~/acme-certs$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;mkcert &lt;span class="nt"&gt;-install&lt;/span&gt;
&lt;span class="go"&gt;Created a new local CA 💥
The local CA is now installed in the system trust store! ⚡️
ERROR: no Firefox and/or Chrome/Chromium security databases found

&lt;/span&gt;&lt;span class="gp"&gt;debian@debian:~/acme-certs$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;mkcert &lt;span class="nt"&gt;-CAROOT&lt;/span&gt;
&lt;span class="go"&gt;/home/debian/.local/share/mkcert
&lt;/span&gt;&lt;span class="gp"&gt;debian@debian:~/acme-certs$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;mkcert &lt;span class="nt"&gt;-CAROOT&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;/rootCA.pem &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;span class="gp"&gt;debian@debian:~/acme-certs$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt;
&lt;span class="go"&gt;rootCA-key.pem  rootCA.pem
&lt;/span&gt;&lt;span class="gp"&gt;debian@debian:~/acme-certs$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;mkcert ldap-server.acme.internal
&lt;span class="go"&gt;Note: the local CA is not installed in the Firefox and/or Chrome/Chromium trust store.
Run "mkcert -install" for certificates to be trusted automatically ⚠️

Created a new certificate valid for the following names 📜
 - "ldap-server.acme.internal"

The certificate is at "./ldap-server.acme.internal.pem" and the key at "./ldap-server.acme.internal-key.pem" ✅

It will expire on 17 June 2028 🗓

&lt;/span&gt;&lt;span class="gp"&gt;debian@debian:~/acme-certs$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;span class="go"&gt;total 16
-rw------- 1 debian debian 1704 Mar 17 09:55 ldap-server.acme.internal-key.pem
-rw-r--r-- 1 debian debian 1472 Mar 17 09:55 ldap-server.acme.internal.pem
-rw-r--r-- 1 debian debian 1619 Mar 17 09:54 rootCA.pem
&lt;/span&gt;&lt;span class="gp"&gt;debian@debian:~/acme-certs$&lt;/span&gt;&lt;span class="w"&gt; 
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Copied those 3 files onto the server and set the required permissions.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;debian@ldap-server:~/acme-certs$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt;
&lt;span class="go"&gt;ldap-server.acme.internal-key.pem  ldap-server.acme.internal.pem  rootCA.pem
&lt;/span&gt;&lt;span class="gp"&gt;debian@ldap-server:~/acme-certs$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /etc/ldap/tls
&lt;span class="gp"&gt;debian@ldap-server:~/acme-certs$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;sudo cp &lt;/span&gt;ldap-server.acme.internal.pem /etc/ldap/tls/ldap-server.crt
&lt;span class="gp"&gt;debian@ldap-server:~/acme-certs$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;sudo cp &lt;/span&gt;ldap-server.acme.internal-key.pem /etc/ldap/tls/ldap-server.key
&lt;span class="gp"&gt;debian@ldap-server:~/acme-certs$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;sudo cp &lt;/span&gt;rootCA.pem /etc/ldap/tls/rootCA.pem
&lt;span class="gp"&gt;debian@ldap-server:~/acme-certs$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;sudo chown&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; openldap:openldap /etc/ldap/tls
&lt;span class="gp"&gt;debian@ldap-server:~/acme-certs$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;640 /etc/ldap/tls/ldap-server.key
&lt;span class="gp"&gt;debian@ldap-server:~/acme-certs$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;644 /etc/ldap/tls/ldap-server.crt /etc/ldap/tls/rootCA.pem
&lt;span class="gp"&gt;debian@ldap-server:~/acme-certs$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; /etc/ldap/tls/
&lt;span class="go"&gt;total 12
-rw-r--r-- 1 openldap openldap 1472 Mar 17 10:51 ldap-server.crt
-rw-r----- 1 openldap openldap 1704 Mar 17 10:51 ldap-server.key
-rw-r--r-- 1 openldap openldap 1619 Mar 17 10:51 rootCA.pem
&lt;/span&gt;&lt;span class="gp"&gt;debian@ldap-server:~/acme-certs$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-ld&lt;/span&gt; /etc/ldap/tls/
&lt;span class="go"&gt;drwxr-xr-x 2 openldap openldap 4096 Mar 17 10:51 /etc/ldap/tls/
&lt;/span&gt;&lt;span class="gp"&gt;debian@ldap-server:~/acme-certs$&lt;/span&gt;&lt;span class="w"&gt; 
&lt;/span&gt;&lt;span class="go"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I created tls conf file (&lt;code&gt;tls.ldif&lt;/code&gt;) and applied it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;debian@ldap-server:~$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;nano tls.ldif
&lt;span class="gp"&gt;debian@ldap-server:~$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;cat &lt;/span&gt;tls.ldif 
&lt;span class="go"&gt;dn: cn=config
changetype: modify
add: olcTLSCACertificateFile
olcTLSCACertificateFile: /etc/ldap/tls/rootCA.pem
-
add: olcTLSCertificateFile
olcTLSCertificateFile: /etc/ldap/tls/ldap-server.crt
-
add: olcTLSCertificateKeyFile
olcTLSCertificateKeyFile: /etc/ldap/tls/ldap-server.key
&lt;/span&gt;&lt;span class="gp"&gt;debian@ldap-server:~$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ldapmodify &lt;span class="nt"&gt;-Y&lt;/span&gt; EXTERNAL &lt;span class="nt"&gt;-H&lt;/span&gt; ldapi:/// &lt;span class="nt"&gt;-f&lt;/span&gt; tls.ldif
&lt;span class="go"&gt;SASL/EXTERNAL authentication started
SASL username: gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth
SASL SSF: 0
modifying entry "cn=config"

&lt;/span&gt;&lt;span class="gp"&gt;debian@ldap-server:~$&lt;/span&gt;&lt;span class="w"&gt; 
&lt;/span&gt;&lt;span class="go"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I set &lt;code&gt;SLAPD_SERVICES&lt;/code&gt; in &lt;code&gt;/etc/default/slapd&lt;/code&gt; to use LDAPS&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;SLAPD_SERVICES&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"ldapi:/// ldaps:///"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And specified LDAP client tools on the server to use the certificate&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;debian@ldap-server:~$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"TLS_CACERT /etc/ldap/tls/rootCA.pem"&lt;/span&gt; | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; /etc/ldap/ldap.conf
&lt;span class="go"&gt;TLS_CACERT /etc/ldap/tls/rootCA.pem
&lt;/span&gt;&lt;span class="gp"&gt;debian@ldap-server:~$&lt;/span&gt;&lt;span class="w"&gt; 
&lt;/span&gt;&lt;span class="go"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I restarted &lt;code&gt;slapd&lt;/code&gt; service and checked if LDAPS is working&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;debian@ldap-server:~$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart slapd
&lt;span class="gp"&gt;debian@ldap-server:~$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ldapsearch &lt;span class="nt"&gt;-x&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; ldaps://ldap-server.acme.internal &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="go"&gt;  -D "cn=admin,dc=acme,dc=internal" -W \
  -b "dc=acme,dc=internal" "(objectClass=*)"
Enter LDAP Password: 
&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;extended LDIF
&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;LDAPv3
&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;base &amp;lt;&lt;span class="nv"&gt;dc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;acme,dc&lt;span class="o"&gt;=&lt;/span&gt;internal&amp;gt; with scope subtree
&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;filter: &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;objectClass&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;requesting: ALL
&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="go"&gt;
&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;acme.internal
&lt;span class="go"&gt;dn: dc=acme,dc=internal
objectClass: top
objectClass: dcObject
objectClass: organization
o: Acme
dc: acme

&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;users&lt;/span&gt;, acme.internal
&lt;span class="go"&gt;dn: ou=users,dc=acme,dc=internal
objectClass: organizationalUnit
ou: users

&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;groups&lt;/span&gt;, acme.internal
&lt;span class="go"&gt;dn: ou=groups,dc=acme,dc=internal
objectClass: organizationalUnit
ou: groups

&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;jsmith, &lt;span class="nb"&gt;users&lt;/span&gt;, acme.internal
&lt;span class="go"&gt;dn: uid=jsmith,ou=users,dc=acme,dc=internal
objectClass: inetOrgPerson
objectClass: posixAccount
objectClass: shadowAccount
uid: jsmith
sn: Smith
givenName: John
cn: John Smith
displayName: John Smith
uidNumber: 10001
gidNumber: 10001
userPassword:: e1NTSEF9MnNJUzlrb1pmTUZmd0VqV29nbFM0SXU4WHBDdmFtTGs=
loginShell: /bin/bash
homeDirectory: /home/jsmith
mail: jsmith@acme.internal

&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;adoe, &lt;span class="nb"&gt;users&lt;/span&gt;, acme.internal
&lt;span class="go"&gt;dn: uid=adoe,ou=users,dc=acme,dc=internal
objectClass: inetOrgPerson
objectClass: posixAccount
objectClass: shadowAccount
uid: adoe
sn: Doe
givenName: Alice
cn: Alice Doe
displayName: Alice Doe
uidNumber: 10002
gidNumber: 10002
userPassword:: e1NTSEF9UWxxU3daZHJXZUlOVzVQUzU0dlR4NEJwcXQrNlBMZGk=
loginShell: /bin/bash
homeDirectory: /home/adoe
mail: adoe@acme.internal

&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;engineers, &lt;span class="nb"&gt;groups&lt;/span&gt;, acme.internal
&lt;span class="go"&gt;dn: cn=engineers,ou=groups,dc=acme,dc=internal
objectClass: posixGroup
cn: engineers
gidNumber: 10001
memberUid: jsmith

&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;hr, &lt;span class="nb"&gt;groups&lt;/span&gt;, acme.internal
&lt;span class="go"&gt;dn: cn=hr,ou=groups,dc=acme,dc=internal
objectClass: posixGroup
cn: hr
gidNumber: 10002
memberUid: adoe

&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;search result
&lt;span class="go"&gt;search: 2
result: 0 Success

&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;numResponses: 8
&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;numEntries: 7
&lt;span class="gp"&gt;debian@ldap-server:~$&lt;/span&gt;&lt;span class="w"&gt; 
&lt;/span&gt;&lt;span class="go"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But there was one small problem with this. The &lt;code&gt;userPassword&lt;/code&gt;'s base64 encoded SSHA has is visible. I am going to fix that using ACLs.&lt;/p&gt;

&lt;p&gt;I created &lt;code&gt;acls.ldif&lt;/code&gt; and set the contents and loaded it and verified it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;debian@ldap-server:~$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;nano acls.ldif
&lt;span class="gp"&gt;debian@ldap-server:~$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;cat &lt;/span&gt;acls.ldif 
&lt;span class="go"&gt;dn: olcDatabase={1}mdb,cn=config
changetype: modify
replace: olcAccess
olcAccess: {0}to attrs=userPassword
  by self write
  by anonymous auth
  by dn="cn=admin,dc=acme,dc=internal" write
  by * none
olcAccess: {1}to attrs=shadowLastChange
  by self write
  by * read
olcAccess: {2}to *
  by self write
  by dn="cn=admin,dc=acme,dc=internal" write
  by * read
&lt;/span&gt;&lt;span class="gp"&gt;debian@ldap-server:~$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ldapmodify &lt;span class="nt"&gt;-Y&lt;/span&gt; EXTERNAL &lt;span class="nt"&gt;-H&lt;/span&gt; ldapi:/// &lt;span class="nt"&gt;-f&lt;/span&gt; acls.ldif
&lt;span class="go"&gt;SASL/EXTERNAL authentication started
SASL username: gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth
SASL SSF: 0
modifying entry "olcDatabase={1}mdb,cn=config"

&lt;/span&gt;&lt;span class="gp"&gt;debian@ldap-server:~$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ldapsearch &lt;span class="nt"&gt;-x&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; ldaps://ldap-server.acme.internal &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="go"&gt;  -b "dc=acme,dc=internal" "(uid=jsmith)" userPassword
&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;extended LDIF
&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;LDAPv3
&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;base &amp;lt;&lt;span class="nv"&gt;dc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;acme,dc&lt;span class="o"&gt;=&lt;/span&gt;internal&amp;gt; with scope subtree
&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;filter: &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;uid&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;jsmith&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;requesting: userPassword 
&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="go"&gt;
&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;jsmith, &lt;span class="nb"&gt;users&lt;/span&gt;, acme.internal
&lt;span class="go"&gt;dn: uid=jsmith,ou=users,dc=acme,dc=internal

&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;search result
&lt;span class="go"&gt;search: 2
result: 0 Success

&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;numResponses: 2
&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;numEntries: 1
&lt;span class="gp"&gt;debian@ldap-server:~$&lt;/span&gt;&lt;span class="w"&gt; 
&lt;/span&gt;&lt;span class="go"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Setting up LDAP client
&lt;/h3&gt;

&lt;p&gt;I copied &lt;code&gt;rootCA.pem&lt;/code&gt; to the LDAP client and trusted it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;debian@ldap-client:~/acme-certs$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt;
&lt;span class="go"&gt;rootCA.pem
&lt;/span&gt;&lt;span class="gp"&gt;debian@ldap-client:~/acme-certs$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;sudo cp &lt;/span&gt;rootCA.pem /usr/local/share/ca-certificates/acme-rootCA.crt
&lt;span class="go"&gt;[sudo] password for debian: 
&lt;/span&gt;&lt;span class="gp"&gt;debian@ldap-client:~/acme-certs$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;update-ca-certificates
&lt;span class="go"&gt;Updating certificates in /etc/ssl/certs...
rehash: warning: skipping ca-certificates.crt, it does not contain exactly one certificate or CRL
&lt;/span&gt;&lt;span class="gp"&gt;1 added, 0 removed;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;done&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt;
&lt;span class="go"&gt;Running hooks in /etc/ca-certificates/update.d...
done.
&lt;/span&gt;&lt;span class="gp"&gt;debian@ldap-client:~/acme-certs$&lt;/span&gt;&lt;span class="w"&gt; 
&lt;/span&gt;&lt;span class="go"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I installed System Security Services&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; sssd libpam-sss libnss-sss
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I now created the configuration for it, set the permissions and enabled the service.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;debian@ldap-client:~$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;nano /etc/sssd/sssd.conf
&lt;span class="gp"&gt;debian@ldap-client:~$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;sudo cat&lt;/span&gt; /etc/sssd/sssd.conf
&lt;span class="go"&gt;[sssd]
services = nss, pam
config_file_version = 2
domains = acme.internal

[domain/acme.internal]
id_provider = ldap
auth_provider = ldap
ldap_uri = ldaps://ldap-server.acme.internal
ldap_search_base = dc=acme,dc=internal
ldap_tls_cacert = /etc/ssl/certs/ca-certificates.crt
ldap_schema = rfc2307
cache_credentials = true
enumerate = true
&lt;/span&gt;&lt;span class="gp"&gt;debian@ldap-client:~$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;600 /etc/sssd/sssd.conf
&lt;span class="gp"&gt;debian@ldap-client:~$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; sssd
&lt;span class="go"&gt;Synchronizing state of sssd.service with SysV service script with /usr/lib/systemd/systemd-sysv-install.
Executing: /usr/lib/systemd/systemd-sysv-install enable sssd
&lt;/span&gt;&lt;span class="gp"&gt;debian@ldap-client:~$&lt;/span&gt;&lt;span class="w"&gt; 
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The only thing left is automatic home dir creation.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;pam-auth-update &lt;span class="nt"&gt;--enable&lt;/span&gt; mkhomedir
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This will make sure a home directory is automatically created when john ssh-es into the ldap client.&lt;/p&gt;

&lt;h3&gt;
  
  
  LDAP Client Verification
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;debian@ldap-client:~$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;id &lt;/span&gt;jsmith
&lt;span class="go"&gt;uid=10001(jsmith) gid=10001(engineers) groups=10001(engineers)
&lt;/span&gt;&lt;span class="gp"&gt;debian@ldap-client:~$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;getent passwd jsmith
&lt;span class="go"&gt;jsmith:*:10001:10001:John Smith:/home/jsmith:/bin/bash
&lt;/span&gt;&lt;span class="gp"&gt;debian@ldap-client:~$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;getent group engineers
&lt;span class="go"&gt;engineers:*:10001:jsmith
&lt;/span&gt;&lt;span class="gp"&gt;debian@ldap-client:~$&lt;/span&gt;&lt;span class="w"&gt; 
&lt;/span&gt;&lt;span class="go"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;LDAP client can see the users and groups set in the LDAP server.&lt;/p&gt;

&lt;h3&gt;
  
  
  SSH verification
&lt;/h3&gt;

&lt;p&gt;From another desktop, copied &lt;code&gt;rootCA.pem&lt;/code&gt; and trusted it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;debian@debian:~/acme-certs$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt;
&lt;span class="go"&gt;rootCA.pem
&lt;/span&gt;&lt;span class="gp"&gt;debian@debian:~/acme-certs$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;sudo cp &lt;/span&gt;rootCA.pem /usr/local/share/ca-certificates/acme-rootCA.crt
&lt;span class="go"&gt;[sudo] password for debian: 
&lt;/span&gt;&lt;span class="gp"&gt;debian@debian:~/acme-certs$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;update-ca-certificates
&lt;span class="go"&gt;Updating certificates in /etc/ssl/certs...
rehash: warning: skipping ca-certificates.crt, it does not contain exactly one certificate or CRL
&lt;/span&gt;&lt;span class="gp"&gt;1 added, 0 removed;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;done&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt;
&lt;span class="go"&gt;Running hooks in /etc/ca-certificates/update.d...
done.
&lt;/span&gt;&lt;span class="gp"&gt;debian@debian:~/acme-certs$&lt;/span&gt;&lt;span class="w"&gt; 
&lt;/span&gt;&lt;span class="go"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now I tried to ssh login&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;debian@debian:~/acme-certs$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ssh jsmith@ldap-client.acme.internal
&lt;span class="go"&gt;The authenticity of host 'ldap-client.acme.internal (192.168.57.6)' can't be established.
ED25519 key fingerprint is SHA256:wo67g9IGEfMUrZBC1KzzKlHS1G41PidIUGXZ5kTGmV0.
This key is not known by any other names.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added 'ldap-client.acme.internal' (ED25519) to the list of known hosts.
jsmith@ldap-client.acme.internal's password: 
Creating directory '/home/jsmith'.
&lt;/span&gt;&lt;span class="gp"&gt;Linux ldap-client.acme.internal 6.12.73+deb13-amd64 #&lt;/span&gt;1 SMP PREEMPT_DYNAMIC Debian 6.12.73-1 &lt;span class="o"&gt;(&lt;/span&gt;2026-02-17&lt;span class="o"&gt;)&lt;/span&gt; x86_64
&lt;span class="go"&gt;
&lt;/span&gt;&lt;span class="gp"&gt;The programs included with the Debian GNU/Linux system are free software;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="go"&gt;the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.

Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
permitted by applicable law.
&lt;/span&gt;&lt;span class="gp"&gt;jsmith@ldap-client:~$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;pwd&lt;/span&gt;
&lt;span class="go"&gt;/home/jsmith
&lt;/span&gt;&lt;span class="gp"&gt;jsmith@ldap-client:~$&lt;/span&gt;&lt;span class="w"&gt; 
&lt;/span&gt;&lt;span class="go"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It worked. Also the home dir was automatically created. &lt;/p&gt;

&lt;h3&gt;
  
  
  Stopping anonymous bind and setup a service account for LDAP client
&lt;/h3&gt;

&lt;p&gt;I need a password hash for the service account first. So I ran&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;debian@ldap-server:~$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;sudo&lt;/span&gt; /usr/sbin/slappasswd &lt;span class="nt"&gt;-s&lt;/span&gt; SSSDPass123
&lt;span class="go"&gt;[sudo] password for debian: 
{SSHA}ASFdTu5vaW4YSytPSFTpTc1StjoC+giz
&lt;/span&gt;&lt;span class="gp"&gt;debian@ldap-server:~$&lt;/span&gt;&lt;span class="w"&gt; 
&lt;/span&gt;&lt;span class="go"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I put it in service accounts conf file and then load it&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;debian@ldap-server:~$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;nano service-accounts.ldif
&lt;span class="gp"&gt;debian@ldap-server:~$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;cat &lt;/span&gt;service-accounts.ldif 
&lt;span class="go"&gt;dn: ou=service-accounts,dc=acme,dc=internal
objectClass: organizationalUnit
ou: service-accounts

dn: cn=sssd,ou=service-accounts,dc=acme,dc=internal
objectClass: simpleSecurityObject
objectClass: organizationalRole
cn: sssd
description: Read-only service account for SSSD
userPassword: {SSHA}ASFdTu5vaW4YSytPSFTpTc1StjoC+giz
&lt;/span&gt;&lt;span class="gp"&gt;debian@ldap-server:~$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ldapadd &lt;span class="nt"&gt;-x&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; ldaps://ldap-server.acme.internal &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="go"&gt;  -D "cn=admin,dc=acme,dc=internal" -W -f service-accounts.ldif
Enter LDAP Password: 
adding new entry "ou=service-accounts,dc=acme,dc=internal"

adding new entry "cn=sssd,ou=service-accounts,dc=acme,dc=internal"

&lt;/span&gt;&lt;span class="gp"&gt;debian@ldap-server:~$&lt;/span&gt;&lt;span class="w"&gt; 
&lt;/span&gt;&lt;span class="go"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And then I create another ACL to make sure I only allow that specific account and disallow anonymous binds&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;debian@ldap-server:~$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;nano acls2.ldif
&lt;span class="gp"&gt;debian@ldap-server:~$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;cat &lt;/span&gt;acls2.ldif 
&lt;span class="go"&gt;dn: olcDatabase={1}mdb,cn=config
changetype: modify
replace: olcAccess
olcAccess: {0}to attrs=userPassword
  by self write
  by anonymous auth
  by dn="cn=admin,dc=acme,dc=internal" write
  by * none
olcAccess: {1}to attrs=shadowLastChange
  by self write
  by * read
olcAccess: {2}to *
  by self write
  by dn="cn=admin,dc=acme,dc=internal" write
  by dn="cn=sssd,ou=service-accounts,dc=acme,dc=internal" read
  by * none
&lt;/span&gt;&lt;span class="gp"&gt;debian@ldap-server:~$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ldapmodify &lt;span class="nt"&gt;-Y&lt;/span&gt; EXTERNAL &lt;span class="nt"&gt;-H&lt;/span&gt; ldapi:/// &lt;span class="nt"&gt;-f&lt;/span&gt; acls2.ldif
&lt;span class="go"&gt;SASL/EXTERNAL authentication started
SASL username: gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth
SASL SSF: 0
modifying entry "olcDatabase={1}mdb,cn=config"

&lt;/span&gt;&lt;span class="gp"&gt;debian@ldap-server:~$&lt;/span&gt;&lt;span class="w"&gt; 
&lt;/span&gt;&lt;span class="go"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now I set the LDAP client to use those accounts and restart the service&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;debian@ldap-client:~$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;sudo cat&lt;/span&gt; /etc/sssd/sssd.conf
&lt;span class="go"&gt;[sssd]
services = nss, pam
config_file_version = 2
domains = acme.internal

[domain/acme.internal]
id_provider = ldap
auth_provider = ldap
ldap_uri = ldaps://ldap-server.acme.internal
ldap_search_base = dc=acme,dc=internal
ldap_tls_cacert = /etc/ssl/certs/ca-certificates.crt
ldap_schema = rfc2307
cache_credentials = true
enumerate = true
ldap_default_bind_dn = cn=sssd,ou=service-accounts,dc=acme,dc=internal
ldap_default_authtok = SSSDPass123
&lt;/span&gt;&lt;span class="gp"&gt;debian@ldap-client:~$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart sssd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;Note: &lt;code&gt;ldap_default_authtok&lt;/code&gt; is stored in plaintext, but &lt;code&gt;sssd.conf&lt;/code&gt; is locked to &lt;code&gt;root&lt;/code&gt; only via the &lt;code&gt;chmod 600&lt;/code&gt; set earlier, so it is not readable by other users on the system.&lt;/p&gt;
&lt;h3&gt;
  
  
  Verifying again
&lt;/h3&gt;
&lt;/blockquote&gt;

&lt;p&gt;LDAP client records fetching:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;debian@ldap-client:~$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;id &lt;/span&gt;jsmith
&lt;span class="go"&gt;uid=10001(jsmith) gid=10001(engineers) groups=10001(engineers)
&lt;/span&gt;&lt;span class="gp"&gt;debian@ldap-client:~$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;getent passwd adoe
&lt;span class="go"&gt;adoe:*:10002:10002:Alice Doe:/home/adoe:/bin/bash
&lt;/span&gt;&lt;span class="gp"&gt;debian@ldap-client:~$&lt;/span&gt;&lt;span class="w"&gt; 
&lt;/span&gt;&lt;span class="go"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SSH verification:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;debian@debian:~/acme-certs$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ssh jsmith@ldap-client.acme.internal
&lt;span class="go"&gt;jsmith@ldap-client.acme.internal's password: 
&lt;/span&gt;&lt;span class="gp"&gt;Linux ldap-client.acme.internal 6.12.73+deb13-amd64 #&lt;/span&gt;1 SMP PREEMPT_DYNAMIC Debian 6.12.73-1 &lt;span class="o"&gt;(&lt;/span&gt;2026-02-17&lt;span class="o"&gt;)&lt;/span&gt; x86_64
&lt;span class="go"&gt;
&lt;/span&gt;&lt;span class="gp"&gt;The programs included with the Debian GNU/Linux system are free software;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="go"&gt;the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.

Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
permitted by applicable law.
Last login: Tue Mar 17 11:43:45 2026 from 192.168.57.104
&lt;/span&gt;&lt;span class="gp"&gt;jsmith@ldap-client:~$&lt;/span&gt;&lt;span class="w"&gt; 
&lt;/span&gt;&lt;span class="go"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is working. Only the LDAP client can call the LDAP servers and anonymous binds are disabled.&lt;/p&gt;

&lt;h2&gt;
  
  
  Screenshots
&lt;/h2&gt;

&lt;h3&gt;
  
  
  VBoxManager
&lt;/h3&gt;

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

&lt;h3&gt;
  
  
  ALL VMs
&lt;/h3&gt;

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

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

&lt;p&gt;OpenLDAP is now running as the centralized identity backend for &lt;code&gt;acme.internal&lt;/code&gt;. The directory is secured with LDAPS using the lab's mkcert CA. ACLs enforce least-privilege access - password hashes are not exposed to anyone except the admin, and anonymous binds are disabled entirely. A dedicated read-only service account is the only thing allowed to query the directory, which is what SSSD on &lt;code&gt;ldap-client&lt;/code&gt; uses. Users that exist only in LDAP can SSH into &lt;code&gt;ldap-client&lt;/code&gt; with their home directories created automatically on first login.&lt;/p&gt;

&lt;p&gt;This is the identity foundation for the next part of this series - connecting Keycloak to this LDAP directory as its user backend.&lt;/p&gt;

</description>
      <category>homelab</category>
      <category>cybersecurity</category>
      <category>linux</category>
      <category>network</category>
    </item>
    <item>
      <title>Setting up HTTPS on my home lab</title>
      <dc:creator>Shoban Chiddarth</dc:creator>
      <pubDate>Mon, 09 Mar 2026 03:22:10 +0000</pubDate>
      <link>https://dev.to/shobanchiddarth/setting-up-ssl-https-on-my-home-lab-g45</link>
      <guid>https://dev.to/shobanchiddarth/setting-up-ssl-https-on-my-home-lab-g45</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;This is the third part of my physical network engineering home lab series of blogs. In this blog I will be sharing how I set up SSL on my home lab and then accessed locally hosted services without a warning in the browser.&lt;/p&gt;

&lt;p&gt;And no I am not buying a domain like a lot of YouTubers do, I am going to use &lt;code&gt;mkcert&lt;/code&gt; to manage the SSL certificates and install the certificate on each client device and make HTTPS work with no warning from the browser. I chose &lt;code&gt;mkcert&lt;/code&gt; because manually configuring &lt;code&gt;openssl&lt;/code&gt; certificates and maintaining a certificate hierarchy is tedious for a home lab.&lt;/p&gt;

&lt;p&gt;The goal of enabling HTTPS is to securely access the Pi-hole admin panel, and other locally hosted services in the home lab.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Side note: SSL is deprecated and replaced with TLS but the term SSL certificates is used in slang and I will be going with that in this blog&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Requirements
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;A Linux machine&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;package &lt;code&gt;libnss3-tools&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;libnss3-tools
&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;package &lt;code&gt;mkcert&lt;/code&gt;. For Linux Mint you can download via apt&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;mkcert
&lt;/code&gt;&lt;/pre&gt;


&lt;p&gt;but if it is not available via apt you have to download it from &lt;a href="https://github.com/FiloSottile/mkcert/releases/latest" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Steps
&lt;/h2&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Creating &lt;code&gt;rootCA&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;This is the highest in the hierarchy of certificates and we will be copying the root certificate to other devices to make them work with HTTPS.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;On your client machine (not the server), run&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mkcert &lt;span class="nt"&gt;-install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;After it is installed, run&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mkcert &lt;span class="nt"&gt;-CAROOT&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;li&gt;&lt;p&gt;This will print the directory where the root key and the root certificate is stored. Make a note of it.&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gray@OMEN-Slim-Gaming-Laptop-16:~/home-lab-tls&lt;span class="nv"&gt;$ &lt;/span&gt;mkcert &lt;span class="nt"&gt;-install&lt;/span&gt;
Created a new &lt;span class="nb"&gt;local &lt;/span&gt;CA 💥
The &lt;span class="nb"&gt;local &lt;/span&gt;CA is now installed &lt;span class="k"&gt;in &lt;/span&gt;the system trust store! ⚡️
The &lt;span class="nb"&gt;local &lt;/span&gt;CA is now installed &lt;span class="k"&gt;in &lt;/span&gt;the Firefox and/or Chrome/Chromium trust store &lt;span class="o"&gt;(&lt;/span&gt;requires browser restart&lt;span class="o"&gt;)!&lt;/span&gt; 🦊

gray@OMEN-Slim-Gaming-Laptop-16:~/home-lab-tls&lt;span class="nv"&gt;$ &lt;/span&gt;mkcert &lt;span class="nt"&gt;-CAROOT&lt;/span&gt;
/home/gray/.local/share/mkcert
gray@OMEN-Slim-Gaming-Laptop-16:~/home-lab-tls&lt;span class="nv"&gt;$ &lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Creating Certificates
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;In a separate directory, run the following command. 2 files will be stored on that current directory which you will need to copy to the server.&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mkcert domain-name.internal
&lt;/code&gt;&lt;/pre&gt;


&lt;p&gt;I have created only for &lt;code&gt;pi.hole&lt;/code&gt; because that is the only service I want to secure right now.&lt;br&gt;
&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gray@OMEN-Slim-Gaming-Laptop-16:~/home-lab-tls&lt;span class="nv"&gt;$ &lt;/span&gt;mkcert pi.hole

Created a new certificate valid &lt;span class="k"&gt;for &lt;/span&gt;the following names 📜
 - &lt;span class="s2"&gt;"pi.hole"&lt;/span&gt;

The certificate is at &lt;span class="s2"&gt;"./pi.hole.pem"&lt;/span&gt; and the key at &lt;span class="s2"&gt;"./pi.hole-key.pem"&lt;/span&gt; ✅

It will expire on 8 June 2028 🗓

gray@OMEN-Slim-Gaming-Laptop-16:~/home-lab-tls&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;ls
&lt;/span&gt;pi.hole-key.pem  pi.hole.pem
gray@OMEN-Slim-Gaming-Laptop-16:~/home-lab-tls&lt;span class="nv"&gt;$ &lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Combine those 2 files along with the &lt;code&gt;rootCA.pem&lt;/code&gt; in this specific order
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cat pi.hole-key.pem pi.hole.pem  $(mkcert -CAROOT)/rootCA.pem &amp;gt; pi.hole-combined.pem
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Copy the combined file to the server, into the directory &lt;code&gt;/etc/pihole&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;On the server, make sure the file is owned by user and group &lt;code&gt;pihole&lt;/code&gt; and only the owner can read/write to it.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo chown &lt;/span&gt;pihole:pihole pi.hole-combined.pem                 
&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;600 pi.hole-combined.pem 
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Pointing the Pi-hole server to use these certificates
&lt;/h3&gt;

&lt;p&gt;Edit this section in &lt;code&gt;/etc/pihole/pihole.toml&lt;/code&gt; as shown&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;  &lt;span class="nn"&gt;[webserver.tls]&lt;/span&gt;
    &lt;span class="c"&gt;# Path to the TLS (SSL) certificate file.&lt;/span&gt;
    &lt;span class="c"&gt;#&lt;/span&gt;
    &lt;span class="c"&gt;# All directories along the path must be readable and accessible by the user running&lt;/span&gt;
    &lt;span class="c"&gt;# FTL (typically 'pihole'). This option is only required when at least one of&lt;/span&gt;
    &lt;span class="c"&gt;# webserver.port is TLS. The file must be in PEM format, and it must have both,&lt;/span&gt;
    &lt;span class="c"&gt;# private key and certificate (the *.pem file created must contain a 'CERTIFICATE'&lt;/span&gt;
    &lt;span class="c"&gt;# section as well as a 'RSA PRIVATE KEY' section).&lt;/span&gt;
    &lt;span class="c"&gt;#&lt;/span&gt;
    &lt;span class="c"&gt;# The *.pem file can be created using `cp server.crt server.pem &amp;amp;&amp;amp; cat server.key &amp;gt;&amp;gt;&lt;/span&gt;
    &lt;span class="c"&gt;# server.pem` if you have these files instead&lt;/span&gt;
    &lt;span class="c"&gt;#&lt;/span&gt;
    &lt;span class="c"&gt;# Allowed values are:&lt;/span&gt;
    &lt;span class="c"&gt;#     A valid TLS certificate file (*.pem)&lt;/span&gt;
    &lt;span class="py"&gt;cert&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/etc/pihole/pi.hole-combined.pem"&lt;/span&gt;

    &lt;span class="c"&gt;# Number of days the automatically generated self-signed TLS/SSL certificate will be&lt;/span&gt;
    &lt;span class="c"&gt;# valid for.&lt;/span&gt;
    &lt;span class="c"&gt;#&lt;/span&gt;
    &lt;span class="c"&gt;# Defaults to 47 days. A minimum of 7 days is enforced.&lt;/span&gt;
    &lt;span class="c"&gt;# Some devices may enforce shorter validity ranges. Note that defining a lower&lt;/span&gt;
    &lt;span class="c"&gt;# validity range may require you to accept the self-signed certificate more often in&lt;/span&gt;
    &lt;span class="c"&gt;# your browser.&lt;/span&gt;
    &lt;span class="c"&gt;# Pi-hole will regenerate certificates it created itself two days prior to expiration.&lt;/span&gt;
    &lt;span class="c"&gt;# If you are using your own certificate, you need to regenerate it yourself. In this&lt;/span&gt;
    &lt;span class="c"&gt;# case, it is advised to set the validity range to 0 days, so that Pi-hole does not&lt;/span&gt;
    &lt;span class="c"&gt;# try to regenerate your certificate. If you set the validity range to 0 days and&lt;/span&gt;
    &lt;span class="c"&gt;# still try to generate a certificate, Pi-hole will set a fixed validity range of&lt;/span&gt;
    &lt;span class="c"&gt;# roughly 30 years for the certificate.&lt;/span&gt;
    &lt;span class="py"&gt;validity&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replace &lt;code&gt;cert&lt;/code&gt; variable with where you stored the combined certificate, and then restart Pi-hole.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sudo service pihole-FTL restart
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;Side Note: If &lt;code&gt;nginx&lt;/code&gt; is running, disable it. Pi-hole's FTL has a built-in web server that handles TLS directly - Nginx will conflict with it on port 80/443.&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sudo systemctl disable nginx --now
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Verifying HTTPS on current device
&lt;/h2&gt;

&lt;p&gt;Open &lt;a href="https://pi.hole" rel="noopener noreferrer"&gt;https://pi.hole&lt;/a&gt; in the browser of the device you ran &lt;code&gt;mkcert -install&lt;/code&gt; command on.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Verifying it on other devices
&lt;/h2&gt;

&lt;p&gt;Copy the file &lt;code&gt;$(mkcert -CAROOT)/rootCA.pem&lt;/code&gt; to other devices, and import to the device's certificates (instructions vary for each device, Google it) store and then open &lt;code&gt;https://pi.hole&lt;/code&gt; on the browser.&lt;/p&gt;

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

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

&lt;p&gt;The Pi-hole admin panel is now accessible over HTTPS with a trusted certificate - no browser warnings, no exceptions. Any other device on the network can be added by copying the &lt;code&gt;rootCA.pem&lt;/code&gt; and importing it into the system trust store.&lt;/p&gt;

&lt;p&gt;From here, the physical lab has DNS, ad-blocking, and HTTPS in place.&lt;/p&gt;

</description>
      <category>networking</category>
      <category>tls</category>
      <category>security</category>
      <category>encryption</category>
    </item>
    <item>
      <title>The Superior Way to make VMs communicate with each other as well as host, with internet access</title>
      <dc:creator>Shoban Chiddarth</dc:creator>
      <pubDate>Sat, 07 Mar 2026 14:05:19 +0000</pubDate>
      <link>https://dev.to/shobanchiddarth/the-superior-way-to-make-vms-communicate-with-each-other-as-well-as-host-with-internet-access-42m1</link>
      <guid>https://dev.to/shobanchiddarth/the-superior-way-to-make-vms-communicate-with-each-other-as-well-as-host-with-internet-access-42m1</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;On December 1, 2025, I made &lt;a href="https://www.linkedin.com/posts/shobanchiddarth_linux-networkengineering-virtualization-activity-7401309251840720896-rgD3" rel="noopener noreferrer"&gt;a LinkedIn post&lt;/a&gt; about setting up inter-VM networking with 3 VMs (a DHCP server, a web server and a client). The full details and &lt;a href="https://shobanchiddarth.github.io/3-VMs-build-log/" rel="noopener noreferrer"&gt;a technical document&lt;/a&gt; are available in the post if you want to check it out.&lt;/p&gt;

&lt;p&gt;The way I set up 3 VMs for inter-VM networking with internet access is&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;VMs are set to use "Virtualbox Host Only Ethernet Adapter"&lt;/li&gt;
&lt;li&gt;Internet is shared to the adapter using Windows proprietary ICS ("Internet Connection Sharing") from Control Panel.
It worked in a Windows host (I was temporarily using Windows at that time) because internet sharing is fully handled by Windows, and the inter-VM networking worked with no issues.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now I am using a Linux host and need to set up inter-VM networking for a project, the same thing is not possible as ICS is a Windows proprietary service. A similar solution will require turning the host into a router and manually managing firewall entries and route tables every time a network segment is created or removed - invasive and hard to cleanly reverse. The other option is attaching two adapters to each VM: one for inter-VM and host communication, another for internet access - which gets complicated across several VMs and can cause issues with desktop environments and network managers.&lt;/p&gt;

&lt;p&gt;There is a superior way to achieve the following:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;VMs can communicate with each other&lt;/li&gt;
&lt;li&gt;Host can communicate with VMs and vice versa&lt;/li&gt;
&lt;li&gt;VMs can access the internet&lt;/li&gt;
&lt;li&gt;Host does not need to be converted to a router&lt;/li&gt;
&lt;li&gt;2 or more adapters do not need to be attached to every single VM&lt;/li&gt;
&lt;li&gt;Nothing is permanently changed on the host that requires manual intervention to revert, everything stays in Virtualbox and goes away when you delete them&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The solution is to run pfSense in a VM that acts as the router between network adapters, keeping everything contained inside VirtualBox.&lt;/p&gt;

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

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqs0k5po82pqyo7fde2hl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqs0k5po82pqyo7fde2hl.png" alt="Network diagram showing pfSense VM with WAN adapter (Bridged/NAT/NAT Network) connected to the internet, and LAN adapter (VBHOEA) connecting to a Host Only Network shared with other VMs" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The setup is simple: install pfSense inside a VM and attach two adapters to it.&lt;/p&gt;

&lt;p&gt;The first adapter connects pfSense to the internet. You can use Bridged, NAT, or NAT Network - all three work as long as the host has internet access. (Bridged gives pfSense a real IP on your physical LAN; NAT and NAT Network are simpler and work without touching your router).&lt;/p&gt;

&lt;p&gt;For more information on VirtualBox virtual networking, see the &lt;a href="https://www.virtualbox.org/manual/ch06.html" rel="noopener noreferrer"&gt;manual&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The second adapter is a VirtualBox Host Only Ethernet Adapter attached to a specific Host Only Network. This becomes the LAN side.&lt;/p&gt;

&lt;p&gt;For every other VM in your lab, attach a single VirtualBox Host Only Ethernet Adapter and select the same Host Only Network you assigned to pfSense's LAN adapter.&lt;/p&gt;

&lt;p&gt;In pfSense, assign the first adapter as WAN and the Host Only Ethernet Adapter as LAN.&lt;/p&gt;

&lt;p&gt;Disable the VirtualBox built-in DHCP server for that Host Only Network, and configure DHCP inside pfSense instead.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Always boot pfSense before your lab VMs&lt;/strong&gt; - it handles DHCP and routing for the entire network.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Steps
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Install pfSense on VirtualBox
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Download the pfSense ISO file from &lt;a href="https://shop.netgate.com/products/netgate-installer" rel="noopener noreferrer"&gt;Netgate Shop&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Click "New" on Virtual Box and fill in the name of the OS, select the ISO file.&lt;/li&gt;
&lt;li&gt;Select OS: "BSD"; Distro: "FreeBSD"; OS Version: "FreeBSD 64 bit";&lt;/li&gt;
&lt;/ol&gt;

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

&lt;ol&gt;
&lt;li&gt;Select 2GB RAM and 4GB Hard Disk&lt;/li&gt;
&lt;/ol&gt;

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

&lt;ol&gt;
&lt;li&gt;Click on "Start" and then install the OS. By default, Virtualbox assigns the "NAT" adapter as the only interface. That is fine for now. Select that as the WAN interface in the installation process. For installing the OS, accept defaults for everything else.&lt;/li&gt;
&lt;li&gt;After installation is over, shut the VM down instead of rebooting. &lt;/li&gt;
&lt;li&gt;Go to VM's settings -&amp;gt; Storage and remove the ISO file.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;ol&gt;
&lt;li&gt;Click "Ok" and then boot the VM up.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;p&gt;If it boots up to this state then it means that it is successful. Now you can reset the admin account and password, then shut it down.&lt;/p&gt;

&lt;h3&gt;
  
  
  Configuring interfaces on pfSense
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Go to Virtualbox Networks and select "Host Only Networks". Create a new Host Only Network if you do not want to use the default. Note the subnet/CIDR block.&lt;/li&gt;
&lt;li&gt;Go to Machines -&amp;gt; pfSense VM settings -&amp;gt; Network and enable a second adapter, and set it to VirtualBox Host Only Ethernet Adapter and then select the Host Only Network you wish to use it. Make sure to select "Allow All" in promiscuous mode. This allows pfSense to forward traffic on behalf of other VMs.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;ol&gt;
&lt;li&gt;Click on "OK" and boot it up again.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;ol&gt;
&lt;li&gt;Select "1" to configure interfaces. Then select no for VLANs, assign &lt;code&gt;em0&lt;/code&gt; as WAN interface and then &lt;code&gt;em1&lt;/code&gt; as LAN interface. Then confirm it.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;ol&gt;
&lt;li&gt;Select 2 to set interface IP addresses. We need a static IP on the LAN interface because pfSense will be our DHCP server. Make sure you are setting up an IP that does not clash with the host, and lies in the same subnet as the VirtualBox Host only network, then choose to enable DHCP server on LAN. (IPv6 is optional).
&amp;gt; Note: To find the host's IP on that network, go to VirtualBox Manager -&amp;gt; Networks -&amp;gt; Host Only Networks and select your network.&lt;/li&gt;
&lt;/ol&gt;

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

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

&lt;ol&gt;
&lt;li&gt;After it is done, access the static IP you set via a web browser from the host. Enter the username &lt;code&gt;admin&lt;/code&gt; and the password you set up earlier.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  pfSense web configuration
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Click next on the welcome messages and on the initial configuration, enter the appropriate values as shown in the image. Make sure to set up a valid upstream DNS resolver.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;ol&gt;
&lt;li&gt;Leave the timeserver as is, and choose your timezone.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;ol&gt;
&lt;li&gt;Click next and you will be asked to configure WAN and LAN interfaces. Change nothing as you already set this one up.&lt;/li&gt;
&lt;li&gt;Set the admin account password again when it prompts you to and then reload and login.
At this point pfSense is fully operational. Any additional configuration such as firewall rules or DNS overrides can be done from this interface.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  VM Intercommunication and Verification
&lt;/h2&gt;

&lt;p&gt;Create VMs as needed. Make sure to attach a VirtualBox Host Only Ethernet Adapter and select the Host Only Network you set up pfSense in.&lt;/p&gt;

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

&lt;p&gt;Set up a web server VM with a static IP and a test page to verify host-to-VM communication.&lt;/p&gt;

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

&lt;p&gt;Set up a client VM in the same adapter and network, configured as a DHCP client. Verify that it receives an IP from pfSense.&lt;/p&gt;

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

&lt;p&gt;From the client VM, access the web server using its IP. Verify that VM-to-VM communication works.&lt;/p&gt;

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

&lt;p&gt;Verify internet access from the client VM.&lt;/p&gt;

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

&lt;p&gt;Note that the client VM has only one interface attached - the Host Only Ethernet Adapter with the relevant Host Only Network selected. Through this single adapter, the VM can reach other VMs on the same network, the host, and the internet.&lt;/p&gt;

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

&lt;p&gt;The result is a clean, self-contained lab network: VMs can communicate with each other and the host, have full internet access, and the host requires no permanent configuration changes. pfSense handles routing, DHCP, and DNS entirely within VirtualBox.&lt;/p&gt;

&lt;p&gt;This setup works as a solid foundation for any multi-VM lab that needs real network services, making it particularly useful for cybersecurity home labs.&lt;/p&gt;

</description>
      <category>networking</category>
      <category>pfsense</category>
      <category>virtualbox</category>
      <category>network</category>
    </item>
    <item>
      <title>The Superior way to share files over a LAN</title>
      <dc:creator>Shoban Chiddarth</dc:creator>
      <pubDate>Fri, 06 Mar 2026 13:18:02 +0000</pubDate>
      <link>https://dev.to/shobanchiddarth/the-superior-way-to-share-files-over-a-lan-28nb</link>
      <guid>https://dev.to/shobanchiddarth/the-superior-way-to-share-files-over-a-lan-28nb</guid>
      <description>&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/ShobanChiddarth/openssh-server" rel="noopener noreferrer"&gt;GitHub: @ShobanChiddarth/openssh-server&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;You need to send a file to another machine on your network. It should be a solved problem. It is not.&lt;/p&gt;

&lt;p&gt;Every method people actually reach for is either insecure, annoying to set up, or leaves something running on your machine long after you needed it. This post is about the one approach that gets all three things right - and it takes about two minutes to set up.&lt;/p&gt;




&lt;h2&gt;
  
  
  What You're Probably Doing (And Why It's Bad)
&lt;/h2&gt;

&lt;p&gt;Let's go through the common options honestly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Python HTTP server
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 &lt;span class="nt"&gt;-m&lt;/span&gt; http.server 8080
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everyone knows this one. It works, and that's roughly where the positives end. Traffic is completely unencrypted. Anyone on the same network can see every byte in transit. There's no authentication - anyone who can reach the port can download anything you're serving.&lt;/p&gt;

&lt;h3&gt;
  
  
  Netcat
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# sender&lt;/span&gt;
nc &lt;span class="nt"&gt;-l&lt;/span&gt; 9999 &amp;lt; file.txt

&lt;span class="c"&gt;# receiver&lt;/span&gt;
nc 192.168.1.x 9999 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; file.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same problem. Cleartext, no auth, one shot if you're lucky. It's basically a raw pipe over TCP dressed up as a tool.&lt;/p&gt;

&lt;h3&gt;
  
  
  Installing openssh-server on your machine
&lt;/h3&gt;

&lt;p&gt;This one at least solves the encryption problem. But now you've installed a permanent service on your host OS. &lt;code&gt;sshd&lt;/code&gt; is running all the time, listening on port 22, attached to your actual system - its users, its filesystem, its everything. You wanted to share one folder for five minutes. You now have a permanently expanded attack surface.&lt;/p&gt;

&lt;p&gt;And if you give the other person SSH credentials? They have a shell into your real machine. Full access. That's a lot of trust for a quick file transfer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Samba / NFS
&lt;/h3&gt;

&lt;p&gt;Heavy to configure, plenty of surface area, and definitely not something you spin up for a quick transfer and tear down cleanly.&lt;/p&gt;




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

&lt;p&gt;The core issue with all of these is that they either skip encryption entirely, or they go too far in the other direction and give the other party more access than a file transfer requires.&lt;/p&gt;

&lt;p&gt;What you actually want is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Encrypted transfer&lt;/li&gt;
&lt;li&gt;Auth (so not just anyone on the LAN can connect)&lt;/li&gt;
&lt;li&gt;Access scoped to exactly one directory, nothing else&lt;/li&gt;
&lt;li&gt;Zero footprint when you're done&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The Solution: OpenSSH Inside a Docker Container
&lt;/h2&gt;

&lt;p&gt;Here's the idea: run an SSH server inside a Docker container. Mount only the specific directory you want to share into that container. The other party connects over SSH, lands in that directory, and that's all they can see - not your home folder, not your system files, not anything else on your machine. When the transfer is done, you stop the container and it's completely gone.&lt;/p&gt;

&lt;p&gt;The host OS never has &lt;code&gt;sshd&lt;/code&gt; installed. No permanent service. No lingering open port. The container is the entire blast radius.&lt;/p&gt;

&lt;p&gt;I built and published this as a ready-to-use Docker image with two variants: one based on Alpine Linux (smaller, recommended) and one based on Ubuntu Noble. The image handles user creation, password setup, and public key injection entirely through environment variables.&lt;/p&gt;




&lt;h2&gt;
  
  
  How It Works
&lt;/h2&gt;

&lt;p&gt;When the container starts, the entrypoint script does the following:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Reads &lt;code&gt;SSH_USER&lt;/code&gt;, &lt;code&gt;SSH_PASSWORD&lt;/code&gt;, and &lt;code&gt;SSH_PUBLIC_KEY&lt;/code&gt; from environment variables&lt;/li&gt;
&lt;li&gt;Creates a low-privilege user inside the container with those credentials&lt;/li&gt;
&lt;li&gt;Writes the public key to that user's &lt;code&gt;~/.ssh/authorized_keys&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Starts &lt;code&gt;sshd&lt;/code&gt; in the foreground&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The user's home directory inside the container is whatever you bind-mount from your host. They can read and write files there. That's the full extent of what they can touch.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step-by-Step: Running It
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Prerequisites:&lt;/strong&gt; Docker installed and running. That's it.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Pull the image
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker pull shobanchiddarth/openssh-server:alpine-1.0.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or if you prefer the Ubuntu variant:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker pull shobanchiddarth/openssh-server:ubuntu-noble-1.0.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Create the directory you want to share
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /tmp/myshare
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Put whatever files you want to transfer in here, or leave it empty for the other party to upload into.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Set up your environment variables
&lt;/h3&gt;

&lt;p&gt;Grab the &lt;code&gt;sample.env&lt;/code&gt; from the repo and copy it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cp &lt;/span&gt;sample.env .env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Edit &lt;code&gt;.env&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SSH_USER=USERNAME
SSH_PASSWORD=PASSWORD
SSH_PUBLIC_KEY=ssh-ed25519 AAAA... you@yourmachine
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;SSH_PUBLIC_KEY&lt;/code&gt; is optional in practice - if the other party doesn't have a key pair, they'll just use the password. But public key is always preferable.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Run the container
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; myshare &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; 0.0.0.0:2222:22 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--env-file&lt;/span&gt; .env &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; /tmp/myshare:/home/USERNAME &lt;span class="se"&gt;\&lt;/span&gt;
  shobanchiddarth/openssh-server:alpine-1.0.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;-v&lt;/code&gt; flag is the key part. &lt;code&gt;/tmp/myshare&lt;/code&gt; is the folder on your machine. &lt;code&gt;/home/USERNAME&lt;/code&gt; is where the SSH user lands inside the container. Those two are linked - anything written to one appears in the other. The username in the path should match &lt;code&gt;SSH_USER&lt;/code&gt; in your &lt;code&gt;.env&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Your LAN IP is whatever &lt;code&gt;ip a&lt;/code&gt; (Linux) or &lt;code&gt;ipconfig&lt;/code&gt; (Windows) shows for your network interface - typically something like &lt;code&gt;192.168.1.x&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Transfer files from the other machine
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Upload a file&lt;/span&gt;
scp &lt;span class="nt"&gt;-P&lt;/span&gt; 2222 /path/to/file.txt USERNAME@192.168.1.x:~/

&lt;span class="c"&gt;# Download a file&lt;/span&gt;
scp &lt;span class="nt"&gt;-P&lt;/span&gt; 2222 USERNAME@192.168.1.x:~/file.txt ./

&lt;span class="c"&gt;# Interactive session&lt;/span&gt;
sftp &lt;span class="nt"&gt;-P&lt;/span&gt; 2222 USERNAME@192.168.1.x
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works from Linux, macOS, and Windows (via PowerShell with OpenSSH, WSL, or Git Bash - all ship with &lt;code&gt;scp&lt;/code&gt; and &lt;code&gt;sftp&lt;/code&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Tear it down
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker stop myshare
docker &lt;span class="nb"&gt;rm &lt;/span&gt;myshare
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Port closed. No service running. Nothing left behind.&lt;/p&gt;




&lt;h2&gt;
  
  
  Windows Host Note
&lt;/h2&gt;

&lt;p&gt;If you're running Docker Desktop on Windows, the volume mount path uses Windows-style paths:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;docker&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;--name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;myshare&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;-p&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;0.0.0.0:2222:22&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;--env-file&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;-v&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;C:\Users\YourName\myshare:/home/USERNAME&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nx"&gt;shobanchiddarth/openssh-server:alpine-1.0.0&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything else - the &lt;code&gt;scp&lt;/code&gt;/&lt;code&gt;sftp&lt;/code&gt; commands from the client side - is identical.&lt;/p&gt;




&lt;h2&gt;
  
  
  Security Properties at a Glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Property&lt;/th&gt;
&lt;th&gt;Behavior&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Encryption&lt;/td&gt;
&lt;td&gt;SSH - all traffic encrypted in transit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Authentication&lt;/td&gt;
&lt;td&gt;Password + public key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Host filesystem&lt;/td&gt;
&lt;td&gt;Completely inaccessible from inside the container&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shell access&lt;/td&gt;
&lt;td&gt;Scoped to the container only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Host SSH daemon&lt;/td&gt;
&lt;td&gt;Never installed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Persistence after use&lt;/td&gt;
&lt;td&gt;Zero - stop the container, it's gone&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One thing worth being explicit about: bind the port to &lt;code&gt;0.0.0.0&lt;/code&gt; only on trusted networks. That makes the container reachable on all your local interfaces. If you want to lock it down to a specific interface, replace &lt;code&gt;0.0.0.0&lt;/code&gt; with that interface's IP (e.g., &lt;code&gt;192.168.1.5:2222:22&lt;/code&gt;). Either way, don't forward port 2222 through your router - this is a LAN tool.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why This Is Better
&lt;/h2&gt;

&lt;p&gt;The Python HTTP server is convenient. This is only marginally less convenient - one &lt;code&gt;docker run&lt;/code&gt; command instead of one &lt;code&gt;python3&lt;/code&gt; command - and in exchange you get encryption, authentication, proper access scoping, and a completely clean teardown.&lt;/p&gt;

&lt;p&gt;If you have Docker, there's no good reason to reach for the HTTP server anymore.&lt;/p&gt;

&lt;p&gt;The repo has both Alpine and Ubuntu variants, a &lt;code&gt;sample.env&lt;/code&gt; template, and full usage documentation. Pull it, try it, and stop sharing files over cleartext.&lt;/p&gt;




&lt;h2&gt;
  
  
  The &lt;code&gt;known_hosts&lt;/code&gt; problem
&lt;/h2&gt;

&lt;p&gt;Let's say you want your friend to share a file to you. So you ask him his public ssh key and spin up a container with port and volume mapped. And then he tries to ssh into your container. Then he gets hit with this&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ssh test@192.168.1.2
The authenticity of host '192.168.1.2 (192.168.1.2)' can't be established.
ED25519 key fingerprint is SHA256:9d8pzTNQ1vLi2laKIqHnzOG+QdgNv8bDQVz67sNJi1E.
This key is not known by any other names.
Are you sure you want to continue connecting (yes/no/[fingerprint])? 
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the unknown host warning, it is what ssh usually does to make sure you are accessing the machine you think you are accessing by showing the host key of the machine. So since your friend knows he is accessing your machine and trusts is, he types yes and then the host key gets added to &lt;code&gt;~/.ssh/known_hosts&lt;/code&gt; file. He then finishes the file sharing work and then closes the connection so you stop the container and delete it. Some time later if he wants to do another file sharing work, you spin up another container and if you are in the same LAN with the same private IP, ssh client on his machine sees your host with a different ssh host key when he is trying to access it. So it prints a message like this&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@    WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!     @
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
Someone could be eavesdropping on you right now (man-in-the-middle attack)!
It is also possible that a host key has just been changed.
The fingerprint for the ED25519 key sent by the remote host is
SHA256:s9QoffY4cHer0u66A9s57WzN3/ufOfCBGTq/v9fhr5w.
Please contact your system administrator.
Add correct host key in /home/mint/.ssh/known_hosts to get rid of this message.
Offending ECDSA key in /home/mint/.ssh/known_hosts:3
  remove with:
  ssh-keygen -f '/home/mint/.ssh/known_hosts' -R '[192.168.56.1]:2222'
Host key for [192.168.56.1]:2222 has changed and you have requested strict checking.
Host key verification failed.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But this can be ignored since it is true that the host key has changed and he has to remove the old known hosts entry with&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ssh-keygen -f '/home/mint/.ssh/known_hosts' -R '[192.168.56.1]:2222'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;as said in that message. If it is unsure whether or not &lt;strong&gt;SOMEONE&lt;/strong&gt; is doing &lt;strong&gt;SOMETHING NASTY&lt;/strong&gt; you should send your host key to your friend and ask him to compare. You can do it by&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;docker exec -it myshare /bin/sh # or /bin/bash if ubuntu
cd /etc/ssh
ls *host*.pub # to see the host public key names
ssh-keygen -lf ssh_host_ecdsa_key.pub 
ssh-keygen -lf ssh_host_ed25519_key.pub 
ssh-keygen -lf ssh_host_rsa_key.pub
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now send the output of the last 3 commands to him and tell him to enter the fingerprint of the algorithm (&lt;code&gt;ecdsa&lt;/code&gt; or &lt;code&gt;ed25519&lt;/code&gt; or &lt;code&gt;rsa&lt;/code&gt;) he is seeing in his screen and if it matches the host will be added to &lt;code&gt;known_hosts&lt;/code&gt; (with the current host public key, so he will have to remove it later by following the above instructions) and the connection will happen.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/ShobanChiddarth/openssh-server" rel="noopener noreferrer"&gt;GitHub: @ShobanChiddarth/openssh-server&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>network</category>
      <category>security</category>
      <category>privacy</category>
    </item>
    <item>
      <title>Setting Up Pi-hole as a Custom DNS Server on my Home Lab</title>
      <dc:creator>Shoban Chiddarth</dc:creator>
      <pubDate>Wed, 04 Mar 2026 14:14:02 +0000</pubDate>
      <link>https://dev.to/shobanchiddarth/setting-up-pi-hole-as-a-custom-dns-server-on-my-home-lab-4jd7</link>
      <guid>https://dev.to/shobanchiddarth/setting-up-pi-hole-as-a-custom-dns-server-on-my-home-lab-4jd7</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;I'm building out a home lab and this post covers setting up Pi-hole as a custom DNS server so that internal hosts are reachable by name rather than IP address.&lt;/p&gt;

&lt;p&gt;This is part of my &lt;a href="https://dev.to/shobanchiddarth/series/36267"&gt;Physical Network Engineering Home Lab series&lt;/a&gt; where I document building and configuring a physical home lab from scratch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reason for Choosing Pi-Hole as a custom DNS server
&lt;/h2&gt;

&lt;p&gt;I needed a local DNS server where I could register internal hostnames and have them resolve across the network — so my server is reachable as &lt;code&gt;server.local&lt;/code&gt; rather than &lt;code&gt;192.168.1.2&lt;/code&gt;. This becomes especially important as I add more services to the lab.&lt;/p&gt;

&lt;p&gt;Pi-hole fits this requirement well. It is primarily a network-level DNS server that supports custom local DNS records, and it comes with a clean web interface for managing them. The fact that it also blocks ads and trackers at the DNS level for every device on the network is a useful side effect — the &lt;a href="https://web.archive.org/web/20221225170743/https://www.ic3.gov/Media/Y2022/PSA221221" rel="noopener noreferrer"&gt;FBI has even recommended ad blockers as protection against malvertising scams&lt;/a&gt;, so having this enforced network-wide without configuring each device individually is a reasonable security baseline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting up Pi-Hole
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Pre-requisites
&lt;/h3&gt;

&lt;p&gt;Refer my &lt;a href="https://dev.to/shobanchiddarth/my-network-engineering-home-lab-setup-2j8g"&gt;Physical Network Engineering Home Lab&lt;/a&gt; to understand what I already have and working with.&lt;/p&gt;

&lt;h3&gt;
  
  
  Installing Pi-Hole software on my server
&lt;/h3&gt;

&lt;p&gt;My server runs headless Linux Mint, which is Ubuntu-based and &lt;a href="https://docs.pi-hole.net/main/prerequisites/#supported-operating-systems" rel="noopener noreferrer"&gt;officially supported&lt;/a&gt; for Pi-hole.&lt;/p&gt;

&lt;p&gt;So I am going to &lt;a href="https://docs.pi-hole.net/main/basic-install/#alternative-2-manually-download-the-installer-and-run" rel="noopener noreferrer"&gt;manually download the installer and run&lt;/a&gt; it on my server.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wget &lt;span class="nt"&gt;-O&lt;/span&gt; basic-install.sh https://install.pi-hole.net
&lt;span class="nb"&gt;sudo &lt;/span&gt;bash basic-install.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The usual install process begins, and I get asked about choosing upstream DNS server.&lt;/p&gt;

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

&lt;p&gt;I went with cloudflare, selected the default ad blocklist, enabled query logging, selected "show everything" because I am the only one using this home lab so there are no privacy issues, and waited for it to finish installing. &lt;/p&gt;

&lt;p&gt;The installation is now complete and I get this popup&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Post-installation
&lt;/h3&gt;

&lt;p&gt;Pi-hole installs a web interface under &lt;code&gt;/var/www/html/admin&lt;/code&gt;. I confirmed it was there after installation.&lt;/p&gt;

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

&lt;p&gt;I visited my server's IP &lt;code&gt;192.168.1.2&lt;/code&gt; on the browser and it worked fine, like usually.&lt;/p&gt;

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

&lt;p&gt;I tried to access the admin panel at &lt;code&gt;192.168.1.2/admin&lt;/code&gt; and got a 403 error.&lt;/p&gt;

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

&lt;p&gt;Then I remembered the admin panel works only over https. So I tried visiting &lt;code&gt;https://192.168.1.2/admin&lt;/code&gt; and it never worked. So I came back to the terminal where I have ssh-ed into the server and ran &lt;code&gt;ss -tulnp | grep :443&lt;/code&gt; to check if the service was running, and the service was indeed running on &lt;code&gt;0.0.0.0&lt;/code&gt; which means it is supposed to be accessible from the other hosts. That is when I remembered I enabled ufw and had to allow certain ports in the firewall.&lt;/p&gt;

&lt;p&gt;I referred the &lt;a href="https://docs.pi-hole.net/main/prerequisites/#ufw" rel="noopener noreferrer"&gt;pi-hole docs for the ufw commands&lt;/a&gt; and ran the following snippet in the terminal&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow 80/tcp
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow 443/tcp
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow 53/tcp
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow 53/udp
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow 67/tcp
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow 67/udp
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow 123/udp
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow 546:547/udp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Future me here, I just realised I don't need to allow port 67 as I did not setup DHCP server on the pi hole).&lt;/p&gt;

&lt;p&gt;Now that I have allowed the ports, I tried visiting &lt;code&gt;https://192.168.1.2/admin/&lt;/code&gt; and got this warning, which I will be taking care of in the next part of my home lab series where I properly configure SSL and get HTTPS on browsers with no warning.&lt;/p&gt;

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

&lt;p&gt;I clicked on "Proceed" and got the pi-hole admin login page, then I entered my admin password I got from the "Installation over" popup and logged in. Now I get this beautiful dashboard.&lt;/p&gt;

&lt;p&gt;Note: You can change the default password by running &lt;code&gt;pihole setpassword&lt;/code&gt; on the pi hole server.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Pointing Router's DHCP to use pi-hole as DNS
&lt;/h2&gt;

&lt;p&gt;I opened my Tenda router's login page via its IP, went to "Internet Settings" and set the DHCP server to &lt;code&gt;192.168.1.2&lt;/code&gt;&lt;/p&gt;

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

&lt;p&gt;Then I went into System Settings -&amp;gt; LAN settings and did the same&lt;/p&gt;

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

&lt;p&gt;To refresh the DNS server config from the DHCP configuration, I manually turned off the wifi from my laptop and turned it on again.&lt;/p&gt;

&lt;p&gt;The DNS server shows up as &lt;code&gt;192.168.1.2&lt;/code&gt; &lt;/p&gt;

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

&lt;h2&gt;
  
  
  Verifying Ad blocking at Network level
&lt;/h2&gt;

&lt;p&gt;I checked if the DNS service was working, for both allowed and blocked hosts.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gray@OMEN-Slim-Gaming-Laptop-16:~&lt;span class="nv"&gt;$ &lt;/span&gt;nslookup google.com
Server:     127.0.0.53
Address:    127.0.0.53#53

Non-authoritative answer:
Name:   google.com
Address: 142.251.222.78
Name:   google.com
Address: 2404:6800:4009:80b::200e

gray@OMEN-Slim-Gaming-Laptop-16:~&lt;span class="nv"&gt;$ &lt;/span&gt;nslookup ad-assets.futurecdn.net 
Server:     127.0.0.53
Address:    127.0.0.53#53

Non-authoritative answer:
Name:   ad-assets.futurecdn.net
Address: 0.0.0.0
Name:   ad-assets.futurecdn.net
Address: ::

gray@OMEN-Slim-Gaming-Laptop-16:~&lt;span class="nv"&gt;$ &lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works perfectly fine. Ads are blocked in the network level.&lt;/p&gt;

&lt;p&gt;Side note: If you are using Cloudflare WARP for privacy, all your internet traffic, including DNS queries are routed through Cloudflare's servers which means the DNS lookup never reaches your Pi-hole and the advertisements domains will be rendered fine. So if you want to access your home lab's hosts using DNS resolution from pi hole or any other DNS server or network level ad blocking for devices where cloudflare warp is not available, you must temporarily turn off Cloudflare WARP. &lt;/p&gt;

&lt;h2&gt;
  
  
  Setting Up Custom DNS records
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;pi.hole&lt;/code&gt; is a custom DNS entry in pi-hole by default and it works.&lt;/p&gt;

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

&lt;p&gt;I want to add my own DNS records and check them, which is the entire purpose of this project. So I wanted another host with static IP on the same LAN. &lt;/p&gt;

&lt;h3&gt;
  
  
  Virtualbox VM with static IP in the LAN
&lt;/h3&gt;

&lt;p&gt;I cloned an existing Mint VM, selected to create random MAC addresses for each interface and set it's network adapter to "Bridged" and bridged it with the real interface I use to connect to the LAN, and set "Promiscuous Mode" to allow all. For more information on Virtualbox networking see &lt;a href="https://www.virtualbox.org/manual/ch06.html" rel="noopener noreferrer"&gt;Virtualbox  Manual - Chapter 6 : Virtual Networking&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;When I booted it up, it had DHCP by default.&lt;/p&gt;

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

&lt;p&gt;I went to settings and changed it to static IP &lt;code&gt;192.168.1.23&lt;/code&gt; and turned the interface off and turned it on again and the static IP is available and accessible from the host as well as the server. I also turned off automatic DNS as DHCP will be disabled and manually added &lt;code&gt;192.168.1.2&lt;/code&gt; as the DNS server.&lt;/p&gt;

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

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

&lt;h3&gt;
  
  
  Adding a custom DNS record in pi hole web GUI
&lt;/h3&gt;

&lt;p&gt;I went to Pi hole admin page -&amp;gt; settings -&amp;gt; Local DNS records and added this entry&lt;/p&gt;

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

&lt;p&gt;Side Note: I want this to be temporary that is why I didn't bind DHCP reservation to MAC address in the router.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing the Custom DNS record
&lt;/h2&gt;

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

&lt;p&gt;DNS query works from both my laptop and the server. So I set up a nginx web server in the VM like usually&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sudo apt-get update
sudo apt-get install nginx -y
sudo systemctl enable nginx --now
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And edited the default &lt;code&gt;index.html&lt;/code&gt; file.&lt;/p&gt;

&lt;p&gt;Nginx web page as well as pinging is working from my laptop&lt;/p&gt;

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

&lt;p&gt;as well as the server.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Changing default website on the pi-hole DNS server (not required)
&lt;/h2&gt;

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

&lt;p&gt;This is the new website hosted on the root folder of the DNS server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Minor Issues encountered
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Cloudflare WARP conflict (as mentioned above)&lt;/li&gt;
&lt;li&gt;Do not use a custom domain with TLD as &lt;code&gt;.local&lt;/code&gt; because all &lt;code&gt;.local&lt;/code&gt; domains are first processed through the host before sending it anywhere else and it is used for &lt;a href="https://en.wikipedia.org/wiki/Multicast_DNS" rel="noopener noreferrer"&gt;mDNS&lt;/a&gt; (multicast DNS, for when hosts need to discover each other without a central DNS server)&lt;/li&gt;
&lt;li&gt;Make sure to allow the required ports in your firewall as mentioned above&lt;/li&gt;
&lt;li&gt;Every client that is connected to the LAN and is using DHCP has to refresh by disconnecting and reconnecting for this update to work.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  CONCLUSION
&lt;/h2&gt;

&lt;p&gt;I have successfully setup pi-hole on an Ubuntu-based server for network wide ad blocking as well as local DNS resolution. &lt;/p&gt;

&lt;p&gt;After this, I am going to set up fully local SSL for HTTPS (without browser warnings) on my physical network engineering home lab. I will also document setting up pi-hole in docker as well as a Virtualbox VM as a portable alternative when this pi-hole server is inaccessible.&lt;/p&gt;

</description>
      <category>pihole</category>
      <category>privacy</category>
      <category>linux</category>
      <category>network</category>
    </item>
    <item>
      <title>How to clone all GitHub repos of multiple users at once?</title>
      <dc:creator>Shoban Chiddarth</dc:creator>
      <pubDate>Thu, 26 Feb 2026 15:58:52 +0000</pubDate>
      <link>https://dev.to/shobanchiddarth/how-to-clone-all-github-repos-of-multiple-users-at-once-36bn</link>
      <guid>https://dev.to/shobanchiddarth/how-to-clone-all-github-repos-of-multiple-users-at-once-36bn</guid>
      <description>&lt;p&gt;&lt;a href="https://github.com/ShobanChiddarth/panic-clone" rel="noopener noreferrer"&gt;Repo Link&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This script helps you clone all repositories of a given list of GitHub users (including organizations) at once, instead of cloning them manually one by one.&lt;/p&gt;

&lt;p&gt;I built it after running into the need to archive and collect repositories from multiple accounts efficiently. The goal was simple: automate bulk cloning in a clean, repeatable way.&lt;/p&gt;




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

&lt;ul&gt;
&lt;li&gt;Accepts multiple GitHub usernames&lt;/li&gt;
&lt;li&gt;Fetches all accessible repositories via the GitHub API&lt;/li&gt;
&lt;li&gt;Clones them into a structured local directory&lt;/li&gt;
&lt;li&gt;Supports shallow clone (&lt;code&gt;--depth 1&lt;/code&gt;) or full history&lt;/li&gt;
&lt;li&gt;Skips repositories that are inaccessible or already cloned&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each user gets their own folder inside the target directory.&lt;/p&gt;




&lt;h2&gt;
  
  
  Requirements
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Python 3.12+&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;git&lt;/code&gt; installed and available in PATH&lt;/li&gt;
&lt;li&gt;A GitHub Personal Access Token with &lt;strong&gt;read access to repository contents&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;Generate a Personal Access Token:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Go to GitHub → Settings → Developer settings → Personal access tokens&lt;/li&gt;
&lt;li&gt;Create a token with repository read access&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Copy &lt;code&gt;sample.env&lt;/code&gt; to &lt;code&gt;.env&lt;/code&gt;  &lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Replace the placeholder token value with your actual token  &lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Install dependencies:&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






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

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python github-main.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You’ll be prompted for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A space-separated list of GitHub usernames&lt;/li&gt;
&lt;li&gt;A target directory (default: &lt;code&gt;mass_clone_output&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Clone depth (&lt;code&gt;None&lt;/code&gt; for full history or &lt;code&gt;1&lt;/code&gt; for latest commit only)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The script validates usernames, fetches repositories, and clones them sequentially with live &lt;code&gt;git clone&lt;/code&gt; output shown in the terminal.&lt;/p&gt;




&lt;h2&gt;
  
  
  Repository
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/ShobanChiddarth/panic-clone" rel="noopener noreferrer"&gt;GitHub Link&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://archive.softwareheritage.org/browse/origin/directory/?origin_url=https://github.com/ShobanChiddarth/panic-clone&amp;amp;visit_type=git" rel="noopener noreferrer"&gt;SoftwareHeritage Archive&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>python</category>
      <category>automation</category>
      <category>git</category>
      <category>github</category>
    </item>
    <item>
      <title>How to Enable MAC Address Randomisation in Linux Desktop</title>
      <dc:creator>Shoban Chiddarth</dc:creator>
      <pubDate>Sat, 14 Feb 2026 17:01:13 +0000</pubDate>
      <link>https://dev.to/shobanchiddarth/how-to-enable-mac-address-randomisation-in-linux-desktop-4g2k</link>
      <guid>https://dev.to/shobanchiddarth/how-to-enable-mac-address-randomisation-in-linux-desktop-4g2k</guid>
      <description>&lt;p&gt;Link to Project: &lt;a href="https://github.com/ShobanChiddarth/randomised_mac_linux" rel="noopener noreferrer"&gt;https://github.com/ShobanChiddarth/randomised_mac_linux&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a Persistent MAC Address Is a Problem
&lt;/h2&gt;

&lt;p&gt;A persistent hardware address allows networks to consistently identify the same device across sessions. On:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Public Wi-Fi&lt;/li&gt;
&lt;li&gt;Campus networks&lt;/li&gt;
&lt;li&gt;Enterprise environments&lt;/li&gt;
&lt;li&gt;Captive portals&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;this makes long-term tracking trivial, even if your IP address changes.&lt;/p&gt;

&lt;p&gt;Many Linux desktops still use the permanent hardware address by default, which means your device can be passively identified every time it reconnects to a network.&lt;/p&gt;

&lt;h2&gt;
  
  
  How This Project Solves It
&lt;/h2&gt;

&lt;p&gt;This project automates MAC address randomisation at boot.&lt;/p&gt;

&lt;p&gt;On every system startup:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The interface is brought down&lt;/li&gt;
&lt;li&gt;A new locally administered MAC address is generated&lt;/li&gt;
&lt;li&gt;The interface is brought back up&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A &lt;code&gt;systemd&lt;/code&gt; service ensures this runs automatically, requiring no manual action after setup.&lt;/p&gt;




&lt;h2&gt;
  
  
  Installation
&lt;/h2&gt;

&lt;p&gt;When you install &lt;code&gt;macchanger&lt;/code&gt;, you may get a prompt to enable regular changing of MAC addresses. You should select "no" because we wan't complete control over when and how the MAC address randomisation is scheduled.&lt;/p&gt;

&lt;h4&gt;
  
  
  Debian based
&lt;/h4&gt;

&lt;p&gt;Save this script and run it as &lt;code&gt;sudo&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;macchanger git &lt;span class="nt"&gt;-y&lt;/span&gt;
git clone https://github.com/ShobanChiddarth/randomised_mac_linux.git
&lt;span class="nb"&gt;cd &lt;/span&gt;randomised_mac_linux
&lt;span class="nb"&gt;chmod&lt;/span&gt; +x ./randomise_MACs.sh
&lt;span class="nb"&gt;sudo cp &lt;/span&gt;randomise_MACs.sh /usr/bin/
&lt;span class="nb"&gt;sudo cp &lt;/span&gt;randomise_MACs.service /etc/systemd/system/
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl daemon-reload
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable &lt;/span&gt;randomise_MACs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now your MAC addresses of real network interfaces will automatically change to a random value on each boot. To verify it, you can run &lt;code&gt;ifconfig&lt;/code&gt; to show MAC addresses before and after running &lt;code&gt;sudo systemctl start randomise_MACs&lt;/code&gt; to compare values.&lt;/p&gt;

&lt;h4&gt;
  
  
  Other distros
&lt;/h4&gt;

&lt;p&gt;Visit &lt;a href="https://github.com/ShobanChiddarth/randomised_mac_linux" rel="noopener noreferrer"&gt;the GitHub repo&lt;/a&gt; and google your distro specific command for each command listed.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>cybersecurity</category>
      <category>privacy</category>
    </item>
    <item>
      <title>My Network Engineering Home Lab setup</title>
      <dc:creator>Shoban Chiddarth</dc:creator>
      <pubDate>Tue, 10 Feb 2026 14:30:41 +0000</pubDate>
      <link>https://dev.to/shobanchiddarth/my-network-engineering-home-lab-setup-2j8g</link>
      <guid>https://dev.to/shobanchiddarth/my-network-engineering-home-lab-setup-2j8g</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;A while ago, I built a home lab to gain hands-on experience with Linux server administration and network engineering. Using some old hardware, a ThinkCentre M81 desktop and a Tenda router, I set up a private LAN, configured a static IP and DHCP reservations, and deployed an SSH-accessible Linux server running an Nginx web server.&lt;/p&gt;

&lt;p&gt;This project allowed me to apply concepts I had learned in college in a real-world scenario, practice network setup and IP management, and explore server configuration beyond the classroom. It’s also an example of repurposing older hardware to create a functional learning environment.&lt;/p&gt;

&lt;p&gt;Below, I’ve documented the full technical process, step by step, so you can see exactly how I built this setup and replicate it if you want to create your own home lab.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tenda Router Setup
&lt;/h2&gt;

&lt;p&gt;The router I used is the Tenda AC1200 router: &lt;a href="https://www.tendacn.com/product/ac6.html" rel="noopener noreferrer"&gt;https://www.tendacn.com/product/ac6.html&lt;/a&gt;.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Turn on the router.&lt;/li&gt;
&lt;li&gt;Connect to the router’s LAN port (1 or 2) via ethernet from a PC you are able to use.&lt;/li&gt;
&lt;li&gt;Visit &lt;code&gt;192.168.0.1&lt;/code&gt;, the default router IP (written in the back of your router) from a web browser.&lt;/li&gt;
&lt;li&gt;Enter the default login password (written in the back of your router).&lt;/li&gt;
&lt;li&gt;Go to system settings and change the login password&lt;/li&gt;
&lt;li&gt;Go to Wi-Fi settings and change the Wi-Fi name and password.&lt;/li&gt;
&lt;li&gt;Go to system settings and upgrade the firmware if needed.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The router is ready for connection. Disconnect the ethernet from the router, you can now access it via Wi-Fi.&lt;/p&gt;

&lt;h2&gt;
  
  
  ThinkCentre M81 Server setup
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Installing Linux Mint
&lt;/h3&gt;

&lt;p&gt;Connect the ethernet port of the CPU to any the Tenda router’s LAN ports, and install linuxmint normally: &lt;a href="https://linuxmint-installation-guide.readthedocs.io/en/latest/" rel="noopener noreferrer"&gt;https://linuxmint-installation-guide.readthedocs.io/en/latest/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;But during the installation process, choose to erase the whole disk and install linux mint while asked about partitions.&lt;/p&gt;

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

&lt;p&gt;Do not select to set up an encrypted LVM (full disk encryption) in the Advanced features. Using an LVM is okay but encrypted LVM for full disk encryption requires someone to type the password in the keyboard connected to the server every time it boots, and we want it to automatically boot and be ready for ssh login so it is not recommended.&lt;/p&gt;

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

&lt;p&gt;And the authorized ssh keys stay in the home folder of the user, so if you encrypt it, logging in to the user account will be required for key based ssh to work. Logging in remotely strictly using ssh keys will not work then. So do not encrypt the home folder.&lt;/p&gt;

&lt;p&gt;After the installation is complete, login and set the root password.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sudo passwd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Turning the desktop into a server
&lt;/h3&gt;

&lt;p&gt;After that, we need to turn this into a server from a desktop.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Install &lt;code&gt;openssh-server&lt;/code&gt; and configure it: &lt;a href="https://wiki.debian.org/SSH#Installation_of_the_server" rel="noopener noreferrer"&gt;https://wiki.debian.org/SSH#Installation_of_the_server&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;[Not required but highly recommended] Make the ssh server run on a port different from 22, for security (to prevent automated brute force scripts).&lt;/li&gt;
&lt;li&gt;[Not required but highly recommended] Turn on firewall and allow the ssh. When the firewall is turned off, every port open on the system is open to the network. So turn it on to make sure only the ports you allow are accessible from the outside.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sudo ufw allow &amp;lt;ssh_port_number&amp;gt;
sudo ufw enable
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Now we need static IP for the server instead of DHCP from the router. We will be using systemd-networkd for this.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sudo ifconfig
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And remember the name of the ethernet port connecting to the router (usually eno1)&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Now stop and disable NetworkManager, it is bound to the display manager so if we use primarily Network Manager, the server will not have networking when started headless.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sudo systemctl stop NetworkManager
sudo systemctl disable NetworkManager
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Now enable systemd-networkd on boot
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sudo systemctl enable systemd-networkd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Create a file &lt;code&gt;/etc/systemd/network/10-eno1.network&lt;/code&gt; and put in the contents
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Match]
Name=eno1

[Network]
Address=192.168.0.2/24
Gateway=192.168.0.1
DNS=192.168.0.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Where &lt;code&gt;192.168.0.1&lt;/code&gt; is the IP address of your router and &lt;code&gt;192.168.0.2&lt;/code&gt; is the IP address of the server, and &lt;code&gt;eno1&lt;/code&gt; is the name of the network interface of the ethernet port.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Reload systemd-networkd for the changes to take effect
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sudo systemctl restart systemd-networkd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Now run &lt;code&gt;sudo ifconfig&lt;/code&gt; again and the ip address should have changed on the ethernet port.&lt;/li&gt;
&lt;li&gt;Now that we have static IP, go to router login page -&amp;gt; System Settings -&amp;gt; DHCP Reservation
&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fc4stq9hz8n3tpht6i728.png" alt="tenda-dhcp-reserv" width="800" height="391"&gt;
And manually bind the static IP to the MAC address of the server.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We have turned the desktop into a server. Now ssh into it from your PC to test if it works.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ssh -p &amp;lt;ssh_port_number&amp;gt; username@192.168.0.2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Making server headless
&lt;/h2&gt;

&lt;p&gt;Once you have logged in to the server via ssh, you can easily turn it headless. You simply have to stop the display manager and disable it on boot so it always runs headless.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(after ssh into server)
sudo systemctl stop lightdm
sudo systemctl disable lightdm
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After this, the monitor screen connected to the desktop goes blank. This is expected. If you are not sure if the system hanged or if the graphical manager has stopped, press Ctrl+Alt+F1 to enter virtual terminal 1. If you see the virtual terminal, it is working properly. Now you can safely remove the monitor and keyboard and mouse from the server’s CPU as you won’t need it anymore and will always access it by ssh.&lt;/p&gt;

&lt;h2&gt;
  
  
  Configuring Web Server
&lt;/h2&gt;

&lt;p&gt;While you are still ssh-ed into the server, run&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sudo apt install nginx
sudo systemctl start nginx
sudo systemctl enable nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To install, start and enable the web server on boot. It runs on port 80, the default port for HTTP. So allow it on the firewall for the website to be accessed from the LAN.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sudo ufw allow 80
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can leave it as it is to get the default nginx server page or create a file &lt;code&gt;/var/www/html/index.html&lt;/code&gt; with your own website on the &lt;code&gt;index.html&lt;/code&gt; file, or even host a react app by copying the build files into the &lt;code&gt;/var/www/html&lt;/code&gt; folder.&lt;/p&gt;

&lt;h2&gt;
  
  
  Accessing the website
&lt;/h2&gt;

&lt;p&gt;Connect to the Wi-Fi network of the Tenda router from any device that has Wi-Fi.&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fo16p8noin4w1xe8417wt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fo16p8noin4w1xe8417wt.png" alt="wifi-con" width="800" height="1066"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And then open the IP address of the server in a web browser. You will get a no HTTPS warning but you can ignore it and continue since we are in a controlled environment.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  SUCCESS
&lt;/h2&gt;

&lt;p&gt;That’s it. The home LAB has been set up successfully. You have a LAN on Wi-Fi and a server that you can ssh into and use as backup storage, and a web server you can host websites on your LAN.&lt;/p&gt;

</description>
      <category>network</category>
      <category>linux</category>
    </item>
  </channel>
</rss>
