<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: Prajwol Adhikari</title>
    <description>The latest articles on Forem by Prajwol Adhikari (@prajwol-ad).</description>
    <link>https://forem.com/prajwol-ad</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%2F2832799%2F1a77016f-6d1e-4f3e-80b7-fce915def15f.jpg</url>
      <title>Forem: Prajwol Adhikari</title>
      <link>https://forem.com/prajwol-ad</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/prajwol-ad"/>
    <language>en</language>
    <item>
      <title>Part 2: Infrastructure as Code with Terraform, OIDC, and a GitOps Pipeline</title>
      <dc:creator>Prajwol Adhikari</dc:creator>
      <pubDate>Sun, 10 May 2026 01:01:53 +0000</pubDate>
      <link>https://forem.com/prajwol-ad/part-2-infrastructure-as-code-with-terraform-oidc-and-a-gitops-pipeline-1c1o</link>
      <guid>https://forem.com/prajwol-ad/part-2-infrastructure-as-code-with-terraform-oidc-and-a-gitops-pipeline-1c1o</guid>
      <description>&lt;h3&gt;
  
  
  Introduction
&lt;/h3&gt;

&lt;p&gt;In Part 1, I built a security-gated CI/CD pipeline for my portfolio site — Gitleaks, CodeQL, Lighthouse audits, and secretless OIDC deployment to GitHub Pages. That pipeline was about code delivery. Push code, run checks, deploy the site.&lt;/p&gt;

&lt;p&gt;But the whole time I was building that pipeline, the infrastructure underneath it — the DNS records, the cloud servers, the network configuration — was still managed by hand. I would log into Cloudflare, click around to add a DNS record. Log into Oracle Cloud, click through a wizard to resize an instance. If something broke, I would try to remember what I had changed and where.&lt;/p&gt;

&lt;p&gt;That is fine when you have two or three things to manage. I had thirteen DNS records across multiple subdomains, a Cloudflare Tunnel configuration, an Oracle Cloud VCN with a subnet and a compute instance, and an AWS S3 bucket holding my Terraform state. Keeping track of all of that by clicking through dashboards was starting to feel like a job I was doing badly.&lt;/p&gt;

&lt;p&gt;So Part 2 is about bringing all of that under code. Every DNS record, every cloud resource, defined in Terraform files, stored in GitHub, and deployed through a pipeline. No more dashboard clicking. No more "wait, did I change that setting last week or was it always like that?"&lt;/p&gt;

&lt;p&gt;This one took longer than Part 1. There were more moving parts, more credentials to manage, and a migration that I was genuinely nervous about. But it is done, and my infrastructure is now as version-controlled as my code.&lt;/p&gt;




&lt;h3&gt;
  
  
  See it live
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://prajwolbikramadhikari.com.np/lab/" rel="noopener noreferrer"&gt;The Lab&lt;/a&gt; — live infrastructure status and build progress tracker&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://prajwolbikramadhikari.com.np/architecture/" rel="noopener noreferrer"&gt;Architecture diagram&lt;/a&gt; — five-zone infrastructure map spanning Waco TX, Phoenix AZ, and Amsterdam NL&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  What is Infrastructure as Code and why should you care?
&lt;/h3&gt;

&lt;p&gt;The concept is simple: instead of configuring infrastructure by clicking through web dashboards, you write code that describes what you want to exist. Then a tool reads that code and creates it for you.&lt;/p&gt;

&lt;p&gt;The code becomes your documentation. If someone asks "what DNS records does your domain have?", you do not need to log into Cloudflare and screenshot the dashboard. You point them at a file. If you need to recreate everything from scratch — disaster recovery, new environment, new cloud account — you run one command instead of spending a day clicking through consoles trying to remember every setting.&lt;/p&gt;

&lt;p&gt;But the part that really sold me on it was the diff. When you change a Terraform file and run &lt;code&gt;terraform plan&lt;/code&gt;, it shows you exactly what will change before anything happens. "I am going to add this DNS record, modify this subnet rule, and leave everything else alone." You review it, confirm it, and only then does it apply. Compare that to clicking "Save" in a dashboard and hoping you did not just break something.&lt;/p&gt;

&lt;p&gt;In my day job at AbbVie, we do not make changes to production systems without documentation and review. That is what cGMP requires. Terraform brings that same discipline to infrastructure — every change is tracked, reviewed, and auditable.&lt;/p&gt;




&lt;h3&gt;
  
  
  Chapter 1: The Module Structure
&lt;/h3&gt;

&lt;p&gt;Before writing any Terraform, I had to decide how to organize the code. Terraform lets you put everything in one big file, but that gets messy fast when you are managing resources across multiple cloud providers.&lt;/p&gt;

&lt;p&gt;I went with a modular structure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;homelab-iac/
├── backend.tf              # Where Terraform stores its state
├── main.tf                 # Calls the modules, passes variables
├── variables.tf            # Declares all input variables
├── terraform.tfvars        # Actual secret values (gitignored)
├── modules/
│   ├── cloudflare/
│   │   ├── main.tf         # All 13 DNS record resources
│   │   └── variables.tf    # Cloudflare-specific variables
│   └── oracle/
│       ├── main.tf         # VCN, subnet, compute instance
│       └── variables.tf    # OCI-specific variables
└── .github/
    └── workflows/
        ├── terraform-plan.yml
        └── terraform-apply.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The idea is separation of concerns. The Cloudflare module knows how to manage DNS records. The Oracle module knows how to manage cloud infrastructure. &lt;code&gt;main.tf&lt;/code&gt; connects them. If I add a third cloud provider later, I add a third module without touching the existing ones.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;terraform.tfvars&lt;/code&gt; file contains the actual secret values — API tokens, OCIDs, private keys. It is in &lt;code&gt;.gitignore&lt;/code&gt; and never gets committed to GitHub. More on how the pipeline handles this later.&lt;/p&gt;




&lt;h3&gt;
  
  
  Chapter 2: Cloudflare DNS — 13 Records as Code
&lt;/h3&gt;

&lt;p&gt;Before Terraform, my DNS setup was a collection of manually created records in the Cloudflare dashboard. I knew roughly what they all did, but I could not have listed all thirteen from memory. Converting them to code forced me to actually understand each one.&lt;/p&gt;

&lt;p&gt;Here is what I am managing:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Portfolio site:&lt;/strong&gt; Two CNAME records pointing the root domain and www subdomain to Netlify, where my Hugo site is hosted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cloudflare Tunnel records:&lt;/strong&gt; Seven CNAME records, one for each service I expose through the tunnel — Grafana, Prometheus, AdGuard, Homer, n8n, Nginx Proxy Manager, and cAdvisor. Each one points to the tunnel ID so traffic routes through Cloudflare's edge network instead of hitting my home IP directly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Email authentication:&lt;/strong&gt; Three records — SPF, DKIM, and DMARC. These are TXT records that prove to email servers that mail claiming to come from my domain is legitimate. I do not actually send email from this domain, but having these records prevents someone else from spoofing it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub Pages verification:&lt;/strong&gt; A TXT record that proves to GitHub that I own the domain, required for the custom domain configuration on GitHub Pages.&lt;/p&gt;

&lt;p&gt;A single DNS record in Terraform looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"cloudflare_record"&lt;/span&gt; &lt;span class="s2"&gt;"tunnel_grafana"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;zone_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cloudflare_zone_id&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"grafana"&lt;/span&gt;
  &lt;span class="nx"&gt;content&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${var.tunnel_id}.cfargotunnel.com"&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"CNAME"&lt;/span&gt;
  &lt;span class="nx"&gt;proxied&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nx"&gt;ttl&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is it. Six lines. If I need to change the Grafana subdomain or point it somewhere else, I change one line of code, open a pull request, review the plan, and merge. The pipeline applies it automatically.&lt;/p&gt;

&lt;p&gt;The Cloudflare provider authenticates using an API token scoped to Zone:Zone:Read and Zone:DNS:Edit on my specific zone only. Least privilege — the token cannot touch anything outside my domain.&lt;/p&gt;




&lt;h3&gt;
  
  
  Chapter 3: Oracle Cloud — The Network and the Server
&lt;/h3&gt;

&lt;p&gt;The Oracle module manages three resources that form a complete cloud environment:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;VCN (Virtual Cloud Network):&lt;/strong&gt; A private network inside Oracle Cloud. Think of it as creating your own isolated LAN in the cloud. Resources inside the VCN can talk to each other, but the outside world cannot reach them unless you explicitly allow it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Subnet:&lt;/strong&gt; A subdivision of the VCN that defines the IP address range and routing rules. My compute instance sits in this subnet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Compute Instance:&lt;/strong&gt; The actual virtual machine — an Ampere ARM instance with 4 OCPUs and 24GB of RAM, running Ubuntu 24.04. This is my cloud server in Phoenix, Arizona. It runs AdGuard Home for DNS ad-blocking, Ollama with DeepSeek-R1 for the AI log summarizer, and Node Exporter for monitoring.&lt;/p&gt;

&lt;p&gt;All three resources are defined in &lt;code&gt;modules/oracle/main.tf&lt;/code&gt;. If Oracle ever reclaims the instance (it happens on the Always Free tier), I can recreate the entire network and server by running the pipeline. Everything comes back exactly as defined — same VCN, same subnet, same instance shape and configuration.&lt;/p&gt;

&lt;p&gt;One thing Terraform does not manage here is what runs inside the instance. Docker, Ollama, AdGuard — those were all set up manually via SSH. Terraform creates the machine. Configuring what is on it is a different tool's job — Ansible, probably, in a future phase.&lt;/p&gt;




&lt;h3&gt;
  
  
  Chapter 4: Remote State — And Why It Matters More Than You Think
&lt;/h3&gt;

&lt;p&gt;When Terraform creates a resource, it writes a record of that resource to a state file. The state file is how Terraform knows what already exists, so it can figure out what needs to change on the next run.&lt;/p&gt;

&lt;p&gt;By default, the state file lives on your local machine. That works for one person on one laptop, but it has two serious problems:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;If your laptop dies, you lose the state. Terraform no longer knows what exists. You either import every resource manually or start over.&lt;/li&gt;
&lt;li&gt;If two people (or two pipeline runs) execute Terraform at the same time, they can corrupt the state by writing to it simultaneously.
I solved both problems by storing state remotely in AWS S3:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;backend&lt;/span&gt; &lt;span class="s2"&gt;"s3"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;bucket&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"your-terraform-state-bucket"&lt;/span&gt;
    &lt;span class="nx"&gt;key&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"homelab/terraform.tfstate"&lt;/span&gt;
    &lt;span class="nx"&gt;region&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"us-east-1"&lt;/span&gt;
    &lt;span class="nx"&gt;use_lockfile&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="nx"&gt;encrypt&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The state file lives in an S3 bucket, encrypted at rest. If my local server catches fire, the state is safe in AWS.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;use_lockfile = true&lt;/code&gt; line enables S3-native state locking. When Terraform runs, it creates a &lt;code&gt;.tflock&lt;/code&gt; file in the bucket. If a second process tries to run simultaneously, it sees the lock and waits. No corruption possible.&lt;/p&gt;

&lt;p&gt;I originally used a DynamoDB table for state locking — that was the standard approach for years. But Terraform 1.10 introduced S3-native locking, and as of 1.11 the DynamoDB approach is deprecated. I migrated by changing one line in &lt;code&gt;backend.tf&lt;/code&gt;, running &lt;code&gt;terraform init -reconfigure&lt;/code&gt;, and deleting the DynamoDB table. The whole migration took about five minutes and simplified my AWS footprint.&lt;/p&gt;




&lt;h3&gt;
  
  
  Chapter 5: The Pipeline — Plan on PR, Apply on Merge
&lt;/h3&gt;

&lt;p&gt;Having Terraform code in GitHub is nice. Having it automatically validate and deploy is the real goal.&lt;/p&gt;

&lt;p&gt;I created two GitHub Actions workflows:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;terraform-plan.yml&lt;/strong&gt; triggers on every pull request to main. It runs &lt;code&gt;terraform fmt -check&lt;/code&gt; (is the code formatted correctly?), &lt;code&gt;terraform validate&lt;/code&gt; (is the syntax valid?), and &lt;code&gt;terraform plan&lt;/code&gt; (what would change?). If any step fails, the PR is blocked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;terraform-apply.yml&lt;/strong&gt; triggers when code is merged to main. It runs &lt;code&gt;terraform apply -auto-approve&lt;/code&gt;, actually making the infrastructure changes.&lt;/p&gt;

&lt;p&gt;The plan workflow is the review step. When I open a PR that adds a DNS record, the plan output shows exactly what will be created. I read it, confirm it looks right, and merge. The apply workflow does the rest.&lt;/p&gt;

&lt;p&gt;This is the same GitOps pattern used by platform engineering teams at companies much larger than my homelab. Git is the source of truth. Every change goes through a PR, gets validated by the pipeline, and is applied automatically on merge. The Git history becomes an audit log of every infrastructure change.&lt;/p&gt;




&lt;h3&gt;
  
  
  Chapter 6: OIDC — The Part That Changed How I Think About Credentials
&lt;/h3&gt;

&lt;p&gt;In Part 1, I used OIDC to deploy to GitHub Pages without stored tokens. In Part 2, I used the same concept for something more complex — authenticating GitHub Actions to AWS.&lt;/p&gt;

&lt;p&gt;The old way would be to create an AWS access key and secret key, store them in GitHub Secrets, and reference them in the workflow. Those keys never expire. If someone compromises your repo or your secrets leak, they have permanent access to your AWS account.&lt;/p&gt;

&lt;p&gt;OIDC flips this around. I configured three things in AWS:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;An OIDC Identity Provider&lt;/strong&gt; — tells AWS "I know what GitHub Actions is and I trust their identity tokens."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An IAM Role&lt;/strong&gt; with a trust policy scoped to my specific repo — tells AWS "only workflows running from &lt;code&gt;&amp;lt;your-username&amp;gt;/&amp;lt;your-repo&amp;gt;&lt;/code&gt; can assume this role."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An inline policy&lt;/strong&gt; — tells AWS "this role can only read and write to the S3 state bucket. Nothing else."
When the pipeline runs, GitHub generates a short-lived token proving it is a workflow from my repo. AWS verifies the token, checks it against the trust policy, and hands back temporary credentials that expire in one hour. The pipeline uses those credentials, finishes its work, and the credentials disappear.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No permanent keys. Nothing stored in secrets. Nothing to rotate. Nothing to leak.&lt;/p&gt;

&lt;p&gt;The workflow step is surprisingly simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Configure AWS credentials&lt;/span&gt;
  &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aws-actions/configure-aws-credentials@v4&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;role-to-assume&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.AWS_ROLE_ARN }}&lt;/span&gt;
    &lt;span class="na"&gt;aws-region&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;us-east-1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The only secret stored in GitHub is the ARN (Amazon Resource Name) of the IAM role — which is not a credential. It is just an identifier. The actual authentication happens through the OIDC handshake at runtime.&lt;/p&gt;

&lt;p&gt;This was the piece that genuinely shifted how I think about credential management. In my previous work, I had always treated API keys as "generate once, store somewhere, hope nobody finds them." OIDC eliminates the "hope" part entirely.&lt;/p&gt;




&lt;h3&gt;
  
  
  Chapter 7: Handling Secrets in the Pipeline
&lt;/h3&gt;

&lt;p&gt;The pipeline needs credentials for three providers — AWS, Cloudflare, and Oracle Cloud. Each one is handled differently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AWS:&lt;/strong&gt; OIDC, as described above. No stored credentials.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cloudflare:&lt;/strong&gt; An API token stored as a GitHub Secret (&lt;code&gt;CLOUDFLARE_API_TOKEN&lt;/code&gt;). The token is scoped to Zone:Zone:Read and Zone:DNS:Edit on my specific zone. I rotated the token during this setup — generated a new one, added it to GitHub Secrets, revoked the old one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Oracle Cloud:&lt;/strong&gt; Multiple values stored as GitHub Secrets — tenancy OCID, user OCID, fingerprint, region, compartment ID, SSH public key, and a private key. The tricky one was the private key. On my local machine, Terraform reads it from a file at &lt;code&gt;~/.oci/oci_api_key.pem&lt;/code&gt;. That file does not exist in GitHub Actions. So the workflow writes the key content from the secret to a temporary file before Terraform runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Write OCI private key&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;mkdir -p ~/.oci&lt;/span&gt;
    &lt;span class="s"&gt;echo "${{ secrets.OCI_PRIVATE_KEY }}" &amp;gt; ~/.oci/oci_api_key.pem&lt;/span&gt;
    &lt;span class="s"&gt;chmod 600 ~/.oci/oci_api_key.pem&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;chmod 600&lt;/code&gt; ensures only the current user can read the key — same as on your local machine. The file exists only for the duration of the workflow run and is destroyed when the runner is cleaned up.&lt;/p&gt;

&lt;p&gt;All the secret values are passed to Terraform using the &lt;code&gt;TF_VAR_&lt;/code&gt; prefix convention. Terraform automatically reads any environment variable starting with &lt;code&gt;TF_VAR_&lt;/code&gt; and maps it to the corresponding variable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;TF_VAR_cloudflare_api_token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.CLOUDFLARE_API_TOKEN }}&lt;/span&gt;
  &lt;span class="na"&gt;TF_VAR_oci_tenancy_ocid&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.OCI_TENANCY_OCID }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This means &lt;code&gt;terraform.tfvars&lt;/code&gt; is only needed locally. The pipeline gets its values from GitHub Secrets and environment variables instead.&lt;/p&gt;




&lt;h3&gt;
  
  
  Chapter 8: Branch Protection — Closing the Loop
&lt;/h3&gt;

&lt;p&gt;A pipeline is only as strong as the rules that enforce it. Without branch protection, nothing stops you from pushing directly to main at midnight and bypassing all the checks.&lt;/p&gt;

&lt;p&gt;I created a ruleset on the repository:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Require a pull request before merging&lt;/strong&gt; — no direct pushes to main&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Require the &lt;code&gt;plan&lt;/code&gt; status check to pass&lt;/strong&gt; — merge is blocked until Terraform plan succeeds&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Block force pushes&lt;/strong&gt; — no rewriting history on main
Now the only way to change infrastructure is: branch → commit → push → open PR → plan runs → review → merge → apply runs. No shortcuts. Not even for the repo owner.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It felt slightly paranoid to lock myself out of my own main branch. But then I remembered that the one time I would want to bypass the pipeline is exactly the time I should not — late at night, tired, "just this one quick fix." The branch protection is there for that version of me.&lt;/p&gt;




&lt;h3&gt;
  
  
  Chapter 9: The Migration That Made Me Nervous
&lt;/h3&gt;

&lt;p&gt;Everything I have described so far was building something new. But there was one part that involved changing something that already existed — migrating from DynamoDB state locking to S3-native locking.&lt;/p&gt;

&lt;p&gt;The state file is the single most important file in a Terraform setup. If it gets corrupted or lost, Terraform loses track of every resource it manages. You do not casually mess with how the state file is stored.&lt;/p&gt;

&lt;p&gt;The actual migration was anticlimactic. I changed one line in &lt;code&gt;backend.tf&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;-    dynamodb_table = "terraform-lock-table"
&lt;/span&gt;&lt;span class="gi"&gt;+    use_lockfile   = true
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ran &lt;code&gt;terraform init -reconfigure&lt;/code&gt;. Ran &lt;code&gt;terraform plan&lt;/code&gt;. It showed no changes — meaning Terraform could still read the state and nothing had drifted. I deleted the DynamoDB table in AWS. Done.&lt;/p&gt;

&lt;p&gt;But the fact that I was nervous about it taught me something about infrastructure work. The migration itself took five minutes. The caution I felt — checking the plan output twice, making sure I could roll back — that is the right instinct. In production, you do not rush infrastructure changes just because the technical step is simple.&lt;/p&gt;




&lt;h3&gt;
  
  
  What I Took Away From This
&lt;/h3&gt;

&lt;p&gt;Part 1 taught me CI/CD. Part 2 taught me that the same principles — version control, automated validation, review before deploy — apply to infrastructure just as well as they apply to code.&lt;/p&gt;

&lt;p&gt;The specific tools matter less than the pattern. Terraform could be replaced by Pulumi or OpenTofu. GitHub Actions could be replaced by GitLab CI or CircleCI. S3 could be replaced by GCS or Azure Blob Storage. The pattern stays the same: define infrastructure in code, store the code in version control, validate changes automatically, deploy through a pipeline, and never make changes by hand.&lt;/p&gt;

&lt;p&gt;The part I am most proud of is the OIDC setup. Not because it was technically difficult — it was about an hour of work — but because it represents a genuine shift in how I think about security. Moving from "store a key and hope it does not leak" to "there is no key to leak" is the kind of change that sticks with you.&lt;/p&gt;

&lt;p&gt;Building this also made me realize how much of DevOps is about discipline, not tooling. The pipeline does not do anything I could not do manually. But it does it the same way every time, it does it on every change without exception, and it leaves a record. That consistency is the actual value.&lt;/p&gt;




&lt;h3&gt;
  
  
  What is Next?
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Part 3&lt;/strong&gt; brings Kubernetes into the homelab. I will be setting up a K3s cluster with my local server as the control plane node and the Oracle Cloud instance as a worker node — a geographically distributed cluster connected over Tailscale. Same discipline: infrastructure as code, pipeline-driven, documented.&lt;/p&gt;

&lt;p&gt;The container orchestration layer is where everything built in Parts 1 and 2 starts to converge. The CI/CD pipeline from Part 1 will build and push container images. The Terraform infrastructure from Part 2 will provision the nodes. Kubernetes will run the workloads.&lt;/p&gt;

&lt;p&gt;Stay tuned, and happy building.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>aws</category>
      <category>github</category>
      <category>terraform</category>
    </item>
    <item>
      <title>Part 1: Building a Security-Gated CI/CD Pipeline with GitHub Actions</title>
      <dc:creator>Prajwol Adhikari</dc:creator>
      <pubDate>Sun, 10 May 2026 00:58:23 +0000</pubDate>
      <link>https://forem.com/prajwol-ad/part-1-building-a-security-gated-cicd-pipeline-with-github-actions-27k8</link>
      <guid>https://forem.com/prajwol-ad/part-1-building-a-security-gated-cicd-pipeline-with-github-actions-27k8</guid>
      <description>&lt;h3&gt;
  
  
  Introduction
&lt;/h3&gt;

&lt;p&gt;If you have followed along with the homelab series, you have seen me build a Debian server from scratch, lock it down with Zero Trust tunnels, and set up high-availability DNS across two continents. The infrastructure side has been a lot of fun to learn.&lt;/p&gt;

&lt;p&gt;But there was something that had been nagging me. Every time I pushed code to this portfolio, it deployed automatically with zero checks. No secret scanning, no security analysis, no performance gates. One bad push and the site could break — or worse, I could accidentally leak a token and not even know it.&lt;/p&gt;

&lt;p&gt;As I have been working toward a DevOps engineering role, CI/CD pipelines have been one of those topics I kept reading about but had not actually built from scratch myself. I am the kind of person who learns best by doing — reading documentation only gets me so far. I wanted to actually build something real, something I could showcase, and something that would teach me the tools that professional engineering teams use every day.&lt;/p&gt;

&lt;p&gt;So that is what this post is about. Building a proper &lt;strong&gt;multi-stage, security-gated CI/CD pipeline&lt;/strong&gt; using GitHub Actions — not because a tutorial told me to, but because I wanted to understand how it actually works.&lt;/p&gt;

&lt;p&gt;Fair warning: it did not all go smoothly. There were failed runs, confusing errors, and at least one moment where I had no idea why the build was failing. I will walk you through all of it.&lt;/p&gt;




&lt;h3&gt;
  
  
  See it live
&lt;/h3&gt;

&lt;p&gt;This post documents the first phase of a five-phase hybrid cloud&lt;br&gt;
engineering showcase. If you want to see the current state of the&lt;br&gt;
infrastructure before reading the build walkthrough:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://prajwolbikramadhikari.com.np/lab/" rel="noopener noreferrer"&gt;The Lab&lt;/a&gt; — live infrastructure status, technology stack,
and build progress tracker&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://prajwolbikramadhikari.com.np/architecture/" rel="noopener noreferrer"&gt;Architecture diagram&lt;/a&gt; — five-zone infrastructure
map spanning Waco TX, Phoenix AZ, and Amsterdam NL&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The lab page updates as each phase completes. By the time you read&lt;br&gt;
this, Phase 2 Terraform IaC may already be marked complete.&lt;/p&gt;


&lt;h3&gt;
  
  
  What is CI/CD and why does it matter?
&lt;/h3&gt;

&lt;p&gt;Before I built this, I had a loose understanding of CI/CD. "Code goes in, site comes out automatically." That is technically true but it misses the point.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CI (Continuous Integration)&lt;/strong&gt; means every push automatically triggers a set of checks — security scans, builds, audits. Every single push. Not once a week before a release.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CD (Continuous Deployment)&lt;/strong&gt; means if all those checks pass, your code goes to production automatically. No human clicking deploy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The real value is not the automation itself — it is the &lt;em&gt;gates&lt;/em&gt;. Without CI/CD, you are trusting yourself to manually check everything every time. That works until it does not. The day you are tired, rushing, or just distracted, something slips through.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DevSecOps&lt;/strong&gt; takes this a step further by making security part of the pipeline itself. Security checks run on every push, blocking anything that does not meet the standard. Given that I am working in a regulated pharmaceutical environment at AbbVie where GxP compliance is part of daily life, this mindset clicked for me immediately. You do not check compliance once a quarter. You build it into the process.&lt;/p&gt;


&lt;h3&gt;
  
  
  Chapter 1: The Pipeline Architecture
&lt;/h3&gt;

&lt;p&gt;Before I wrote a single line of YAML, I spent time thinking about how the jobs should depend on each other. This turned out to be one of the most valuable parts of the whole exercise.&lt;/p&gt;

&lt;p&gt;Here is the structure I landed on:&lt;br&gt;
&lt;/p&gt;

&lt;p&gt;```text {.ascii-diagram}&lt;br&gt;
git push to master&lt;br&gt;
        │&lt;br&gt;
        ├── Gitleaks      (secret scanning)       ┐&lt;br&gt;
        ├── CodeQL        (SAST analysis)         ├── parallel&lt;br&gt;
        └── Dep Review    (CVE scanning)          ┘&lt;br&gt;
                │  all three must pass&lt;br&gt;
                ▼&lt;br&gt;
        ┌─────────────────────────────┐&lt;br&gt;
        │   Containerized Hugo build  │&lt;br&gt;
        │   Docker · pinned version   │&lt;br&gt;
        └─────────────────────────────┘&lt;br&gt;
                │&lt;br&gt;
                ▼&lt;br&gt;
        ┌─────────────────────────────┐&lt;br&gt;
        │   Lighthouse audit          │&lt;br&gt;
        │   Performance ≥ 90          │&lt;br&gt;
        │   Accessibility ≥ 90        │&lt;br&gt;
        └─────────────────────────────┘&lt;br&gt;
                │&lt;br&gt;
                ▼&lt;br&gt;
        ┌─────────────────────────────┐&lt;br&gt;
        │   Deploy to GitHub Pages    │&lt;br&gt;
        │   OIDC · no stored tokens   │&lt;br&gt;
        └─────────────────────────────┘&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;


The key decision is running the three security gates **in parallel**, not one after another. Gitleaks, CodeQL, and dependency review do not depend on each other — there is no reason to wait for Gitleaks to finish before starting CodeQL. Running them simultaneously means the whole security check phase takes as long as the slowest single scan, not the sum of all three.

The build only starts once all three pass. If any one of them fails, the whole pipeline stops there.

---

### Chapter 2: Security Gate 1 — Gitleaks Secret Scanning

Gitleaks scans your repository for accidentally committed secrets — API keys, tokens, passwords, private keys. This was one of those tools I had heard about but never actually used. Setting it up was straightforward. Understanding why one specific line matters took me longer.



```yaml
secret-scan:
  name: Gitleaks
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
      with:
        fetch-depth: 0
    - uses: gitleaks/gitleaks-action@v2
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;That &lt;code&gt;fetch-depth: 0&lt;/code&gt; line is the important one. Without it, GitHub only checks out your latest commit. But git history keeps everything — if you committed a Cloudflare token six months ago and deleted it in the next commit, that token is still visible to anyone who clones your repo with &lt;code&gt;git log&lt;/code&gt;. The &lt;code&gt;fetch-depth: 0&lt;/code&gt; tells Gitleaks to scan the entire history, not just the tip.&lt;/p&gt;

&lt;p&gt;A scanner that only sees the latest commit is security theater. The full history scan is what makes it real.&lt;/p&gt;

&lt;p&gt;If you have test files or documentation with example tokens that look like real secrets, add a &lt;code&gt;.gitleaks.toml&lt;/code&gt; to your repo root to suppress false positives:&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;[allowlist]&lt;/span&gt;
&lt;span class="py"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Known false positives"&lt;/span&gt;
&lt;span class="py"&gt;regexes&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="s"&gt;'''EXAMPLE_API_KEY'''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s"&gt;'''test-token-placeholder'''&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="py"&gt;paths&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="s"&gt;'''testdata/'''&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  Chapter 3: Security Gate 2 — CodeQL Static Analysis
&lt;/h3&gt;

&lt;p&gt;CodeQL is GitHub's free SAST tool — Static Application Security Testing. Instead of looking for secrets, it reads your actual code and looks for patterns that could be exploited: XSS vulnerabilities, injection risks, insecure patterns in JavaScript.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;codeql&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CodeQL&lt;/span&gt;
  &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
  &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;security-events&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
    &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
  &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github/codeql-action/init@v3&lt;/span&gt;
      &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;languages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;javascript&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github/codeql-action/autobuild@v3&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github/codeql-action/analyze@v3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One thing worth noticing: &lt;code&gt;security-events: write&lt;/code&gt; is declared at the &lt;strong&gt;job level&lt;/strong&gt;, not globally. This is the principle of least privilege — each job only gets the permissions it actually needs. The global permissions block deliberately does not include this. Only CodeQL needs to write security events, so only CodeQL gets that permission.&lt;/p&gt;

&lt;p&gt;Results appear in your repository's &lt;strong&gt;Security → Code scanning alerts&lt;/strong&gt; tab. For a static Hugo portfolio, CodeQL will likely find nothing — which is the expected and correct result. The habit and the architecture are what matter.&lt;/p&gt;




&lt;h3&gt;
  
  
  Chapter 4: Security Gate 3 — Dependency Review
&lt;/h3&gt;

&lt;p&gt;The dependency review action checks your packages against the GitHub Advisory Database on every pull request. If any dependency has a known CVE at &lt;code&gt;high&lt;/code&gt; severity or above, the pipeline fails.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;dependency-review&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Dependency Review&lt;/span&gt;
  &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
  &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github.event_name == 'pull_request'&lt;/span&gt;
  &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/dependency-review-action@v4&lt;/span&gt;
      &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;fail-on-severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;high&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;if: github.event_name == 'pull_request'&lt;/code&gt; condition is here because this action requires a base and head ref to compare — it's designed specifically for pull requests. On a direct push to master there's no base to compare against, so it would fail with a confusing error.&lt;/p&gt;

&lt;p&gt;The correct engineering response is not to remove the job — it is to scope it to the right trigger. It runs on PRs, skips silently on direct pushes.&lt;/p&gt;

&lt;p&gt;Because it can be skipped, the build job needs a special condition to prevent a cascade where a skipped job causes everything downstream to skip too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;secret-scan&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;codeql&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;dependency-review&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ !failure() &amp;amp;&amp;amp; !cancelled() }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This condition is applied to the build, Lighthouse, and deploy&lt;br&gt;
jobs. Each one proceeds as long as nothing upstream actually&lt;br&gt;
failed or was cancelled — a skipped dependency review on a&lt;br&gt;
direct push does not block the rest of the pipeline.&lt;/p&gt;


&lt;h3&gt;
  
  
  Chapter 5: The Containerized Build — And Where I Got Stuck
&lt;/h3&gt;

&lt;p&gt;This is the stage where things got interesting. And by interesting, I mean frustrating for a while.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build with Docker&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;docker run --rm \&lt;/span&gt;
      &lt;span class="s"&gt;--user $(id -u):$(id -g) \&lt;/span&gt;
      &lt;span class="s"&gt;-v ${{ github.workspace }}:/src \&lt;/span&gt;
      &lt;span class="s"&gt;-w /src \&lt;/span&gt;
      &lt;span class="s"&gt;floryn90/hugo:0.120.4-ext-alpine \&lt;/span&gt;
      &lt;span class="s"&gt;--minify --gc&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first few runs of the pipeline kept failing at the build stage. The error was not obvious — it was a permissions issue. Hugo was running as root inside the Docker container, writing the &lt;code&gt;public/&lt;/code&gt; directory with root ownership. The next pipeline step could not read those files.&lt;/p&gt;

&lt;p&gt;The fix is the &lt;code&gt;--user $(id -u):$(id -g)&lt;/code&gt; flag. This tells Docker to run the Hugo process as the current runner user instead of root, which means the output files are owned by the right user and everything downstream can read them cleanly.&lt;/p&gt;

&lt;p&gt;It is not something you would find in a basic Docker tutorial. You find it by having the pipeline break and digging into why.&lt;/p&gt;

&lt;p&gt;The other decision worth explaining: &lt;code&gt;floryn90/hugo:0.120.4-ext-alpine&lt;/code&gt; with a pinned version, not &lt;code&gt;latest&lt;/code&gt;. GitHub's runners update their tool versions regularly. If Hugo releases a breaking change and the runner silently upgrades, your build breaks with no obvious reason. Pinning to &lt;code&gt;0.120.4&lt;/code&gt; means that exact version runs every time, regardless of what the runner has.&lt;/p&gt;

&lt;p&gt;After the build, two separate artifacts get uploaded:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Uncompressed HTML for Lighthouse to audit&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Upload artifact for Lighthouse&lt;/span&gt;
  &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/upload-artifact@v4&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;public-site&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;public/&lt;/span&gt;

&lt;span class="c1"&gt;# Compressed tarball for GitHub Pages deploy&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Upload artifact for Pages&lt;/span&gt;
  &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/upload-pages-artifact@v3&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;public/&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These are different formats. &lt;code&gt;actions/deploy-pages&lt;/code&gt; requires its own specific artifact format from &lt;code&gt;upload-pages-artifact&lt;/code&gt;. You cannot reuse the same artifact for both — something I discovered when the deploy step failed because it could not find the right artifact format.&lt;/p&gt;




&lt;h3&gt;
  
  
  Chapter 6: The Lighthouse Audit
&lt;/h3&gt;

&lt;p&gt;Before anything reaches production, Lighthouse audits the built site against enforced thresholds. If scores drop below the minimums, the deploy is blocked.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;lighthouse&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Lighthouse Audit&lt;/span&gt;
  &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
  &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

  &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Download artifact&lt;/span&gt;
      &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/download-artifact@v4&lt;/span&gt;
      &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ !failure() &amp;amp;&amp;amp; !cancelled() }}&lt;/span&gt;
      &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;public-site&lt;/span&gt;
        &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;public/&lt;/span&gt;

    &lt;span class="c1"&gt;# SRE fix: remove non-content files before audit&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Prune non-content files from audit&lt;/span&gt;
      &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rm -f public/google*.html public/404.html&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Serve &amp;amp; audit&lt;/span&gt;
      &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;treosh/lighthouse-ci-action@v11&lt;/span&gt;
      &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;uploadArtifacts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="na"&gt;temporaryPublicStorage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="na"&gt;configPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.lighthouserc.json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The file pruning step was another real discovery. Google Search Console verification files and custom 404 pages were causing Lighthouse to audit them as content pages and fail on them. They are not real content — removing them before the audit prevents false failures on files I do not control. It is a small fix that took some head-scratching to figure out.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;.lighthouserc.json&lt;/code&gt; configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ci"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"collect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"staticDistDir"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"./public"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"numberOfRuns"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"upload"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"target"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"temporary-public-storage"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"assert"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"assertions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"categories:performance"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"minScore"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.9&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"categories:accessibility"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"minScore"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.9&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"categories:best-practices"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"warn"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"minScore"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.9&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"categories:seo"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"warn"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"minScore"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.9&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Running twice and averaging reduces the chance of a single slow network moment on GitHub's shared runners falsely blocking a legitimate deploy. Performance and accessibility are hard errors — below 90 blocks the deploy. Best practices and SEO are warnings — tracked but not blocking.&lt;/p&gt;




&lt;h3&gt;
  
  
  Chapter 7: Secretless Deployment with OIDC
&lt;/h3&gt;

&lt;p&gt;The deploy stage was the one I was most curious about going in. Most tutorials tell you to generate an API token, store it in GitHub Secrets, and use it on every deploy. That works, but it means there is a long-lived credential sitting in your secrets that stays valid until you manually rotate it.&lt;/p&gt;

&lt;p&gt;OIDC is different. Instead of a stored token, GitHub Actions generates a short-lived cryptographic proof of identity at runtime. It proves who it is, completes the deploy, and the token expires minutes later. There is nothing to store, nothing to rotate, and nothing to leak.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy to GitHub Pages&lt;/span&gt;
  &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
  &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;lighthouse&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ !failure() &amp;amp;&amp;amp; !cancelled() }}&lt;/span&gt;
  &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github-pages&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ steps.deployment.outputs.page_url }}&lt;/span&gt;
  &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy to GitHub Pages&lt;/span&gt;
      &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deployment&lt;/span&gt;
      &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/deploy-pages@v4&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The OIDC capability comes from the global permissions block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
  &lt;span class="na"&gt;pages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
  &lt;span class="na"&gt;id-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt; &lt;span class="c1"&gt;# enables OIDC&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;id-token: write&lt;/code&gt; line is what enables the handshake. Without it, GitHub Actions cannot request the short-lived identity token and the deploy fails.&lt;/p&gt;




&lt;h3&gt;
  
  
  Chapter 8: The Moment It All Worked
&lt;/h3&gt;

&lt;p&gt;I am not going to pretend the first run was clean. There were multiple failed runs — the permissions error on the Docker build, the artifact format mismatch, the Lighthouse false failures on the verification files. Each one took some digging to understand and fix.&lt;/p&gt;

&lt;p&gt;But when I finally pushed a commit and watched all five jobs turn green in the GitHub Actions tab — Gitleaks, CodeQL, Build Hugo, Lighthouse Audit, Deploy to GitHub Pages, all green — it felt genuinely good. Not just because it worked, but because I actually understood &lt;em&gt;why&lt;/em&gt; each piece was there and what it was doing.&lt;/p&gt;

&lt;p&gt;What surprised me most was how well everything worked together once it was wired up correctly. GitHub Actions triggering the pipeline, GitHub Pages serving the site, Cloudflare picking it up for DNS and CDN — the whole chain from &lt;code&gt;git push&lt;/code&gt; to live site update was faster and more seamless than I expected. The integration between these tools is genuinely impressive.&lt;/p&gt;




&lt;h3&gt;
  
  
  Branch Protection — Locking It In
&lt;/h3&gt;

&lt;p&gt;The pipeline means nothing if you can bypass it by pushing directly to &lt;code&gt;master&lt;/code&gt; without any checks running. In your GitHub repository go to &lt;strong&gt;Settings → Branches → Add branch protection rule&lt;/strong&gt; for &lt;code&gt;master&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;✅ Require status checks to pass before merging

&lt;ul&gt;
&lt;li&gt;Add: &lt;code&gt;Gitleaks&lt;/code&gt;, &lt;code&gt;CodeQL&lt;/code&gt;, &lt;code&gt;Build Hugo&lt;/code&gt;, &lt;code&gt;Lighthouse Audit&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;✅ Require branches to be up to date before merging&lt;/li&gt;

&lt;li&gt;✅ Do not allow bypassing the above settings&lt;/li&gt;

&lt;/ul&gt;

&lt;p&gt;Now the pipeline is the only path to production. Not even the repo owner can push directly to master and skip it.&lt;/p&gt;




&lt;h3&gt;
  
  
  The Complete Workflow File
&lt;/h3&gt;

&lt;p&gt;The full &lt;code&gt;.github/workflows/deploy.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;DevSecOps CI/CD Pipeline&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;master&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;master&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
  &lt;span class="na"&gt;pages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
  &lt;span class="na"&gt;id-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;secret-scan&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Gitleaks&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;fetch-depth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gitleaks/gitleaks-action@v2&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;GITHUB_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GITHUB_TOKEN }}&lt;/span&gt;

  &lt;span class="na"&gt;codeql&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CodeQL&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;security-events&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
      &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github/codeql-action/init@v3&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;languages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;javascript&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github/codeql-action/autobuild@v3&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github/codeql-action/analyze@v3&lt;/span&gt;

  &lt;span class="na"&gt;dependency-review&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Dependency Review&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github.event_name == 'pull_request'&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/dependency-review-action@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;fail-on-severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;high&lt;/span&gt;

  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build Hugo&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;secret-scan&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;codeql&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;dependency-review&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ !failure() &amp;amp;&amp;amp; !cancelled() }}&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;submodules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build with Docker&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;docker run --rm \&lt;/span&gt;
            &lt;span class="s"&gt;--user $(id -u):$(id -g) \&lt;/span&gt;
            &lt;span class="s"&gt;-v ${{ github.workspace }}:/src \&lt;/span&gt;
            &lt;span class="s"&gt;-w /src \&lt;/span&gt;
            &lt;span class="s"&gt;floryn90/hugo:0.120.4-ext-alpine \&lt;/span&gt;
            &lt;span class="s"&gt;--minify --gc&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Upload artifact for Lighthouse&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/upload-artifact@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;public-site&lt;/span&gt;
          &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;public/&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Upload artifact for Pages&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/upload-pages-artifact@v3&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;public/&lt;/span&gt;

  &lt;span class="na"&gt;lighthouse&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Lighthouse Audit&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ !failure() &amp;amp;&amp;amp; !cancelled() }}&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Download artifact&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/download-artifact@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;public-site&lt;/span&gt;
          &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;public/&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Prune non-content files from audit&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rm -f public/google*.html public/404.html&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Serve &amp;amp; audit&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;treosh/lighthouse-ci-action@v11&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;uploadArtifacts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
          &lt;span class="na"&gt;temporaryPublicStorage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
          &lt;span class="na"&gt;configPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.lighthouserc.json&lt;/span&gt;

  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy to GitHub Pages&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;lighthouse&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ !failure() &amp;amp;&amp;amp; !cancelled() }}&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github-pages&lt;/span&gt;
      &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ steps.deployment.outputs.page_url }}&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy to GitHub Pages&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deployment&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/deploy-pages@v4&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  What I Took Away From This
&lt;/h3&gt;

&lt;p&gt;Building this taught me that CI/CD pipelines are not magic — they are just a series of jobs with dependencies between them, each one doing one specific thing. Once you understand the dependency graph, the YAML almost writes itself.&lt;/p&gt;

&lt;p&gt;The parts that actually taught me the most were not the parts that worked. They were the failed runs — figuring out why the Docker build was writing files as root, why Lighthouse was failing on a Google verification file, why the artifact format mattered. Those failures forced me to actually understand what was happening rather than just accepting that it worked.&lt;/p&gt;

&lt;p&gt;Tools like GitHub Actions, Gitleaks, CodeQL, and Lighthouse are not intimidating once you have broken them and fixed them yourself. That is the thing about learning by building — the failures are the lesson.&lt;/p&gt;




&lt;h3&gt;
  
  
  What is Next?
&lt;/h3&gt;

&lt;p&gt;In &lt;strong&gt;Part 2&lt;/strong&gt; of this series, we will bring the infrastructure itself under version control. All 12 Cloudflare DNS records, the Zero Trust tunnel configuration, and the Oracle Cloud instance — managed by Terraform, with state stored in AWS S3, and changes deployed through the same pipeline we built here.&lt;/p&gt;

&lt;p&gt;The same discipline applied to code delivery, applied to the servers themselves.&lt;/p&gt;

&lt;p&gt;Stay tuned, and happy building!&lt;/p&gt;




&lt;h3&gt;
  
  
  Appendix: The &lt;code&gt;.lighthouserc.json&lt;/code&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ci"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"collect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"staticDistDir"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"./public"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"numberOfRuns"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"upload"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"target"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"temporary-public-storage"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"assert"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"assertions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"categories:performance"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"minScore"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.9&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"categories:accessibility"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"minScore"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.9&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"categories:best-practices"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"warn"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"minScore"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.9&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"categories:seo"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"warn"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"minScore"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.9&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>devops</category>
      <category>github</category>
      <category>cicd</category>
      <category>docker</category>
    </item>
    <item>
      <title>Building an LLM-Powered Log Triage Pipeline with Python and DeepSeek-R1</title>
      <dc:creator>Prajwol Adhikari</dc:creator>
      <pubDate>Sun, 10 May 2026 00:53:54 +0000</pubDate>
      <link>https://forem.com/prajwol-ad/building-an-llm-powered-log-triage-pipeline-with-python-and-deepseek-r1-4n0m</link>
      <guid>https://forem.com/prajwol-ad/building-an-llm-powered-log-triage-pipeline-with-python-and-deepseek-r1-4n0m</guid>
      <description>&lt;h3&gt;
  
  
  Introduction
&lt;/h3&gt;

&lt;p&gt;I have Prometheus and Grafana monitoring my homelab. I have Alertmanager sending Discord notifications when a node goes down. But there was a gap in the middle that kept bugging me.&lt;/p&gt;

&lt;p&gt;Prometheus tells me &lt;em&gt;that&lt;/em&gt; something is wrong. CPU is high. A container restarted. A scrape target is unreachable. What it does not tell me is &lt;em&gt;why&lt;/em&gt;. For that, you need to read the logs. And reading Docker logs across multiple containers, multiple times a day, is the kind of task that feels productive for about ten minutes before you start skimming and missing things.&lt;/p&gt;

&lt;p&gt;So I built something to read them for me. A Python script that runs every 15 minutes, pulls Docker container logs, checks for anything that looks critical, and sends the critical stuff to a small language model running on my Oracle Cloud instance. The model reads the raw log entry and writes a plain-English summary. That summary gets posted to a Discord channel.&lt;/p&gt;

&lt;p&gt;Instead of me reading through hundreds of log lines and hoping I notice the important one, an LLM reads them and only bothers me when something actually matters.&lt;/p&gt;

&lt;p&gt;This is not a fancy AI agent with tool use and multi-step reasoning. It is a straightforward automation — rules-based triage plus an LLM for summarization. But it solves a real problem I was actually having, and it taught me a lot about how to practically integrate an LLM into an infrastructure workflow.&lt;/p&gt;




&lt;h3&gt;
  
  
  Why not just use Alertmanager for everything?
&lt;/h3&gt;

&lt;p&gt;Fair question. Alertmanager handles the metrics side well — if CPU spikes above 90% for five minutes, or if a node goes unreachable, it fires an alert. But metrics and logs are different things.&lt;/p&gt;

&lt;p&gt;A container can be running fine from a metrics perspective — CPU normal, memory stable, responding to health checks — but still be logging errors internally. Maybe it is failing to connect to an upstream API. Maybe it is retrying a database connection every 30 seconds. Maybe there is a deprecation warning that will become a breaking change next release. None of that shows up in Prometheus metrics. All of it shows up in logs.&lt;/p&gt;

&lt;p&gt;The log triage pipeline covers the gap between "the container is running" and "the container is healthy."&lt;/p&gt;




&lt;h3&gt;
  
  
  Chapter 1: The Architecture
&lt;/h3&gt;

&lt;p&gt;The pipeline has four components spread across two machines:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On my local server (Waco, Texas):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The Python script that reads Docker logs and classifies severity&lt;/li&gt;
&lt;li&gt;A cron job that runs the script every 15 minutes&lt;/li&gt;
&lt;li&gt;Docker, whose containers produce the logs
&lt;strong&gt;On the Oracle Cloud instance (Phoenix, Arizona):&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Ollama, serving the DeepSeek-R1 1.5B model as a REST API
&lt;strong&gt;In between:&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Tailscale, connecting both machines over an encrypted mesh VPN&lt;/li&gt;
&lt;li&gt;Discord webhooks, receiving the final alert messages
The separation is intentional. The LLM runs on the Oracle instance because it has 24GB of RAM — enough to load a small model comfortably. My local server has less headroom, and I did not want model inference competing with the Docker services it is supposed to be monitoring.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Python script calls the Ollama API over Tailscale, so the traffic never touches the public internet. The model endpoint is not exposed to anyone outside my Tailscale network.&lt;/p&gt;




&lt;h3&gt;
  
  
  Chapter 2: Setting Up Ollama and DeepSeek-R1
&lt;/h3&gt;

&lt;p&gt;Ollama makes self-hosting a language model surprisingly painless. On the Oracle instance, the setup was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://ollama.com/install.sh | sh
ollama pull deepseek-r1:1.5b
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is it. Ollama downloads the model and serves it as a REST API on port 11434. You can test it immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl http://localhost:11434/api/generate &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
  "model": "deepseek-r1:1.5b",
  "prompt": "Summarize this log entry: ERROR: database connection refused at 10.0.0.5:5432, retrying in 30s",
  "stream": false
}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And it responds with a natural-language summary of what the log entry means.&lt;/p&gt;

&lt;p&gt;I chose the 1.5B parameter model for a reason. It is small enough to run on the Oracle ARM instance without maxing out memory, and fast enough that inference takes a few seconds per log entry rather than minutes. For summarizing log lines, you do not need GPT-4 level intelligence. You need something that can read a stack trace and say "the database connection is failing" in plain English. The 1.5B model does that reliably.&lt;/p&gt;

&lt;p&gt;A larger model would produce slightly more polished summaries, but the latency and memory tradeoff is not worth it for an automation that runs every 15 minutes. I would rather have fast and good enough than slow and perfect.&lt;/p&gt;




&lt;h3&gt;
  
  
  Chapter 3: The Python Script — Rules First, LLM Second
&lt;/h3&gt;

&lt;p&gt;This is where the design decision that matters most lives. The script does not send every log line to the LLM. That would be slow, expensive on compute, and pointless — most log lines are routine. Instead, it uses a two-stage approach:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stage 1: Rules-based severity classification.&lt;/strong&gt; The script reads the last 15 minutes of logs from each Docker container using &lt;code&gt;docker logs --since 15m&lt;/code&gt;. It then checks each line against a set of keyword patterns:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Lines containing &lt;code&gt;error&lt;/code&gt;, &lt;code&gt;fatal&lt;/code&gt;, &lt;code&gt;critical&lt;/code&gt;, &lt;code&gt;OOM&lt;/code&gt;, &lt;code&gt;killed&lt;/code&gt;, &lt;code&gt;panic&lt;/code&gt;, &lt;code&gt;exception&lt;/code&gt; → classified as critical&lt;/li&gt;
&lt;li&gt;Lines containing &lt;code&gt;warn&lt;/code&gt;, &lt;code&gt;timeout&lt;/code&gt;, &lt;code&gt;retry&lt;/code&gt;, &lt;code&gt;refused&lt;/code&gt; → classified as warning&lt;/li&gt;
&lt;li&gt;Everything else → ignored
This is intentionally simple. I am not trying to build a perfect classifier. I am trying to filter out the 95% of log lines that say things like "request completed in 12ms" so the LLM only has to deal with the 5% that might actually matter.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Stage 2: LLM summarization.&lt;/strong&gt; Only the lines classified as critical get sent to DeepSeek. The prompt is straightforward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;You are a DevOps engineer reviewing system logs.
Summarize the following log entry in one or two sentences.
Explain what happened and whether immediate action is needed.

Log entry:
&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;log_line&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model returns a summary like: "The Grafana container failed to authenticate with its PostgreSQL backend. The connection was refused, suggesting the database container may be down or the credentials have changed. Immediate investigation recommended."&lt;/p&gt;

&lt;p&gt;That summary is what gets posted to Discord — not the raw log line, but the plain-English interpretation of it.&lt;/p&gt;




&lt;h3&gt;
  
  
  Chapter 4: The Discord Integration
&lt;/h3&gt;

&lt;p&gt;Discord webhooks are probably the simplest notification integration you can set up. You create a webhook URL in your Discord server settings, and then posting to it is one HTTP request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;send_discord_alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;container_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;severity&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;webhook_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;your-discord-webhook-url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;embeds&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;🔴 &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;severity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upper&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; — &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;container_name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;description&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;color&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;15158332&lt;/span&gt;  &lt;span class="c1"&gt;# red
&lt;/span&gt;        &lt;span class="p"&gt;}]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;webhook_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The embed format gives you a clean, colored card in Discord rather than a wall of text. Critical alerts show up in red. Warnings could show up in yellow if I ever decide to surface those too — for now I only send critical ones to keep the noise low.&lt;/p&gt;

&lt;p&gt;The webhook URL is stored as an environment variable, not hardcoded. I learned this the hard way earlier in the project when I accidentally shared webhook URLs in a chat and had to regenerate them. Treat webhook URLs like API keys — anyone with the URL can post to your channel.&lt;/p&gt;




&lt;h3&gt;
  
  
  Chapter 5: The Cron Job
&lt;/h3&gt;

&lt;p&gt;The script runs every 15 minutes via cron on my local 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="k"&gt;*&lt;/span&gt;/15 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; /usr/bin/python3 /home/user/scripts/log-triage.py &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /var/log/log-triage.log 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fifteen minutes is a balance between responsiveness and noise. Every 5 minutes would catch things faster but generate more Discord traffic during noisy periods (like when I am actively deploying something and containers are restarting). Every hour would miss things for too long. Fifteen minutes means I find out about a critical issue within fifteen minutes — which for a homelab is perfectly fine.&lt;/p&gt;

&lt;p&gt;The output gets appended to its own log file, which is a bit meta — the log triage tool has its own logs. But it is useful for debugging when the script itself fails, which happened more than once during development.&lt;/p&gt;




&lt;h3&gt;
  
  
  Chapter 6: What I Learned Building This
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;The rules-based first stage is doing most of the work.&lt;/strong&gt; I originally planned to send all logs to the LLM and let it figure out what was important. That was a mistake. The model was slow, the responses were inconsistent for routine log lines, and the Discord channel was flooded with summaries of perfectly normal events. Adding the keyword filter in front cut the LLM calls by about 95% and made the whole pipeline actually useful.&lt;/p&gt;

&lt;p&gt;This is a pattern I have seen in every discussion about production LLM systems: you almost always want a cheap, fast filter in front of the expensive, slow model. Let the simple rules handle the simple cases. Only escalate to the LLM when something actually needs interpretation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Small models are fine for specific tasks.&lt;/strong&gt; There is a temptation to reach for the biggest model you can run. But for log summarization, the 1.5B parameter model produces perfectly adequate output. It occasionally misses nuance that a larger model would catch, but the summaries are accurate enough to tell me whether I need to investigate further. For an alerting pipeline, "accurate enough to trigger investigation" is the right bar — not "perfect analysis."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Self-hosting has real advantages for this use case.&lt;/strong&gt; I could have called an external API like OpenAI or Anthropic instead of running my own model. But there are three reasons I did not:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Cost — at 96 runs per day, even cheap API calls add up over months. The Oracle instance is free tier.&lt;/li&gt;
&lt;li&gt;Privacy — I am sending my infrastructure logs to the model. Even in a homelab, I would rather not send container logs to a third-party API.&lt;/li&gt;
&lt;li&gt;Latency — the Ollama instance responds in 2-3 seconds over Tailscale. An API call over the internet would be similar, but with more variable latency and the possibility of rate limiting.
&lt;strong&gt;This is not an AI agent.&lt;/strong&gt; I want to be clear about what this is and what it is not. An agent makes decisions and takes actions — it might read a log, decide the database needs restarting, and execute the restart. This pipeline does not do that. It reads logs, summarizes them, and tells me about them. I am still the one who decides what to do. That is a deliberate choice — I am not comfortable with automated remediation on infrastructure I actually depend on. Maybe in a future iteration.&lt;/li&gt;
&lt;/ol&gt;




&lt;h3&gt;
  
  
  What Could Be Better
&lt;/h3&gt;

&lt;p&gt;There are obvious improvements I have not made yet:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Smarter classification.&lt;/strong&gt; The keyword matching is crude. "Error" in a log line is not always an error — sometimes it is a log line about error handling working correctly, like "recovered from error successfully." A more sophisticated approach would use regex patterns tuned per container, or even a small classifier model. For now, the false positive rate is low enough that I live with it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Log aggregation with Loki.&lt;/strong&gt; Right now, the script runs &lt;code&gt;docker logs&lt;/code&gt; on each container individually. If I set up Grafana Loki, all container logs would flow into a central store, and the script could query Loki instead of Docker directly. That is a cleaner architecture and it is on my roadmap for a future phase.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Alert deduplication.&lt;/strong&gt; If a container logs the same error repeatedly (like a connection retry every 30 seconds), the script will send the same alert multiple times. I should add a simple cache that tracks recently seen errors and suppresses duplicates within a time window.&lt;/p&gt;




&lt;h3&gt;
  
  
  The Monitoring Stack So Far
&lt;/h3&gt;

&lt;p&gt;This pipeline sits alongside the rest of the observability stack I have been building across the hybrid cloud project:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Prometheus&lt;/strong&gt; scrapes system metrics (CPU, memory, disk, network) from three geographically distributed nodes — my local server in Texas, Oracle Cloud in Arizona, and a shell server in the Netherlands.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grafana&lt;/strong&gt; visualizes those metrics on dashboards.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alertmanager&lt;/strong&gt; fires alerts to Discord when metric-based rules trigger (like a node going unreachable).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;This Python pipeline&lt;/strong&gt; covers the log side — reading container logs, summarizing critical entries with DeepSeek, and posting summaries to Discord.
Together, they give me visibility into both the system-level health (metrics) and the application-level behavior (logs) of the homelab. Not bad for infrastructure running on a laptop and a free-tier cloud instance.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  Appendix: The Complete Script
&lt;/h3&gt;

&lt;p&gt;Here is a cleaned-up version of the script. Replace the placeholder values with your own container names, Ollama endpoint, and Discord webhook URL.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;#!/usr/bin/env python3
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
LLM-Augmented Log Triage Pipeline
Rules-based severity classification + DeepSeek-R1 summarization.
Runs via cron every 15 minutes.
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;

&lt;span class="c1"&gt;# ── Configuration ──────────────────────────────────────────────
&lt;/span&gt;&lt;span class="n"&gt;DISCORD_WEBHOOK_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DISCORD_WEBHOOK_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;DISCORD_WEBHOOK_URL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DISCORD_WEBHOOK_URL not set&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;OLLAMA_URL&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://&amp;lt;your-ollama-host&amp;gt;:11434/api/generate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;OLLAMA_MODEL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;deepseek-r1:1.5b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="c1"&gt;# Containers to monitor — adjust to match your Docker stack
&lt;/span&gt;&lt;span class="n"&gt;CONTAINERS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prometheus&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;grafana&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alertmanager&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nginx-proxy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;adguard&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;# ── Stage 1: Rules-based triage ────────────────────────────────
&lt;/span&gt;
&lt;span class="c1"&gt;# Keywords that trigger LLM analysis
&lt;/span&gt;&lt;span class="n"&gt;ESCALATE_KEYWORDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fatal&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;panic&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;oom&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;killed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;out of memory&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;disk full&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no space left&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;corruption&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;segfault&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exception&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unauthorized&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;authentication failed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;permission denied&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;container exited&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exit code 1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exit code 2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;# Known-harmless patterns to ignore before keyword matching
&lt;/span&gt;&lt;span class="n"&gt;IGNORE_PATTERNS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;filter update&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;# adguard routine
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nginx reloaded&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;         &lt;span class="c1"&gt;# proxy routine
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;certificate renewed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;# TLS renewal noise
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;checkpoint&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;             &lt;span class="c1"&gt;# prometheus WAL compaction
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;compacted&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;              &lt;span class="c1"&gt;# prometheus normal
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;watching for new ooms&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# cadvisor startup
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_container_logs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;container&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Pull the last N lines of logs from a Docker container.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;docker&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;logs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--tail&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;container&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="n"&gt;capture_output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;output&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stdout&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;1500&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;output&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;No output.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Error: &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;should_analyze&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
    Rules-based filter. Strips known-harmless patterns first,
    then checks for escalation keywords.
    Returns (needs_analysis: bool, matched_keyword: str or None).
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;logs_lower&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;pattern&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;IGNORE_PATTERNS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;pattern&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;logs_lower&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;logs_lower&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;logs_lower&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;keyword&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ESCALATE_KEYWORDS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;keyword&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;logs_lower&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;keyword&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;


&lt;span class="c1"&gt;# ── Stage 2: LLM summarization ────────────────────────────────
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;analyze_with_ai&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;container&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;trigger_keyword&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Send critical logs to DeepSeek for plain-English summarization.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;You are an SRE. A Docker container triggered an alert.&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Container: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;container&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Trigger keyword found: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;trigger_keyword&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Logs:&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;logs&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Explain in 2-3 sentences:&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1. What is the actual problem?&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2. How severe is it: critical or warning?&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3. What should the engineer do?&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;OLLAMA_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;OLLAMA_MODEL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prompt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stream&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;options&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;temperature&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;num_predict&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;num_ctx&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;response&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

        &lt;span class="c1"&gt;# DeepSeek-R1 wraps reasoning in &amp;lt;think&amp;gt; tags — strip them
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;think&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;/think&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

        &lt;span class="c1"&gt;# Determine severity from the model's response
&lt;/span&gt;        &lt;span class="n"&gt;raw_lower&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;severity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;warning&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;critical&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;raw_lower&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;not critical&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;raw_lower&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;severity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;critical&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;analysis&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;severity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;severity&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;analysis&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AI analysis failed: &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;severity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;warning&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;


&lt;span class="c1"&gt;# ── Discord alerting ───────────────────────────────────────────
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;send_discord_alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;container&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;trigger_keyword&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;analysis_result&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Post a formatted embed to Discord with the LLM summary.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;severity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;analysis_result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;severity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;warning&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;colors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;critical&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mh"&gt;0xF85149&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;warning&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mh"&gt;0xE3B341&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;embeds&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Alert — &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;container&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;color&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;colors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;severity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0xE3B341&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fields&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Container&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;`&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;container&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;`&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;inline&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
                &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Severity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;severity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upper&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;       &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;inline&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
                &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Trigger keyword&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;`&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;trigger_keyword&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;`&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;inline&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
                &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AI Analysis&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;analysis_result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;analysis&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;inline&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
                &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Time&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;strftime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;%Y-%m-%d %H:%M:%S&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;inline&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;footer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Rules triage + DeepSeek-R1 1.5B&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DISCORD_WEBHOOK_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Discord failed: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="c1"&gt;# ── Main loop ──────────────────────────────────────────────────
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;[&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;strftime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;%H&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="n"&gt;M&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="n"&gt;S&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;] Log triage starting...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;escalated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;container&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;CONTAINERS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;logs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_container_logs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;container&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;needs_analysis&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;keyword&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;should_analyze&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;needs_analysis&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;

        &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;analyze_with_ai&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;container&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;keyword&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;send_discord_alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;container&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;keyword&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;escalated&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;

    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Done. &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;escalated&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CONTAINERS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; containers escalated.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To run it on a 15-minute schedule, add a cron job:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;crontab &lt;span class="nt"&gt;-e&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;*/15 * * * * DISCORD_WEBHOOK_URL="https://discord.com/api/webhooks/your-webhook-here" /usr/bin/python3 /path/to/log-triage.py &amp;gt;&amp;gt; /var/log/log-triage.log 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  What is Next
&lt;/h3&gt;

&lt;p&gt;The hybrid cloud series continues with &lt;strong&gt;Part 3: K3s Kubernetes Cluster&lt;/strong&gt; — setting up a K3s cluster with my local server as the control plane and the Oracle Cloud instance as a worker node, connected over Tailscale. Once that is running, I plan to containerize this log triage pipeline itself and deploy it as a Kubernetes workload, shipped through the CI/CD pipeline I built in Part 1. That would close the loop — the monitoring tool running inside the system it monitors, delivered through the same pipeline as everything else.&lt;/p&gt;

&lt;p&gt;Stay tuned, and happy building.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>deepseek</category>
      <category>python</category>
      <category>oracle</category>
    </item>
    <item>
      <title>Part 5: Securing a Homelab with Cloudflare Tunnels and Zero Trust</title>
      <dc:creator>Prajwol Adhikari</dc:creator>
      <pubDate>Sun, 10 May 2026 00:51:09 +0000</pubDate>
      <link>https://forem.com/prajwol-ad/part-5-securing-a-homelab-with-cloudflare-tunnels-and-zero-trust-52gi</link>
      <guid>https://forem.com/prajwol-ad/part-5-securing-a-homelab-with-cloudflare-tunnels-and-zero-trust-52gi</guid>
      <description>&lt;h3&gt;
  
  
  Introduction
&lt;/h3&gt;

&lt;p&gt;Welcome to Part 5 of this homelab series! In the previous parts, we've built a Debian server, deployed a full suite of services with Docker, and set up a high-availability DNS network. But one critical piece is still missing: end-to-end security.&lt;/p&gt;

&lt;p&gt;Until now, we've been accessing local services via &lt;code&gt;http://grafana.local&lt;/code&gt;, which browsers correctly flag as "Not Secure." The common solution is to open ports 80 and 443 on our router, but that exposes our server and home network to the entire internet—a huge security risk.&lt;/p&gt;

&lt;p&gt;In this guide, we'll walk through the ultimate solution: using a &lt;strong&gt;Cloudflare Tunnel&lt;/strong&gt; and a public domain to get 100% free, valid &lt;strong&gt;HTTPS&lt;/strong&gt; certificates for all &lt;em&gt;internal&lt;/em&gt; services, all with &lt;strong&gt;zero open ports&lt;/strong&gt; on the router. We'll also lock everything down behind Cloudflare's Zero Trust platform, so only authorized users can access them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Chapter 1: The Domain Advantage
&lt;/h3&gt;

&lt;p&gt;To make this work, a public domain (e.g., &lt;code&gt;your-domain.com&lt;/code&gt;) is required. &lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A quick tip for Nepali citizens:&lt;/strong&gt; You can register a &lt;code&gt;.com.np&lt;/code&gt; domain for free for life, which is an incredible resource for projects like this.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The goal is to create secure, public-facing subdomains for our private services (like &lt;code&gt;grafana.your-domain.com&lt;/code&gt;) without actually exposing our server.&lt;/p&gt;

&lt;h3&gt;
  
  
  Chapter 2: Setting Up the Cloudflare Tunnel
&lt;/h3&gt;

&lt;p&gt;A Cloudflare Tunnel is a secure, outbound-only connection from a connector (a small piece of software) running on our server to the Cloudflare network. This means no inbound ports are needed.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Create a Zero Trust Account:&lt;/strong&gt; First, log into the Cloudflare dashboard, go to the &lt;strong&gt;Zero Trust&lt;/strong&gt; menu, and sign up for the free plan. You will be asked to choose a "team name" (e.g., &lt;code&gt;my-lab&lt;/code&gt;), which creates a unique login URL for your account.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Create the Tunnel:&lt;/strong&gt; In the Zero Trust dashboard, navigate to &lt;strong&gt;Networks &amp;gt; Tunnels&lt;/strong&gt; and click "Create a tunnel".&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Choose &lt;strong&gt;"Cloudflared"&lt;/strong&gt; as the connector type and give the tunnel a name, like &lt;code&gt;homelab-debian&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Get the Token:&lt;/strong&gt; Cloudflare then presents options for installing the connector. Select &lt;strong&gt;Docker&lt;/strong&gt;, which provides a &lt;code&gt;docker run&lt;/code&gt; command containing a unique, secret token.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Chapter 3: Deploying the &lt;code&gt;cloudflared&lt;/code&gt; Connector
&lt;/h3&gt;

&lt;p&gt;Instead of just running the &lt;code&gt;docker run&lt;/code&gt; command, using &lt;code&gt;docker-compose.yml&lt;/code&gt; is much better for long-term management.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;Create a new directory on the server:&lt;br&gt;
&lt;/p&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; ~/docker/cloudflared
&lt;span class="nb"&gt;cd&lt;/span&gt; ~/docker/cloudflared
nano docker-compose.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Paste in the following configuration, using the &lt;strong&gt;token&lt;/strong&gt; from the Cloudflare dashboard. It's critical to connect this container to the &lt;code&gt;npm_default&lt;/code&gt; network created in Part 2.&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;cloudflared&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cloudflare/cloudflared:latest&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cloudflared-tunnel&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tunnel --no-autoupdate run --token &amp;lt;YOUR_TOKEN_HERE&amp;gt;&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;npm_default&lt;/span&gt;

&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;npm_default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;external&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Launch the container: &lt;code&gt;docker compose up -d&lt;/code&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Back in the Cloudflare dashboard, the "Connectors" section for the tunnel should now show a &lt;strong&gt;"Healthy"&lt;/strong&gt; status.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Chapter 4: Routing Traffic - Cloudflare to NPM
&lt;/h3&gt;

&lt;p&gt;Now, we need to tell the tunnel where to send incoming traffic. The goal is to send &lt;em&gt;all&lt;/em&gt; traffic for our subdomains to one place: &lt;strong&gt;Nginx Proxy Manager (NPM)&lt;/strong&gt;.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; In the tunnel's configuration, go to the &lt;strong&gt;"Published application routes"&lt;/strong&gt; tab.&lt;/li&gt;
&lt;li&gt; Click &lt;strong&gt;"Add a published application routes"&lt;/strong&gt; and create an entry for each of your services. For Grafana:

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Subdomain:&lt;/strong&gt; &lt;code&gt;grafana&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Domain:&lt;/strong&gt; &lt;code&gt;your-domain.com&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Service Type:&lt;/strong&gt; &lt;code&gt;HTTP&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;URL:&lt;/strong&gt; &lt;code&gt;http://npm-app-1:80&lt;/code&gt; (This is the container name and internal port for NPM)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt; Click &lt;strong&gt;"Save"&lt;/strong&gt; and repeat this for all other services (&lt;code&gt;homer&lt;/code&gt;, &lt;code&gt;prometheus&lt;/code&gt;, etc.). This process automatically creates the public CNAME records in Cloudflare's DNS panel.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Chapter 5: The "Split-Brain DNS" Setup
&lt;/h3&gt;

&lt;p&gt;This setup ensures our new domains work perfectly &lt;em&gt;both&lt;/em&gt; inside and outside our home network.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Publicly (Away from Home):&lt;/strong&gt; This is already done. When a device is on cellular data, it uses public DNS, finds the Cloudflare CNAME, and is securely sent through the tunnel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Locally (At Home):&lt;/strong&gt; When at home, we don't want traffic going out to the internet and back. We use AdGuard Home to create DNS rewrites.

&lt;ol&gt;
&lt;li&gt; In the AdGuard Home dashboard, go to &lt;strong&gt;Filters &amp;gt; DNS Rewrites&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt; Add a new rule:

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Domain:&lt;/strong&gt; &lt;code&gt;grafana.your-domain.com&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Answer:&lt;/strong&gt; &lt;code&gt;192.168.1.100&lt;/code&gt; (The local IP of your NPM server)&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt; Repeat this for &lt;code&gt;homer.your-domain.com&lt;/code&gt;, &lt;code&gt;prometheus.your-domain.com&lt;/code&gt;, etc.&lt;/li&gt;

&lt;/ol&gt;

&lt;/li&gt;

&lt;/ul&gt;

&lt;h3&gt;
  
  
  Chapter 6: The "Split-Brain" SSL Fix (Local &amp;amp; Public)
&lt;/h3&gt;

&lt;p&gt;This setup will create two different SSL errors: &lt;code&gt;ERR_SSL_UNRECOGNIZED_NAME_ALERT&lt;/code&gt; when at home, and &lt;code&gt;ERR_TOO_MANY_REDIRECTS&lt;/code&gt; when on a public network.&lt;/p&gt;

&lt;p&gt;Here is the step-by-step solution that fixes both problems permanently.&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Step 1: Get a Cloudflare API Token&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;NPM needs a way to automatically prove to Cloudflare that it owns the domain.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; In the Cloudflare profile, go to &lt;strong&gt;API Tokens &amp;gt; Create Token&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt; Use the &lt;strong&gt;"Edit zone DNS"&lt;/strong&gt; template.&lt;/li&gt;
&lt;li&gt; Set &lt;strong&gt;Zone Resources&lt;/strong&gt; to &lt;code&gt;Include&lt;/code&gt; &amp;gt; &lt;code&gt;Specific zone&lt;/code&gt; &amp;gt; &lt;code&gt;your-domain.com&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt; Click "Continue" and "Create Token". &lt;strong&gt;Copy the generated token&lt;/strong&gt; immediately.&lt;/li&gt;
&lt;/ol&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Step 2: Configure NPM Proxy Host&lt;/strong&gt;
&lt;/h4&gt;

&lt;ol&gt;
&lt;li&gt; In the Nginx Proxy Manager admin panel, edit the proxy host for &lt;code&gt;grafana.your-domain.com&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt; On the &lt;strong&gt;Details Tab&lt;/strong&gt;, make sure the Forward Hostname is &lt;code&gt;grafana&lt;/code&gt; and the Forward Port is &lt;code&gt;3000&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt; Go to the &lt;strong&gt;SSL Tab&lt;/strong&gt;:

&lt;ul&gt;
&lt;li&gt;For &lt;strong&gt;SSL Certificate&lt;/strong&gt;, choose &lt;strong&gt;"Request a new SSL Certificate"&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Toggle &lt;strong&gt;"Use a DNS Challenge"&lt;/strong&gt; to &lt;strong&gt;ON&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;"Add a new credential"&lt;/strong&gt;, select Cloudflare, and paste in your API token.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CRITICALLY: Toggle "Force SSL" to OFF.&lt;/strong&gt; This is what prevents the redirect loop.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt; Click &lt;strong&gt;Save&lt;/strong&gt;. NPM will now use the API token to get a valid Let's Encrypt certificate.&lt;/li&gt;
&lt;/ol&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Step 3: Configure Cloudflare SSL&lt;/strong&gt;
&lt;/h4&gt;

&lt;ol&gt;
&lt;li&gt; In the main Cloudflare dashboard, for your domain go to &lt;strong&gt;SSL/TLS &amp;gt; Overview&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt; Set the SSL/TLS encryption mode to &lt;strong&gt;"Full (Strict)"&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This combination is the perfect solution:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Locally:&lt;/strong&gt; The browser connects directly to NPM (thanks to the AdGuard rewrite) and is served the valid Let's Encrypt certificate, fixing the &lt;code&gt;ERR_SSL_UNRECOGNIZED_NAME_ALERT&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Publicly:&lt;/strong&gt; Cloudflare enforces &lt;code&gt;HTTPS&lt;/code&gt; (Full Strict). The request goes to NPM, which (with "Force SSL" off) no longer tries to redirect, fixing the &lt;code&gt;ERR_TOO_MANY_REDIRECTS&lt;/code&gt; loop.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Chapter 7: The Final Layer - Cloudflare Access (Zero Trust)
&lt;/h3&gt;

&lt;p&gt;Our services are now accessible, but they are public. This final step secures them.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; In the Cloudflare Zero Trust dashboard, go to &lt;strong&gt;Access &amp;gt; Applications&lt;/strong&gt; and click "Add an application".&lt;/li&gt;
&lt;li&gt; Choose &lt;strong&gt;"Self-hosted"&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt; Add all the new public hostnames (&lt;code&gt;grafana.your-domain.com&lt;/code&gt;, &lt;code&gt;homer.your-domain.com&lt;/code&gt;, etc.) to the "Public hostname" section.&lt;/li&gt;
&lt;li&gt; On the next page, create one simple policy:

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Policy name:&lt;/strong&gt; &lt;code&gt;Allow-Admin-Only&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Action:&lt;/strong&gt; &lt;code&gt;Allow&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rule:&lt;/strong&gt; &lt;code&gt;Include&lt;/code&gt;, &lt;code&gt;Emails&lt;/code&gt;, &lt;code&gt;your-email@example.com&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt; Click "Save application".&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Conclusion: From Bare Metal to a Secure, Global Homelab
&lt;/h3&gt;

&lt;p&gt;And with that, we've placed the final and most important piece of our puzzle: professional-grade security.&lt;/p&gt;

&lt;p&gt;Let's step back and appreciate what we've built. On our home network, we have seamless, direct access to our services with valid SSL certificates. The moment we step outside our home, our lab becomes a secure fortress. Our services are completely invisible to the public internet, hidden behind Cloudflare's robust Zero Trust authentication.&lt;/p&gt;

&lt;p&gt;We have successfully achieved the gold standard of modern, secure infrastructure:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Zero-Trust Access:&lt;/strong&gt; Only authenticated users can even &lt;em&gt;see&lt;/em&gt; our login pages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A "Closed-Port" Firewall:&lt;/strong&gt; We've done it all &lt;strong&gt;without opening a single port on our router&lt;/strong&gt;, eliminating one of the single greatest security risks for any homelab.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Global Accessibility:&lt;/strong&gt; We can securely access our tools from anywhere in the world, just like a professional enterprise service.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  Looking Back: Our 5-Part Journey
&lt;/h3&gt;

&lt;p&gt;It's amazing to see how far we've come. In this series, we started with nothing but a powered-off machine and an idea.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;In &lt;strong&gt;Part 1&lt;/strong&gt;, we built our server from the ground up with a minimal &lt;strong&gt;Debian&lt;/strong&gt; install utilizing our old laptop.&lt;/li&gt;
&lt;li&gt;In &lt;strong&gt;Part 2&lt;/strong&gt;, we hardened its security with &lt;strong&gt;SSH keys, UFW, and Fail2Ban&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;In &lt;strong&gt;Part 3&lt;/strong&gt;, we unleashed its potential with &lt;strong&gt;Docker&lt;/strong&gt; and deployed our first service, &lt;strong&gt;AdGuard Home&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;In &lt;strong&gt;Part 4&lt;/strong&gt;, we solved internal networking with a &lt;strong&gt;local DNS server&lt;/strong&gt; for clean, "at-home" SSL.&lt;/li&gt;
&lt;li&gt;And finally, in &lt;strong&gt;Part 5&lt;/strong&gt;, we've secured it for the entire world with &lt;strong&gt;Cloudflare Tunnels&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We have successfully built a stable, secure, and powerful foundation. This server is no longer just a project; it's a platform ready to host any idea we can dream up.&lt;/p&gt;

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

&lt;p&gt;Thank you so much for following along on this journey. I hope this guide has been valuable and has empowered you to build your own private corner of the internet.&lt;/p&gt;

&lt;p&gt;For now, this concludes our setup series. We've built the "house" and secured it. The next logical step, and the subject for a whole new series, is to learn how to manage it. What if we want to deploy 10 services? What if this server fails and we need to rebuild it in minutes, not hours?&lt;/p&gt;

&lt;p&gt;Our foundation is set. The next adventure will be in the world of automation and orchestration, using powerful tools like &lt;strong&gt;Ansible (Infrastructure as Code)&lt;/strong&gt; to automate our setup and &lt;strong&gt;Kubernetes (K3s)&lt;/strong&gt; to manage our containerized applications at scale.&lt;/p&gt;

&lt;p&gt;Stay tuned, and happy building!&lt;/p&gt;

</description>
      <category>linux</category>
      <category>cloud</category>
      <category>docker</category>
      <category>security</category>
    </item>
    <item>
      <title>Part 4: Automating a Homelab with Backups, Updates, and Alerts</title>
      <dc:creator>Prajwol Adhikari</dc:creator>
      <pubDate>Sun, 10 May 2026 00:48:25 +0000</pubDate>
      <link>https://forem.com/prajwol-ad/part-4-automating-a-homelab-with-backups-updates-and-alerts-58d0</link>
      <guid>https://forem.com/prajwol-ad/part-4-automating-a-homelab-with-backups-updates-and-alerts-58d0</guid>
      <description>&lt;h3&gt;
  
  
  Introduction
&lt;/h3&gt;

&lt;p&gt;Welcome to the part 4 of the homelab series! In the previous parts, we built a server, deployed a suite of services, and configured our network. Now, it's time to make it resilient and self-maintaining. A homelab isn't just about setting things up; it's about keeping them running reliably.&lt;/p&gt;

&lt;p&gt;This guide will show you how to set up the three pillars of modern IT operations: &lt;strong&gt;Automated Backups&lt;/strong&gt;, &lt;strong&gt;Automated Updates&lt;/strong&gt;, and &lt;strong&gt;Proactive Alerting&lt;/strong&gt;. By the end, you'll have a homelab that runs itself, ensures your data is safe, stays up-to-date, and notifies you when something goes wrong.&lt;/p&gt;




&lt;h3&gt;
  
  
  Chapter 1: The Automated Backup Strategy (at 3 AM)
&lt;/h3&gt;

&lt;p&gt;A solid backup strategy is non-negotiable. I implemented a robust system inspired by the "3-2-1" rule, focusing on redundancy and an off-site copy. My strategy involves maintaining &lt;strong&gt;two copies&lt;/strong&gt; of my data in &lt;strong&gt;two separate locations&lt;/strong&gt;: one local backup on the server itself for fast recovery, and one automated, off-site backup to Google Drive to protect against a local disaster like a fire or hardware failure.&lt;/p&gt;

&lt;p&gt;This script runs at 3 AM, creates a local backup, uploads it, and then notifies Discord.&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Step 1: Configure &lt;code&gt;rclone&lt;/code&gt; for Google Drive&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;First, you need a tool to communicate with Google Drive. We'll use &lt;code&gt;rclone&lt;/code&gt;.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Install &lt;code&gt;rclone&lt;/code&gt;&lt;/strong&gt; on your Debian server:&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="p"&gt;;&lt;/span&gt; curl https://rclone.org/install.sh | &lt;span class="nb"&gt;sudo &lt;/span&gt;bash
&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Run the interactive setup:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;rclone config
&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Follow the Prompts:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;n&lt;/code&gt; (New remote) *&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;name&amp;gt;&lt;/code&gt;: &lt;code&gt;gdrive&lt;/code&gt; (You can name it anything)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;storage&amp;gt;&lt;/code&gt;: Find and select &lt;code&gt;drive&lt;/code&gt; (Google Drive).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;client_id&amp;gt;&lt;/code&gt; &amp;amp; &lt;code&gt;client_secret&amp;gt;&lt;/code&gt;: Press Enter for both to leave blank.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;scope&amp;gt;&lt;/code&gt;: Choose &lt;code&gt;1&lt;/code&gt; (Full access).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Use auto config? y/n&amp;gt;&lt;/code&gt;: This is a &lt;strong&gt;critical step&lt;/strong&gt;. Since we are on a headless server, type &lt;code&gt;n&lt;/code&gt; and press Enter.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Authorize Headless:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;rclone&lt;/code&gt; will give you a command to run on a machine &lt;em&gt;with a web browser&lt;/em&gt; (like your main computer).&lt;/li&gt;
&lt;li&gt;On your main computer (where you have &lt;code&gt;rclone&lt;/code&gt; installed), run the &lt;code&gt;rclone authorize "drive" "..."&lt;/code&gt; command.&lt;/li&gt;
&lt;li&gt;This will open your browser, ask you to log in to Google, and grant permission.&lt;/li&gt;
&lt;li&gt;Your main computer's terminal will then output a block of text (your &lt;code&gt;config_token&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Paste Token:&lt;/strong&gt; Copy the token from your main computer and paste it back into your server's &lt;code&gt;rclone&lt;/code&gt; prompt.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Finish the prompts, and your connection is complete.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Step 2: Create the Backup Script&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;Next, create a shell script to perform the backup.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;Create the file and make it executable:&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nano ~/backup.sh
&lt;span class="nb"&gt;chmod&lt;/span&gt; +x ~/backup.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Paste in the following script. &lt;strong&gt;You must edit the first 7 variables&lt;/strong&gt; to match your setup.&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;

&lt;span class="c"&gt;# --- Configuration ---&lt;/span&gt;
&lt;span class="nv"&gt;SOURCE_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/path/to/your/docker"&lt;/span&gt;  &lt;span class="c"&gt;# &amp;lt;-- Change to your Docker projects directory&lt;/span&gt;
&lt;span class="nv"&gt;BACKUP_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/path/to/your/backups"&lt;/span&gt;  &lt;span class="c"&gt;# &amp;lt;-- Change to your backups folder&lt;/span&gt;
&lt;span class="nv"&gt;FILENAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"homelab-backup-&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%Y-%m-%d&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;.tar.gz"&lt;/span&gt;
&lt;span class="nv"&gt;LOCAL_RETENTION_DAYS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;3
&lt;span class="nv"&gt;CLOUD_RETENTION_DAYS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;3
&lt;span class="nv"&gt;RCLONE_REMOTE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"gdrive"&lt;/span&gt;  &lt;span class="c"&gt;# &amp;lt;-- Must match your rclone remote name&lt;/span&gt;
&lt;span class="nv"&gt;RCLONE_DEST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"Homelab Backups"&lt;/span&gt;  &lt;span class="c"&gt;# &amp;lt;-- Folder name in Google Drive&lt;/span&gt;

&lt;span class="c"&gt;# --- "https://discordapp.com/api/webhooks/141949178941/6Tx6f1yjf26LztQ" ---&lt;/span&gt;
&lt;span class="nv"&gt;DISCORD_WEBHOOK_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"YOUR_DISCORD_WEBHOOK_URL"&lt;/span&gt;

&lt;span class="c"&gt;# --- Notification Function ---&lt;/span&gt;
send_notification&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;MESSAGE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;
    curl &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;content&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="nv"&gt;$MESSAGE&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;}"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DISCORD_WEBHOOK_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;# --- Script Logic ---&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"--- Starting Homelab Backup: &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; ---"&lt;/span&gt;
send_notification &lt;span class="s2"&gt;"✅ Starting Homelab Backup..."&lt;/span&gt;

&lt;span class="c"&gt;# 1. Create local backup&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Creating local backup..."&lt;/span&gt;
&lt;span class="nb"&gt;tar&lt;/span&gt; &lt;span class="nt"&gt;-czf&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BACKUP_DIR&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;FILENAME&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;SOURCE_DIR&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Local backup created at &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BACKUP_DIR&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;FILENAME&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# 2. Upload to Google Drive&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Uploading backup to &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;RCLONE_REMOTE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;..."&lt;/span&gt;
rclone copy &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BACKUP_DIR&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;FILENAME&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;RCLONE_REMOTE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;RCLONE_DEST&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Upload complete."&lt;/span&gt;

&lt;span class="c"&gt;# 3. Clean up local backups&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Cleaning up local backups older than &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;LOCAL_RETENTION_DAYS&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; days..."&lt;/span&gt;
find &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BACKUP_DIR&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-type&lt;/span&gt; f &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s2"&gt;"*.tar.gz"&lt;/span&gt; &lt;span class="nt"&gt;-mtime&lt;/span&gt; +&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;LOCAL_RETENTION_DAYS&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt; &lt;span class="nt"&gt;-delete&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Local cleanup complete."&lt;/span&gt;

&lt;span class="c"&gt;# 4. Clean up cloud backups&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Cleaning up cloud backups older than &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CLOUD_RETENTION_DAYS&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; days..."&lt;/span&gt;
rclone delete &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;RCLONE_REMOTE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;RCLONE_DEST&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--min-age&lt;/span&gt; &lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CLOUD_RETENTION_DAYS&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;d
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Cloud cleanup complete."&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Backup process finished."&lt;/span&gt;
send_notification &lt;span class="s2"&gt;"🎉 Homelab backup and cloud upload completed successfully!"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

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

&lt;h4&gt;
  
  
  &lt;strong&gt;Step 3: Automate with Cron&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;To run this script automatically, you must add it to the &lt;code&gt;root&lt;/code&gt; user's crontab. This is critical for giving the script permission to read all Docker files.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;Open the root crontab editor:&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;crontab &lt;span class="nt"&gt;-e&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Add the following line to schedule the backup for 3:00 AM every morning:&lt;br&gt;
&lt;code&gt;0 3 * * * /path/to/your/backup.sh&lt;/code&gt;&lt;br&gt;
You will now get a fresh, onsite and off-site backup every night and a Discord message when it's done.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;h3&gt;
  
  
  Chapter 2: Automated Updates with Watchtower (at 6 AM)
&lt;/h3&gt;

&lt;p&gt;Manually updating every Docker container is tedious. We can automate this by deploying &lt;strong&gt;Watchtower&lt;/strong&gt;.&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Step 1: The Docker Compose File&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;Create a &lt;code&gt;docker-compose.yml&lt;/code&gt; for Watchtower. This configuration schedules it to run once a day at 6:00 AM, clean up old images, and send a Discord notification &lt;em&gt;only&lt;/em&gt; if it finds an update.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;code&gt;mkdir -p ~/docker/watchtower&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt; &lt;code&gt;cd ~/docker/watchtower&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt; &lt;code&gt;nano docker-compose.yml&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Paste in this configuration:&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;watchtower&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;containrrr/watchtower&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;watchtower&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/var/run/docker.sock:/var/run/docker.sock&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# Timezone setting&lt;/span&gt;
        &lt;span class="na"&gt;TZ&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;America/Chicago&lt;/span&gt;

        &lt;span class="c1"&gt;# Discord notification settings&lt;/span&gt;
        &lt;span class="na"&gt;WATCHTOWER_NOTIFICATIONS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shoutrrr&lt;/span&gt;
        &lt;span class="na"&gt;WATCHTOWER_NOTIFICATION_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;discord://YOUR_DISCORD_WEBHOOK_ID_URL&amp;gt;&lt;/span&gt;

        &lt;span class="s"&gt;#&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Notification&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;settings&lt;/span&gt;
        &lt;span class="s"&gt;WATCHTOWER_NOTIFICATIONS_LEVEL:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;info&lt;/span&gt;
        &lt;span class="s"&gt;WATCHTOWER_NOTIFICATION_REPORT:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;
        &lt;span class="na"&gt;WATCHTOWER_NOTIFICATIONS_HOSTNAME&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Homelab-Laptop&lt;/span&gt;

        &lt;span class="c1"&gt;# Update settings&lt;/span&gt;
        &lt;span class="na"&gt;WATCHTOWER_CLEANUP&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;
        &lt;span class="na"&gt;WATCHTOWER_INCLUDE_STOPPED&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;false"&lt;/span&gt;
        &lt;span class="na"&gt;WATCHTOWER_INCLUDE_RESTARTING&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;
        &lt;span class="na"&gt;WATCHTOWER_SCHEDULE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;6&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*"&lt;/span&gt;

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


&lt;p&gt;&lt;em&gt;Note: The &lt;code&gt;WATCHTOWER_NOTIFICATION_URL&lt;/code&gt; uses a special &lt;code&gt;shoutrrr&lt;/code&gt; format for Discord, which looks like &lt;code&gt;discord://token@webhook-id&lt;/code&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Now, every morning at 6:00 AM, Watchtower will scan all running containers and update any that have a new image available.&lt;/p&gt;




&lt;h3&gt;
  
  
  Chapter 3: Proactive Alerting (24/7)
&lt;/h3&gt;

&lt;p&gt;The final piece of automation is proactive alerting. This setup ensures you are immediately notified via Discord if something goes wrong.&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Step 1: The Alerting Pipeline&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;The pipeline we'll build is: &lt;strong&gt;Prometheus&lt;/strong&gt; (detects problems) -&amp;gt; &lt;strong&gt;Alertmanager&lt;/strong&gt; (groups and routes alerts) -&amp;gt; &lt;strong&gt;Discord&lt;/strong&gt; (notifies you).&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Step 2: Deploy Alertmanager&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;First, deploy Alertmanager. It must be on the same &lt;code&gt;npm_default&lt;/code&gt; network as Prometheus.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;code&gt;mkdir -p ~/docker/alertmanager&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt; &lt;code&gt;cd ~/docker/alertmanager&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Create the &lt;code&gt;alertmanager.yml&lt;/code&gt; configuration file:&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nano alertmanager.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Paste in this configuration. It uses advanced routing to send &lt;code&gt;critical&lt;/code&gt; alerts every 2 hours and &lt;code&gt;warning&lt;/code&gt; alerts every 12 hours.&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;global&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;resolve_timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5m&lt;/span&gt;

&lt;span class="na"&gt;route&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;group_by&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alertname"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;severity"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;group_wait&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;30s&lt;/span&gt;
  &lt;span class="na"&gt;group_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10m&lt;/span&gt;
  &lt;span class="na"&gt;repeat_interbal&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;12h&lt;/span&gt;
  &lt;span class="na"&gt;receiver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;discord-notifications"&lt;/span&gt;
  &lt;span class="na"&gt;routes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;receiver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;discord-notifications"&lt;/span&gt;
      &lt;span class="na"&gt;matchers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;severity="critical"&lt;/span&gt;
      &lt;span class="na"&gt;repeat_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2h&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;receiver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;discord-notifications"&lt;/span&gt;
      &lt;span class="na"&gt;matchers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;severity="warning"&lt;/span&gt;
      &lt;span class="na"&gt;repeat_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;12h&lt;/span&gt;

&lt;span class="na"&gt;receivers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;discord-notifications"&lt;/span&gt;
    &lt;span class="na"&gt;discord_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;webhook_url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;YOUR_DISCORD_WEBHOOK_URL"&lt;/span&gt;
        &lt;span class="na"&gt;send_resolved&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Now create the &lt;code&gt;docker-compose.yml&lt;/code&gt; for Alertmanager:&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nano docker-compose.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Paste in the following:&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;alertmanager&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prom/alertmanager:latest&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;alertmanager&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./alertmanager.yml:/etc/alertmanager/alertmanager.yml&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;npm_default&lt;/span&gt;

&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;npm_default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;external&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Launch it: &lt;code&gt;docker compose up -d&lt;/code&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Step 3: Configure Prometheus&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;Finally, tell Prometheus to send alerts to Alertmanager and load your rules.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;Create your rules file, &lt;code&gt;~/docker/monitoring/alert_rules.yml&lt;/code&gt;, with rules for "Instance Down," "High CPU," "Low Disk Space," etc.&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ~/docker/monitoring
nano alert_rules.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Add the &lt;code&gt;alert_rules.yml&lt;/code&gt; as a volume in your &lt;code&gt;~/docker/monitoring/docker-compose.yml&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./prometheus.yml:/etc/prometheus/prometheus.yml&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./alert_rules.yml:/etc/prometheus/alert_rules.yml&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;prometheus_data:/prometheus&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Add the &lt;code&gt;alerting&lt;/code&gt; and &lt;code&gt;rule_files&lt;/code&gt; blocks to your &lt;code&gt;~/docker/monitoring/prometheus.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;groups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;-name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Critical System Alerts&lt;/span&gt;
  &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;30s&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;InstanceDown&lt;/span&gt;
    &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;up == &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;
    &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2m&lt;/span&gt;
    &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;critical&lt;/span&gt;
    &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;🔴&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Instance&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;$labels.instance&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;is&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;DOWN"&lt;/span&gt;
      &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Service&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;$labels.job&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;has&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;been&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;unreachable&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;for&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;2&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;minutes."&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;LaptopOnBattery&lt;/span&gt;
      &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node_power_supply_online == &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;
      &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5m&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;critical&lt;/span&gt;
      &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;🔋&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Server&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;running&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;on&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;BATTERY"&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Homelab&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;has&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;been&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;unplugged&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;for&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;5&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;minutes.&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Check&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;power&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;connection!"&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;LowBatteryLevel&lt;/span&gt;
      &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node_power_supply_capacity &amp;lt; 20 and node_power_supply_online == &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;
      &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1m&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;critical&lt;/span&gt;
      &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;⚠️&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;CRITICAL:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Battery&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;at&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;$value&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}%"&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Battery&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;below&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;20%.&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Server&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;may&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;shut&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;down&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;soon!"&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;DiskAlmostFull&lt;/span&gt;
      &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;(node_filesystem_avail_bytes{mountpoint="/",fstype!="tmpfs"} / node_filesystem_size_bytes{mountpoint="/",fstype!="tmpfs"}) * 100 &amp;lt; &lt;/span&gt;&lt;span class="m"&gt;10&lt;/span&gt;
      &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5m&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;critical&lt;/span&gt;
      &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;💾&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Disk&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;space&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;critically&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;low:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;$value&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;humanize&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}%&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;remaining"&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Root&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;filesystem&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;has&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;less&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;than&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;10%&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;free&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;space."&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;OutOfMemory&lt;/span&gt;
      &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;(node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100 &amp;lt; &lt;/span&gt;&lt;span class="m"&gt;5&lt;/span&gt;
      &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2m&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;critical&lt;/span&gt;
      &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;🧠&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Memory&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;critically&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;low:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;$value&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;humanize&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}%&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;available"&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Less&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;than&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;5%&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;memory&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;available.&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;System&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;may&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;become&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;unresponsive."&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CriticalCpuTemperature&lt;/span&gt;
      &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node_hwmon_temp_celsius{chip="coretemp"} &amp;gt; &lt;/span&gt;&lt;span class="m"&gt;95&lt;/span&gt;
      &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2m&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;critical&lt;/span&gt;
      &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;🔥&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;CRITICAL&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;CPU&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Temperature:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;$value&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}°C"&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CPU&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;temperature&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;exceeds&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;95°C.&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Thermal&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;throttling&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;or&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;shutdown&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;imminent!"&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Warning System Alerts&lt;/span&gt;
  &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1m&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HighCpuUsage&lt;/span&gt;
      &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) &amp;gt; &lt;/span&gt;&lt;span class="m"&gt;80&lt;/span&gt;
      &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5m&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;warning&lt;/span&gt;
      &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;⚡&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;High&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;CPU&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;usage:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;$value&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;humanize&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}%"&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CPU&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;usage&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;above&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;80%&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;for&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;5&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;minutes&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;on&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;$labels.instance&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HighSystemLoad&lt;/span&gt;
      &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node_load5 / on(instance) count(node_cpu_seconds_total{mode="idle"}) by (instance) &amp;gt; &lt;/span&gt;&lt;span class="m"&gt;1.5&lt;/span&gt;
      &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10m&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;warning&lt;/span&gt;
      &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;📊&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;High&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;system&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;load:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;$value&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;humanize&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5-minute&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;load&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;average&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;is&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;1.5x&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;CPU&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;cores&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;for&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;10&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;minutes."&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HighMemoryUsage&lt;/span&gt;
      &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;(node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100 &amp;lt; &lt;/span&gt;&lt;span class="m"&gt;20&lt;/span&gt;
      &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5m&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;warning&lt;/span&gt;
      &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;🧠&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;High&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;memory&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;usage:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;$value&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;humanize&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}%&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;available"&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Less&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;than&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;20%&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;memory&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;available."&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HighCpuTemperature&lt;/span&gt;
      &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node_hwmon_temp_celsius{chip="coretemp"} &amp;gt; &lt;/span&gt;&lt;span class="m"&gt;85&lt;/span&gt;
      &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5m&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;warning&lt;/span&gt;
      &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;🌡️&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;High&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;CPU&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;temperature:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;$value&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}°C"&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CPU&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;temperature&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;above&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;85°C.&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Consider&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;improving&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;cooling."&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HighNvmeTemperature&lt;/span&gt;
      &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node_hwmon_temp_celsius{chip="nvme"} &amp;gt; &lt;/span&gt;&lt;span class="m"&gt;65&lt;/span&gt;
      &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10m&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;warning&lt;/span&gt;
      &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;💿&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;High&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;NVMe&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;temperature:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;$value&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}°C"&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NVMe&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;drive&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;temperature&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;above&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;65°C&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;for&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;10&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;minutes."&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;DiskSpaceLow&lt;/span&gt;
      &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;(node_filesystem_avail_bytes{mountpoint="/",fstype!="tmpfs"} / node_filesystem_size_bytes{mountpoint="/",fstype!="tmpfs"}) * 100 &amp;lt; &lt;/span&gt;&lt;span class="m"&gt;20&lt;/span&gt;
      &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10m&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;warning&lt;/span&gt;
      &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;💾&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Disk&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;space&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;low:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;$value&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;humanize&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}%&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;remaining"&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Root&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;filesystem&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;has&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;less&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;than&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;20%&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;free&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;space."&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HighSwapUsage&lt;/span&gt;
      &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;((node_memory_SwapTotal_bytes - node_memory_SwapFree_bytes) / node_memory_SwapTotal_bytes * 100) &amp;gt; &lt;/span&gt;&lt;span class="m"&gt;50&lt;/span&gt;
      &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10m&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;warning&lt;/span&gt;
      &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;💱&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;High&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;swap&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;usage:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;$value&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;humanize&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}%"&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Swap&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;usage&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;above&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;50%.&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;System&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;may&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;be&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;memory-constrained."&lt;/span&gt;

    &lt;span class="c1"&gt;# Monitor your USB-C hub ethernet adapter (enx00)&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;EthernetInterfaceDown&lt;/span&gt;
      &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node_network_up{device="enx00"} == &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;
      &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2m&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;warning&lt;/span&gt;
      &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;🌐&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;USB-C&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Ethernet&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;adapter&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;is&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;DISCONNECTED"&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Your&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;USB-C&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;hub&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;ethernet&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;connection&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;(enx00)&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;is&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;down.&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Check&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;cable&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;or&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;hub."&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HighNetworkErrors&lt;/span&gt;
      &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rate(node_network_receive_errs_total{device="enx00"}[5m]) &amp;gt; 10 or rate(node_network_transmit_errs_total{device="enx00"}[5m]) &amp;gt; &lt;/span&gt;&lt;span class="m"&gt;10&lt;/span&gt;
      &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5m&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;warning&lt;/span&gt;
      &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;🌐&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;High&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;network&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;errors&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;on&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;USB-C&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;ethernet"&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Your&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;ethernet&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;adapter&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;is&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;experiencing&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;high&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;rate.&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Check&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;cable&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;quality."&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Docker Container Alerts&lt;/span&gt;
  &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1m&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Simplified alert - just checks if container exporter is working&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ContainerMonitoringDown&lt;/span&gt;
      &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;absent(container_last_seen)&lt;/span&gt;
      &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2m&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;warning&lt;/span&gt;
      &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;🐳&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Container&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;monitoring&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;is&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;down"&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cAdvisor&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;or&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;container&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;metrics&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;are&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;not&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;available.&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Check&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;if&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;containers&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;are&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;being&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;monitored."&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ContainerRestarting&lt;/span&gt;
      &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rate(container_start_time_seconds[5m]) &amp;gt; &lt;/span&gt;&lt;span class="m"&gt;0.01&lt;/span&gt;
      &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2m&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;warning&lt;/span&gt;
      &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;🐳&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Container&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;$labels.name&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;is&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;restarting"&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Container&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;$labels.name&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;has&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;restarted&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;recently."&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ContainerHighCpu&lt;/span&gt;
      &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rate(container_cpu_usage_seconds_total{name!~".*POD.*",name!=""}[5m]) * 100 &amp;gt; &lt;/span&gt;&lt;span class="m"&gt;80&lt;/span&gt;
      &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10m&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;warning&lt;/span&gt;
      &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;🐳&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Container&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;$labels.name&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;high&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;CPU:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;$value&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;humanize&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}%"&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Container&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;CPU&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;usage&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;above&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;80%&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;for&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;10&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;minutes."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Restart Prometheus to apply the changes:&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ~/docker/monitoring
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--force-recreate&lt;/span&gt; prometheus
&lt;/code&gt;&lt;/pre&gt;


&lt;p&gt;Now, if any service fails or your server's resources run low, you will get an instant notification in Discord.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Step 3: The Critical Firewall Fix&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;You may find your alerts are not sending. This is often due to a conflict between Docker and &lt;code&gt;ufw&lt;/code&gt;.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;Open the main &lt;code&gt;ufw&lt;/code&gt; configuration file:&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;nano /etc/default/ufw
&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Change &lt;code&gt;DEFAULT_FORWARD_POLICY="DROP"&lt;/code&gt; to &lt;code&gt;DEFAULT_FORWARD_POLICY="ACCEPT"&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Reload the firewall:&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw reload
&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Restart your containers that need internet access:&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose restart
&lt;/code&gt;&lt;/pre&gt;

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

&lt;p&gt;Now, if any service fails or your server's resources run low, you will get an instant notification in Discord.&lt;/p&gt;




&lt;h3&gt;
  
  
  Conclusion
&lt;/h3&gt;

&lt;p&gt;Our homelab has now truly come to life. It's no longer just a collection of services but a resilient, self-maintaining platform. With automated backups to Google Drive, daily updates via Watchtower, and proactive alerts with Prometheus and Alertmanager, our server can now run 24/7 with minimal manual intervention. We've built a solid, reliable, and intelligent system.&lt;/p&gt;

&lt;p&gt;But there's one critical piece still missing: end-to-end security for our local services.&lt;/p&gt;

&lt;p&gt;Right now, we're accessing our dashboards at addresses like &lt;code&gt;http://grafana.local&lt;/code&gt;, which browsers flag as "Not Secure." What if we could use a real, public domain name for our &lt;em&gt;internal&lt;/em&gt; services and get a valid HTTPS certificate, all without opening a single port on our router?&lt;/p&gt;

&lt;p&gt;In the next part of this series, I'll show you exactly how to do that. We'll dive into an advanced but powerful setup using Cloudflare and Nginx Proxy Manager to bring trusted, zero-exposure SSL to everything we've built.&lt;/p&gt;

&lt;p&gt;Stay tuned!&lt;/p&gt;

</description>
      <category>linux</category>
      <category>discord</category>
      <category>automation</category>
      <category>homelab</category>
    </item>
    <item>
      <title>Part 3: A High-Availability DNS Network with AdGuard Home</title>
      <dc:creator>Prajwol Adhikari</dc:creator>
      <pubDate>Sun, 10 May 2026 00:44:45 +0000</pubDate>
      <link>https://forem.com/prajwol-ad/part-3-a-high-availability-dns-network-with-adguard-home-39p4</link>
      <guid>https://forem.com/prajwol-ad/part-3-a-high-availability-dns-network-with-adguard-home-39p4</guid>
      <description>&lt;h3&gt;
  
  
  Introduction
&lt;/h3&gt;

&lt;p&gt;Welcome to Part 3 of my homelab series! In the previous parts, I built my server and deployed a suite of management and monitoring tools. Now, it's time to build the brain of my network: a robust, redundant, and high-availability DNS system using &lt;strong&gt;AdGuard Home&lt;/strong&gt; that works both at home and on the go.&lt;/p&gt;

&lt;p&gt;In this detailed guide, I'll walk you through how I deployed a total of &lt;strong&gt;three&lt;/strong&gt; AdGuard Home instances, each with its own unique IP address. I set up a primary resolver on my homelab, a secondary failover resolver in the cloud for my mobile devices, and a tertiary resolver on a separate virtual network for local redundancy.&lt;/p&gt;




&lt;h3&gt;
  
  
  Chapter 1: The Local Workhorse (Primary DNS)
&lt;/h3&gt;

&lt;p&gt;I started by deploying my main, day-to-day DNS resolver on my homelab server.&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Step 1: Deploying AdGuard Home with Docker Compose&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;First, I SSHed into my server, created a directory for the project, and a &lt;code&gt;docker-compose.yml&lt;/code&gt; file to define the service.&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;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/docker/adguard-primary
&lt;span class="nb"&gt;cd&lt;/span&gt; ~/docker/adguard-primary
nano docker-compose.yml

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

&lt;/div&gt;



&lt;p&gt;I pasted in the following configuration. This runs the AdGuard Home container, maps all the necessary ports for DNS and the web UI, and connects it to the shared &lt;code&gt;npm_default&lt;/code&gt; network I set up in Part 2.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;adguardhome&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;adguard/adguardhome:latest&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;adguard-primary&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;53:53/tcp"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;53:53/udp"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8080:80/tcp"&lt;/span&gt;      &lt;span class="c1"&gt;# Web UI&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;853:853/tcp"&lt;/span&gt;      &lt;span class="c1"&gt;# DNS-over-TLS&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./workdir:/opt/adguardhome/work&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./confdir:/opt/adguardhome/conf&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;npm_default&lt;/span&gt;

&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;npm_default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;external&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I then launched the container by running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  &lt;strong&gt;Step 2: Initial AdGuard Home Setup Wizard&lt;/strong&gt;
&lt;/h4&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;I navigated to &lt;code&gt;http://&amp;lt;your-server-ip&amp;gt;:3000&lt;/code&gt; in my web browser to start the setup wizard.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;I clicked "Get Started."&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;On the "Admin Web Interface" screen, I changed the "Listen Interface" to &lt;code&gt;All interfaces&lt;/code&gt; and the port to &lt;code&gt;80&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;On the "DNS server" screen, I changed the "Listen Interface" to &lt;code&gt;All interfaces&lt;/code&gt; and left the port as &lt;code&gt;53&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;I followed the prompts to create my admin username and password.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Once the setup was complete, I was redirected to my main dashboard, now available at &lt;code&gt;http://&amp;lt;your-server-ip&amp;gt;:8080&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Step 3: Configure My Home Router&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;To make all my devices use AdGuard automatically, I logged into my home router's admin panel, found the &lt;strong&gt;DHCP Server&lt;/strong&gt; settings, and changed the &lt;strong&gt;Primary DNS Server&lt;/strong&gt; to my homelab's static IP address (e.g., &lt;code&gt;192.168.1.10&lt;/code&gt;).&lt;/p&gt;




&lt;h3&gt;
  
  
  Chapter 2: The Cloud Failover (Secondary DNS on Oracle Cloud)
&lt;/h3&gt;

&lt;p&gt;An off-site DNS server ensures I have ad-blocking on my mobile devices and acts as a backup.&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Why I Chose Oracle Cloud&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;After testing the free tiers of both AWS and Linode, I chose &lt;strong&gt;Oracle Cloud Infrastructure (OCI)&lt;/strong&gt;. In my experience, OCI's "Always Free" tier is far more generous with its resources. It provides powerful Ampere A1 Compute instances with up to 4 CPU cores and 24 GB of RAM, plus 200 GB of storage and significant bandwidth, all for free. This was ideal for running my service 24/7 without the strict limitations or eventual costs associated with other providers.&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Step 1: Launching the Oracle Cloud VM&lt;/strong&gt;
&lt;/h4&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Sign Up:&lt;/strong&gt; I created my account on the Oracle Cloud website.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Create VM Instance:&lt;/strong&gt; In the OCI console, I navigated to Compute &amp;gt; Instances and clicked "Create instance".&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Configure Instance:&lt;/strong&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- **Name:** I gave it a name like `AdGuard-Cloud`.

- **Image and Shape:** I clicked "Edit". For the image, I selected Ubuntu. For the shape, I selected "Ampere" and chose the `VM.Standard.A1.Flex` shape (it's "Always Free-eligible").

- **Networking:** I used the default VCN and made sure "Assign a public IPv4 address" was checked.

- **SSH Keys:** I added my SSH public key.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;ol&gt;
&lt;li&gt;I clicked &lt;strong&gt;Create&lt;/strong&gt;. Once the instance was running, I took note of its &lt;strong&gt;Public IP Address&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Step 2: Configuring the Cloud Firewall&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;For maximum security, I locked down the administrative ports to only my home IP address.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Find My Public IP:&lt;/strong&gt; I went to a site like &lt;code&gt;whatismyip.com&lt;/code&gt; and copied my home's public IP address.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Edit Security List:&lt;/strong&gt; I navigated to my instance's details page, clicked the subnet link, then clicked the "Security List" link.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;I clicked "Add Ingress Rules" and added the following rules:&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- **For SSH (Port 22):** I set the Source to my home's public IP, followed by `/32` (e.g., `203.0.113.55/32`). This is a critical security step.

- **For AdGuard Setup (Port 3000):** I also set the Source to my home's public IP with `/32`.

- **For AdGuard Web UI (Port 80/443):** I set the Source to my home's public IP with `/32` as well.

- **For Public DNS (Port 53, 853, etc.):** I set the Source to `0.0.0.0/0` (Anywhere) to allow all my devices to connect from any network.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;h4&gt;
  
  
  &lt;strong&gt;Step 3: Installing AdGuard Home &amp;amp; Configuring SSL&lt;/strong&gt;
&lt;/h4&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Connect via SSH:&lt;/strong&gt; I used the public IP and my SSH key to connect to the VM.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Run Install Script:&lt;/strong&gt; I chose to install AdGuard Home directly on the OS for this instance.&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-S&lt;/span&gt; &lt;span class="nt"&gt;-L&lt;/span&gt; https://raw.githubusercontent.com/AdguardTeam/AdGuardHome/master/scripts/install.sh | sh &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;


&lt;blockquote&gt;
&lt;p&gt;The script will give you a link, like &lt;code&gt;http://YOUR_INSTANCE_IP:3000&lt;/code&gt;. Open this in your browser. Follow the on-screen steps to create your admin username and password.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Get a Hostname:&lt;/strong&gt; I went to &lt;strong&gt;No-IP.com&lt;/strong&gt;, created a free hostname (e.g., &lt;code&gt;my-cloud-dns.ddns.net&lt;/code&gt;), and pointed it to my cloud VM's public IP.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Enable Encryption:&lt;/strong&gt; We'll use &lt;strong&gt;Let's Encrypt&lt;/strong&gt; and &lt;strong&gt;Certbot&lt;/strong&gt; to get a free SSL certificate, which lets us use secure &lt;code&gt;https://&lt;/code&gt; and encrypted DNS.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- **Install Certbot:** In your SSH session, run these commands:
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;```bash
sudo apt update
sudo apt install certbot -y
```
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- **Get the Certificate:** Run this command, replacing the email and domain with your own.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;```bash
# This command will temporarily stop any service on port 80, get the certificate, and then finish.
sudo certbot certonly --standalone --agree-tos --email YOUR_EMAIL@example.com -d your-no-ip-hostname.ddns.net
```
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;If it's successful, it will tell you where your certificate files are saved (usually in `/etc/letsencrypt/live/your-no-ip-hostname.ddns.net/`).

- **Configure AdGuard Home Encryption:**
  * Go to your AdGuard Home dashboard (**Settings -&amp;gt; Encryption settings**).
  * Check **"Enable encryption"**.
  * In the **"Server name"** field, enter your No-IP hostname.
  * Under **"Certificates"**, choose **"Set a certificates file path"**.
    * **Certificate path:** `/etc/letsencrypt/live/your-no-ip-hostname.ddns.net/fullchain.pem`
    * **Private key path:** `/etc/letsencrypt/live/your-no-ip-hostname.ddns.net/privkey.pem`
* Click **"Save configuration"**. The page will reload on a secure `https://` connection!
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;h4&gt;
  
  
  &lt;strong&gt;Step 4: Automating SSL Renewal (Cron Job)&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;Let's Encrypt certificates last for 90 days. We can tell our server to automatically renew them.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Open Firewall (Port 80):&lt;/strong&gt; Certbot &lt;em&gt;requires&lt;/em&gt; &lt;strong&gt;port 80&lt;/strong&gt; for its renewal challenge. We must add this &lt;code&gt;ufw&lt;/code&gt; rule on our server, or the renewal will fail.&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow 80/tcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Open the Cron Editor:&lt;/strong&gt; In SSH, run &lt;code&gt;sudo crontab -e&lt;/code&gt; and choose &lt;code&gt;nano&lt;/code&gt; as your editor.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Add the Renewal Job:&lt;/strong&gt; Add this line to the bottom of the file. It tells the server to try renewing the certificate every day at 2:30 AM.&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;30 2 * * * certbot renew --quiet --pre-hook "systemctl stop AdGuardHome.service" --post-hook "systemctl start AdGuardHome.service"
&lt;/code&gt;&lt;/pre&gt;


&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; The &lt;code&gt;--post-hook&lt;/code&gt; is critical. It &lt;em&gt;guarantees&lt;/em&gt; AdGuard Home restarts even if the renewal fails, which prevents a service outage.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Save and exit&lt;/strong&gt; (&lt;code&gt;Ctrl+X&lt;/code&gt;, then &lt;code&gt;Y&lt;/code&gt;, then &lt;code&gt;Enter&lt;/code&gt;). Your server will now keep its certificate fresh forever!&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;h4&gt;
  
  
  &lt;strong&gt;Step 5: Creating a Cloud Backup (Snapshot)&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;A critical final step for any cloud service is creating a backup. Here is how I did it in OCI:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;In the OCI Console, I navigated to the details page for my &lt;code&gt;AdGuard-Cloud&lt;/code&gt; instance.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Under the "Resources" menu on the left, I clicked on &lt;strong&gt;"Boot volume"&lt;/strong&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;On the Boot Volume details page, under "Resources," I clicked &lt;strong&gt;"Boot volume backups"&lt;/strong&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;I clicked the &lt;strong&gt;"Create boot volume backup"&lt;/strong&gt; button.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;I gave the backup a descriptive name (e.g., &lt;code&gt;AdGuard-Cloud-Backup-YYYY-MM-DD&lt;/code&gt;) and clicked the create button. This creates a full snapshot of my server that I can use to restore it in minutes.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;h4&gt;
  
  
  &lt;strong&gt;Step 6: How to Use Your Cloud DNS on Mobile Devices&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;The main benefit of the cloud server is having ad-blocking on the go. Here’s how I set it up on my mobile phone using secure, encrypted DNS.&lt;/p&gt;
&lt;h5&gt;
  
  
  &lt;strong&gt;For Android (Version 9+):&lt;/strong&gt;
&lt;/h5&gt;

&lt;p&gt;Modern Android has a built-in feature called "Private DNS" that uses DNS-over-TLS (DoT), which is perfect for this.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Open &lt;strong&gt;Settings&lt;/strong&gt; on your Android device.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Tap on &lt;strong&gt;"Network &amp;amp; internet"&lt;/strong&gt; (this may be called "Connections" on some devices).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Find and tap on &lt;strong&gt;"Private DNS"&lt;/strong&gt;. You may need to look under an "Advanced" section.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Select the option labeled &lt;strong&gt;"Private DNS provider hostname"&lt;/strong&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;In the text box, enter the &lt;strong&gt;No-IP hostname&lt;/strong&gt; you created for your Oracle Cloud server (e.g., &lt;code&gt;my-cloud-dns.ddns.net&lt;/code&gt;).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Tap &lt;strong&gt;Save&lt;/strong&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Your phone will now send all its DNS queries through an encrypted tunnel to your personal AdGuard Home server in the cloud, giving you ad-blocking on both Wi-Fi and cellular data.&lt;/p&gt;
&lt;h5&gt;
  
  
  &lt;strong&gt;For iOS (iPhone/iPad):&lt;/strong&gt;
&lt;/h5&gt;

&lt;p&gt;On iOS, the easiest way to set up encrypted DNS is by installing a configuration profile.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;On your iPhone or iPad, open Safari.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Go to a DNS profile generator site, like the one provided by AdGuard.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;When prompted, enter the &lt;strong&gt;DNS-over-HTTPS (DoH)&lt;/strong&gt; address for your cloud server. It will be your No-IP hostname with &lt;code&gt;/dns-query&lt;/code&gt; at the end (e.g., &lt;code&gt;https://my-cloud-dns.ddns.net/dns-query&lt;/code&gt;).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Download the generated configuration profile.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Go to your device's &lt;strong&gt;Settings&lt;/strong&gt; app. You will see a new &lt;strong&gt;"Profile Downloaded"&lt;/strong&gt; item near the top. Tap on it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Follow the on-screen prompts to &lt;strong&gt;Install&lt;/strong&gt; the profile. You may need to enter your device passcode.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Once installed, your iOS device will also route its DNS traffic through your secure cloud server.&lt;/p&gt;


&lt;h3&gt;
  
  
  Chapter 3: Ultimate Local Redundancy (Tertiary DNS with Macvlan)
&lt;/h3&gt;

&lt;p&gt;For an extra layer of redundancy &lt;em&gt;within&lt;/em&gt; my homelab, I created a third AdGuard instance. By using an advanced Docker network mode called &lt;strong&gt;macvlan&lt;/strong&gt;, this container gets its own unique IP address on my home network, making it a truly independent resolver.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Create Macvlan Network:&lt;/strong&gt; First, I created the macvlan network, telling it which of my physical network cards to use (&lt;code&gt;eth0&lt;/code&gt; in my case).&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker network create &lt;span class="nt"&gt;-d&lt;/span&gt; macvlan &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--subnet&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;192.168.1.0/24 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--gateway&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;192.168.1.1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;parent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;eth0 homelab_net
&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Deploy Tertiary Instance:&lt;/strong&gt; I created a new folder (&lt;code&gt;~/docker/adguard-tertiary&lt;/code&gt;) and this &lt;code&gt;docker-compose.yml&lt;/code&gt;. Notice there are no &lt;code&gt;ports&lt;/code&gt; since the container gets its own IP.&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;adguardhome2&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;adguard/adguardhome:latest&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;adguardhome2&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;./work:/opt/adguardhome/work"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;./conf:/opt/adguardhome/conf"&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;homelab_net&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;ipv4_address&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;192.168.1.11&lt;/span&gt; &lt;span class="c1"&gt;# The new, unique IP for this container&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;

&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;homelab_net&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;external&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Configure Router for Local Failover:&lt;/strong&gt; To complete the local redundancy, I went back into my router's DHCP settings.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- In the **Primary DNS** field, I have the IP of my main homelab server (e.g., `192.168.1.10`).

- In the **Secondary DNS** field, I entered the unique IP address I assigned to my macvlan container (e.g., `192.168.1.11`).


Now, if my primary AdGuard container has an issue, all devices on my network will automatically fail over to the tertiary instance.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;




&lt;h3&gt;
  
  
  Chapter 4: Fine-Tuning and Integration
&lt;/h3&gt;

&lt;p&gt;Finally, I implemented some best practices on my primary AdGuard Home instance.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Upstream DNS Servers:&lt;/strong&gt; Under &lt;strong&gt;Settings &amp;gt; DNS Settings&lt;/strong&gt;, I configured AdGuard to send requests to multiple resolvers in parallel for speed and reliability, using &lt;strong&gt;Cloudflare (&lt;code&gt;1.1.1.1&lt;/code&gt;)&lt;/strong&gt;, &lt;strong&gt;Google (&lt;code&gt;8.8.8.8&lt;/code&gt;)&lt;/strong&gt;, and &lt;strong&gt;Quad9 (&lt;code&gt;9.9.9.9&lt;/code&gt;)&lt;/strong&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Enable DNSSEC:&lt;/strong&gt; In the same settings page, I enabled DNSSEC to verify the integrity of DNS responses.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;DNS Blocklists:&lt;/strong&gt; I added several popular lists from the "Filters &amp;gt; DNS blocklists" page, including the &lt;strong&gt;AdGuard DNS filter&lt;/strong&gt; and the &lt;strong&gt;OISD Blocklist&lt;/strong&gt;, for robust protection.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;DNS Rewrites for Local Services:&lt;/strong&gt; This is the key to a clean homelab experience. For each service, I performed a detailed two-step process:&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. &lt;strong&gt;Create the Proxy Host in Nginx Proxy Manager:&lt;/strong&gt; I logged into my NPM admin panel, went to &lt;strong&gt;Hosts &amp;gt; Proxy Hosts&lt;/strong&gt;, and clicked "Add Proxy Host". For my Homer dashboard, I set the &lt;strong&gt;Forward Hostname&lt;/strong&gt; to &lt;code&gt;homer&lt;/code&gt; (the container name) and the &lt;strong&gt;Forward Port&lt;/strong&gt; to &lt;code&gt;8080&lt;/code&gt; (its internal port), using &lt;code&gt;homer.local&lt;/code&gt; as the domain name.

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Create the DNS Rewrite in AdGuard Home:&lt;/strong&gt; I logged into my primary AdGuard dashboard, went to &lt;strong&gt;Filters &amp;gt; DNS Rewrites&lt;/strong&gt;, and clicked "Add DNS rewrite". I entered &lt;code&gt;homer.local&lt;/code&gt; as the domain and the IP address of my Nginx Proxy Manager server as the answer.
&lt;/li&gt;
&lt;/ol&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;h3&gt;


Conclusion
&lt;/h3&gt;


&lt;p&gt;I've now built an incredibly robust, multi-layered DNS infrastructure. My home devices use the primary local server, which is backed up by a second, independent local server, and my mobile devices use a completely separate cloud instance for on-the-go protection. This provides a resilient, secure, and ad-free internet experience.&lt;/p&gt;

&lt;p&gt;In the final part of this series, we'll shift our focus from deploying services to maintaining them. I'll show you how I set up a fully automated operations pipeline for my homelab, including daily off-site backups, automatic container updates with Watchtower, and proactive alerting with Prometheus. Stay tuned!&lt;/p&gt;

</description>
      <category>linux</category>
      <category>oracle</category>
      <category>cloud</category>
      <category>adguard</category>
    </item>
    <item>
      <title>Part 2: Homelab Management &amp; Monitoring</title>
      <dc:creator>Prajwol Adhikari</dc:creator>
      <pubDate>Sun, 10 May 2026 00:42:07 +0000</pubDate>
      <link>https://forem.com/prajwol-ad/part-2-homelab-management-monitoring-4oea</link>
      <guid>https://forem.com/prajwol-ad/part-2-homelab-management-monitoring-4oea</guid>
      <description>&lt;h3&gt;
  
  
  Introduction
&lt;/h3&gt;

&lt;p&gt;Welcome to Part 2 of my homelab series! In &lt;a href="https://dev.to/prajwol-ad/part-1-reviving-an-old-laptop-with-debian-docker-3fhp"&gt;Part 1&lt;/a&gt;, we built a solid foundation by turning an old laptop into a hardened Debian server with Docker. Now that our server is running, we need to deploy services to manage, monitor, and easily access our projects.&lt;/p&gt;

&lt;p&gt;In this guide, we'll deploy three essential stacks. First, &lt;strong&gt;Nginx Proxy Manager (NPM)&lt;/strong&gt; will act as our server's front door and create a shared network for our containers. Second, we'll set up a professional-grade monitoring stack with &lt;strong&gt;Prometheus&lt;/strong&gt; and &lt;strong&gt;Grafana&lt;/strong&gt;. Finally, we'll deploy a &lt;strong&gt;Homer&lt;/strong&gt; dashboard to create a beautiful and convenient launchpad for all our services.&lt;/p&gt;




&lt;h3&gt;
  
  
  1. The Management Layer: Nginx Proxy Manager (NPM) 🌐
&lt;/h3&gt;

&lt;p&gt;Before we can deploy our other services, we need a way to manage connections between them. NPM will act as our reverse proxy and, crucially, will create the shared Docker network that all our other services will connect to.&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;A. Deploy Nginx Proxy Manager&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;First, let's create a directory and the &lt;code&gt;docker-compose.yml&lt;/code&gt; file for NPM.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Create the directory&lt;/span&gt;
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/docker/npm
&lt;span class="nb"&gt;cd&lt;/span&gt; ~/docker/npm

&lt;span class="c"&gt;# Create the docker-compose.yml&lt;/span&gt;
nano docker-compose.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
`&lt;/p&gt;

&lt;p&gt;Paste in the following configuration. This file defines the NPM service and creates a network named &lt;code&gt;npm_default&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;&lt;br&gt;
services:&lt;br&gt;
  app:&lt;br&gt;
    image: 'jc21/nginx-proxy-manager:latest'&lt;br&gt;
    container_name: npm-app-1&lt;br&gt;
    restart: unless-stopped&lt;br&gt;
    ports:&lt;br&gt;
      - '80:80'&lt;br&gt;
      - '443:443'&lt;br&gt;
      - '81:81'&lt;br&gt;
    volumes:&lt;br&gt;
      - ./data:/data&lt;br&gt;
      - ./letsencrypt:/etc/letsencrypt&lt;br&gt;
networks:&lt;br&gt;
  default:&lt;br&gt;
    name: npm_default&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;plaintext&lt;/p&gt;

&lt;p&gt;Launch it with &lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;&lt;br&gt;
docker compose up -d&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;yaml&lt;br&gt;
You can now log in to the admin UI at &lt;code&gt;http://&amp;lt;your-server-ip&amp;gt;:81&lt;/code&gt;.&lt;/p&gt;




&lt;h3&gt;
  
  
  2. The Monitoring Stack 📊
&lt;/h3&gt;

&lt;p&gt;With our shared network in place, we can now deploy our monitoring stack.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Prometheus:&lt;/strong&gt; Collects all the metrics.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Node Exporter:&lt;/strong&gt; Exposes the server's hardware metrics.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;cAdvisor:&lt;/strong&gt; Exposes Docker container metrics.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Grafana:&lt;/strong&gt; Visualizes all the data in beautiful dashboards.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;A. Create the Prometheus Configuration&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;Prometheus needs a config file to know what to monitor.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`&lt;/p&gt;

&lt;h1&gt;
  
  
  Create the project directory
&lt;/h1&gt;

&lt;p&gt;mkdir -p ~/docker/monitoring&lt;br&gt;
cd ~/docker/monitoring&lt;/p&gt;

&lt;h1&gt;
  
  
  Create the prometheus.yml file
&lt;/h1&gt;

&lt;p&gt;nano prometheus.yml&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;plaintext&lt;/p&gt;

&lt;p&gt;Paste in the following configuration:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`&lt;br&gt;
global:&lt;br&gt;
  scrape_interval: 15s&lt;/p&gt;

&lt;p&gt;scrape_configs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;job_name: 'prometheus'
static_configs:

&lt;ul&gt;
&lt;li&gt;targets: ['localhost:9090']&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;job_name: 'node-exporter'
static_configs:

&lt;ul&gt;
&lt;li&gt;targets: ['node-exporter:9100']&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;job_name: 'cadvisor'
static_configs:

&lt;ul&gt;
&lt;li&gt;targets: ['cadvisor:8080']
`&lt;code&gt;&lt;/code&gt;yaml&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;/ul&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;B. Deploy the Stack with Docker Compose&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;Next, create the &lt;code&gt;docker-compose.yml&lt;/code&gt; file in the same &lt;code&gt;~/docker/monitoring&lt;/code&gt; directory.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;&lt;br&gt;
nano docker-compose.yml&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;plaintext&lt;/p&gt;

&lt;p&gt;This file defines all four monitoring services and tells them to connect to the &lt;code&gt;npm_default&lt;/code&gt; network we created earlier.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`&lt;br&gt;
services:&lt;br&gt;
  prometheus:&lt;br&gt;
    image: prom/prometheus:latest&lt;br&gt;
    container_name: prometheus&lt;br&gt;
    restart: unless-stopped&lt;br&gt;
    ports:&lt;br&gt;
      - "9090:9090"&lt;br&gt;
    volumes:&lt;br&gt;
      - ./prometheus.yml:/etc/prometheus/prometheus.yml&lt;br&gt;
      - prometheus_data:/prometheus&lt;br&gt;
    networks:&lt;br&gt;
      - default&lt;/p&gt;

&lt;p&gt;grafana:&lt;br&gt;
    image: grafana/grafana:latest&lt;br&gt;
    container_name: grafana&lt;br&gt;
    restart: unless-stopped&lt;br&gt;
    ports:&lt;br&gt;
      - "3001:3000"&lt;br&gt;
    volumes:&lt;br&gt;
      - grafana_data:/var/lib/grafana&lt;br&gt;
    networks:&lt;br&gt;
      - default&lt;/p&gt;

&lt;p&gt;node-exporter:&lt;br&gt;
    image: prom/node-exporter:latest&lt;br&gt;
    container_name: node-exporter&lt;br&gt;
    restart: unless-stopped&lt;br&gt;
    volumes:&lt;br&gt;
      - /proc:/host/proc:ro&lt;br&gt;
      - /sys:/host/sys:ro&lt;br&gt;
      - /:/rootfs:ro&lt;br&gt;
    command:&lt;br&gt;
      - '--path.procfs=/host/proc'&lt;br&gt;
      - '--path.sysfs=/host/sys'&lt;br&gt;
      - '--path.rootfs=/rootfs'&lt;br&gt;
      - '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'&lt;br&gt;
    networks:&lt;br&gt;
      - default&lt;/p&gt;

&lt;p&gt;cadvisor:&lt;br&gt;
    image: gcr.io/cadvisor/cadvisor:latest&lt;br&gt;
    container_name: cadvisor&lt;br&gt;
    restart: unless-stopped&lt;br&gt;
    ports:&lt;br&gt;
      - "8081:8080"&lt;br&gt;
    volumes:&lt;br&gt;
      - /:/rootfs:ro&lt;br&gt;
      - /var/run:/var/run:rw&lt;br&gt;
      - /sys:/sys:ro&lt;br&gt;
      - /var/lib/docker/:/var/lib/docker:ro&lt;br&gt;
    networks:&lt;br&gt;
      - default&lt;/p&gt;

&lt;p&gt;volumes:&lt;br&gt;
  prometheus_data:&lt;br&gt;
  grafana_data:&lt;/p&gt;

&lt;p&gt;networks:&lt;br&gt;
  default:&lt;br&gt;
    name: npm_default&lt;br&gt;
    external: true&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;shell&lt;/p&gt;

&lt;p&gt;Now, launch the stack:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;&lt;br&gt;
docker compose up -d&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;C. Configure Grafana&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;Log in to Grafana at &lt;code&gt;http://&amp;lt;your-server-ip&amp;gt;:3001&lt;/code&gt; (default: &lt;code&gt;admin&lt;/code&gt;/&lt;code&gt;admin&lt;/code&gt;).&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Add Data Source:&lt;/strong&gt; Go to Connections &amp;gt; Data Sources, add a &lt;strong&gt;Prometheus&lt;/strong&gt; source, and set the URL to &lt;code&gt;http://prometheus:9090&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Import Dashboards:&lt;/strong&gt; Go to Dashboards &amp;gt; New &amp;gt; Import and add these dashboards by ID:&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- **Node Exporter Full (ID: `1860`)**

- **Docker Host/Container Metrics (ID: `193`)**
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;




&lt;h3&gt;
  
  
  3. The Homer Launchpad Dashboard 🚀
&lt;/h3&gt;

&lt;p&gt;Finally, let's deploy Homer as our beautiful start page with custom icons.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Create Directories &amp;amp; Download Icons:&lt;/strong&gt; First, create a directory for Homer and an &lt;code&gt;assets&lt;/code&gt; subdirectory. Then, &lt;code&gt;cd&lt;/code&gt; into the &lt;code&gt;assets&lt;/code&gt; folder and download the icons.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;&lt;br&gt;
mkdir -p ~/docker/homer/assets&lt;br&gt;
cd ~/docker/homer/assets&lt;br&gt;
wget -O grafana.png [https://raw.githubusercontent.com/walkxcode/dashboard-icons/main/png/grafana.png](https://raw.githubusercontent.com/walkxcode/dashboard-icons/main/png/grafana.png)&lt;br&gt;
wget -O prometheus.png [https://raw.githubusercontent.com/walkxcode/dashboard-icons/main/png/prometheus.png](https://raw.githubusercontent.com/walkxcode/dashboard-icons/main/png/prometheus.png)&lt;br&gt;
wget -O cadvisor.png [https://raw.githubusercontent.com/walkxcode/dashboard-icons/main/png/cadvisor.png](https://raw.githubusercontent.com/walkxcode/dashboard-icons/main/png/cadvisor.png)&lt;br&gt;
wget -O npm.png [https://nginxproxymanager.com/icon.png](https://nginxproxymanager.com/icon.png)&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Create Configuration:&lt;/strong&gt; Go back to the main &lt;code&gt;homer&lt;/code&gt; directory and create the &lt;code&gt;config.yml&lt;/code&gt; file.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;&lt;br&gt;
cd ~/docker/homer&lt;br&gt;
nano config.yml&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Paste in the following configuration. The &lt;code&gt;logo:&lt;/code&gt; lines point to the icons we just downloaded.&lt;/p&gt;
&lt;h2&gt;
  
  
  &lt;code&gt;&lt;/code&gt;`
&lt;/h2&gt;

&lt;p&gt;title: "Homelab Dashboard"&lt;br&gt;
subtitle: "Server Management"&lt;br&gt;
theme: "dark"&lt;/p&gt;

&lt;p&gt;services:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;name: "Management"
icon: "fas fa-server"
items:

&lt;ul&gt;
&lt;li&gt;name: "Nginx Proxy Manager"
logo: "assets/tools/npm.png"
subtitle: "Reverse Proxy Admin"
url: "http://:81"&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  - name: "Monitoring"
    icon: "fas fa-chart-bar"
    items:
      - name: "Grafana"
        logo: "assets/tools/grafana.png"
        subtitle: "Metrics Dashboard"
        url: "http://&amp;lt;your-server-ip&amp;gt;:3001"
      - name: "Prometheus"
        logo: "assets/tools/prometheus.png"
        subtitle: "Metrics Database"
        url: "http://&amp;lt;your-server-ip&amp;gt;:9090"
      - name: "cAdvisor"
        logo: "assets/tools/cadvisor.png"
        subtitle: "Container Metrics"
        url: "http://&amp;lt;your-server-ip&amp;gt;:8081"
```
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Create Docker Compose File:&lt;/strong&gt; Finally, create the &lt;code&gt;docker-compose.yml&lt;/code&gt; file.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;&lt;br&gt;
nano docker-compose.yml&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;This configuration connects Homer to our shared network.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`&lt;br&gt;
services:&lt;br&gt;
  homer:&lt;br&gt;
    image: b4bz/homer&lt;br&gt;
    container_name: homer&lt;br&gt;
    volumes:&lt;br&gt;
      - ./config.yml:/www/assets/config.yml&lt;br&gt;
      - ./assets:/www/assets/tools&lt;br&gt;
    ports:&lt;br&gt;
      - "8090:8080"&lt;br&gt;
    restart: unless-stopped&lt;br&gt;
    networks:&lt;br&gt;
      - npm_default&lt;/p&gt;

&lt;p&gt;networks:&lt;br&gt;
  npm_default:&lt;br&gt;
    external: true&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Launch:&lt;/strong&gt; Run &lt;code&gt;docker compose up -d&lt;/code&gt;. You can now access your new dashboard with custom icons at &lt;code&gt;http://&amp;lt;your-server-ip&amp;gt;:8090&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Conclusion
&lt;/h3&gt;

&lt;p&gt;Our homelab now has a powerful management and monitoring foundation. Nginx Proxy Manager is ready to direct traffic, Grafana is visualizing our server's health, and Homer provides a central launchpad.&lt;/p&gt;

&lt;p&gt;In the next part of the series, we'll deploy our core network service, &lt;strong&gt;AdGuard Home&lt;/strong&gt;, and use NPM to create clean, memorable local domains for all the applications we set up today. Stay tuned!&lt;/p&gt;

</description>
      <category>docker</category>
      <category>nginx</category>
      <category>linux</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>Part 1: Reviving an Old Laptop with Debian &amp; Docker</title>
      <dc:creator>Prajwol Adhikari</dc:creator>
      <pubDate>Sun, 10 May 2026 00:37:57 +0000</pubDate>
      <link>https://forem.com/prajwol-ad/part-1-reviving-an-old-laptop-with-debian-docker-3fhp</link>
      <guid>https://forem.com/prajwol-ad/part-1-reviving-an-old-laptop-with-debian-docker-3fhp</guid>
      <description>&lt;h3&gt;
  
  
  Introduction
&lt;/h3&gt;

&lt;p&gt;Welcome to the first post in my new homelab series! I've always been fascinated by self-hosting and DevOps, and I believe the best way to learn is by doing. In this series, I'll document my journey of turning an old, unused laptop into a powerful, efficient, and secure bare-metal server for hosting a variety of network services.&lt;/p&gt;

&lt;p&gt;The goal for this first part is to lay a solid foundation. We'll take an old laptop, install a minimal and stable Linux operating system, perform some initial security hardening, and set up Docker as our containerization engine. By the end of this post, we'll have a perfect blank canvas ready for the exciting services we'll deploy in the upcoming parts.&lt;/p&gt;




&lt;h3&gt;
  
  
  1. Choosing the Hardware &amp;amp; OS
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Why an Old Laptop?
&lt;/h4&gt;

&lt;p&gt;Before diving in, why use an old laptop instead of a Raspberry Pi or a dedicated server? For a starter homelab, a laptop has three huge advantages:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cost-Effective:&lt;/strong&gt; It's free if you have one lying around!&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Built-in UPS:&lt;/strong&gt; The battery acts as a built-in Uninterruptible Power Supply (UPS), keeping the server running through short power outages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Low Power Consumption:&lt;/strong&gt; Laptop hardware is designed to be power-efficient, which is great for a device that will be running 24/7.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  Why Debian 13 "Trixie"?
&lt;/h4&gt;

&lt;p&gt;For the operating system, I chose Debian. It's renowned for its stability, security, and massive package repository. It’s the bedrock of many other distributions (like Ubuntu) and is perfect for a server because it's lightweight and doesn't include unnecessary software. We'll be using the minimal "net-install" to ensure we only install what we absolutely need.&lt;/p&gt;




&lt;h3&gt;
  
  
  2. Installation and Network Configuration
&lt;/h3&gt;

&lt;p&gt;The installation process is straightforward, but the network setup is key to a reliable server.&lt;/p&gt;

&lt;h4&gt;
  
  
  Minimal Installation
&lt;/h4&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Create a Bootable USB:&lt;/strong&gt; I downloaded the Debian 13 "netinst" ISO from the official website and used Rufus on Windows to create a bootable USB drive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Boot from USB:&lt;/strong&gt; I plugged the USB into the laptop and booted from it (usually pressing &lt;strong&gt;F12&lt;/strong&gt;, &lt;strong&gt;F2&lt;/strong&gt;, or &lt;strong&gt;Esc&lt;/strong&gt; during startup to select the USB device).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Language, Location, and Keyboard:&lt;/strong&gt; Selected English, United States, and the default keyboard layout.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Network Setup:&lt;/strong&gt; Connected the laptop to my home network (Ethernet preferred for stability).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hostname &amp;amp; Domain:&lt;/strong&gt; Entered a short, memorable hostname for the server (e.g., &lt;code&gt;homelab&lt;/code&gt;) and left the domain blank.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;User Accounts:&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;Set a &lt;strong&gt;root password&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Created a non-root &lt;strong&gt;regular user&lt;/strong&gt; (this will be used for daily management).&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Partition Disks:&lt;/strong&gt; Chose &lt;strong&gt;Guided – use entire disk&lt;/strong&gt; with &lt;strong&gt;separate /home partition&lt;/strong&gt;. This is simpler for a server setup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Software Selection:&lt;/strong&gt; At the “Software selection” screen:

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Unchecked&lt;/strong&gt; “Debian desktop environment”&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Checked&lt;/strong&gt; “SSH server” and “standard system utilities”&lt;/li&gt;
&lt;li&gt;This ensures a clean command-line system that can be accessed remotely.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GRUB Bootloader:&lt;/strong&gt; Installed GRUB on the primary drive (so the system boots correctly).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Finish Installation:&lt;/strong&gt; Removed the USB drive when prompted and rebooted into the fresh Debian install.&lt;/li&gt;
&lt;/ol&gt;

&lt;h4&gt;
  
  
  Setting a Static IP
&lt;/h4&gt;

&lt;p&gt;A server needs a permanent, unchanging IP address. The best way to do this is with &lt;strong&gt;DHCP Reservation&lt;/strong&gt; on your router. This tells your router to always assign the same IP address to your server's unique MAC address.&lt;/p&gt;

&lt;p&gt;First, find your laptop’s current IP address and network interface name by running:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;You’ll see output similar to:&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;2: enp3s0: &amp;lt;BROADCAST,MULTICAST,UP,LOWER_UP&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;mtu 1500 qdisc fq_codel state UP group default qlen 1000
&lt;span class="go"&gt;    inet 192.168.0.45/24 brd 192.168.0.255 scope global dynamic enp3s0
       valid_lft 86396sec preferred_lft 86396sec
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In this example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Interface name:&lt;/strong&gt; &lt;code&gt;enp3s0&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Current IP:&lt;/strong&gt; &lt;code&gt;192.168.0.45&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MAC address:&lt;/strong&gt; shown under &lt;code&gt;link/ether&lt;/code&gt; in the same section.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With this info, log into your router’s admin panel, find the "DHCP Reservation" or "Static Leases" section, and assign a memorable IP address (e.g., &lt;code&gt;192.168.0.45&lt;/code&gt;) to your server’s MAC address.&lt;/p&gt;

&lt;p&gt;This ensures the server always gets the same IP from your router, making it easy to find on your network.&lt;/p&gt;

&lt;h4&gt;
  
  
  Connecting Remotely with SSH
&lt;/h4&gt;

&lt;p&gt;With a static IP set, all future management will be done remotely using an SSH client. For Windows, I highly recommend &lt;strong&gt;Solar-PuTTY&lt;/strong&gt;. I created a new session, entered the server's static IP address, my username, and password, and connected.&lt;/p&gt;




&lt;h3&gt;
  
  
  3. Initial Server Hardening
&lt;/h3&gt;

&lt;p&gt;With a remote SSH session active, the first thing to do is secure the server and configure it for its headless role.&lt;/p&gt;

&lt;h4&gt;
  
  
  Update the System
&lt;/h4&gt;

&lt;p&gt;First, let's make sure all packages are up to date.&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="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;apt upgrade &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Configure the Firewall
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;ufw&lt;/code&gt; (Uncomplicated Firewall) is perfect for a simple setup. We'll set it to deny all incoming traffic by default and only allow SSH connections.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Install UFW&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;ufw &lt;span class="nt"&gt;-y&lt;/span&gt;

&lt;span class="c"&gt;# Allow SSH connections&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow ssh

&lt;span class="c"&gt;# Enable the firewall&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw &lt;span class="nb"&gt;enable&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Configure Lid-Close Action
&lt;/h4&gt;

&lt;p&gt;To ensure the laptop keeps running when the lid is closed, we edit the &lt;code&gt;logind.conf&lt;/code&gt; file.&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;nano /etc/systemd/logind.conf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Uncomment the line:&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="nt"&gt;HandleLidSwitch&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;ignore
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Save the file, then restart the service:&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 systemd-logind.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  4. Installing the Containerization Engine: Docker
&lt;/h3&gt;

&lt;p&gt;Instead of installing applications directly on our host, we'll use Docker to keep the system clean and make management easier.&lt;/p&gt;

&lt;h4&gt;
  
  
  Install Docker Engine
&lt;/h4&gt;

&lt;p&gt;The official convenience script is the easiest way to get the latest version.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://get.docker.com &lt;span class="nt"&gt;-o&lt;/span&gt; get-docker.sh
&lt;span class="nb"&gt;sudo &lt;/span&gt;sh get-docker.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Add User to Docker Group
&lt;/h4&gt;

&lt;p&gt;To run docker commands without &lt;code&gt;sudo&lt;/code&gt;, add your user to the docker group. The &lt;code&gt;$USER&lt;/code&gt; variable automatically uses the currently logged-in user.&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;usermod &lt;span class="nt"&gt;-aG&lt;/span&gt; docker &lt;span class="nv"&gt;$USER&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After this, log out and log back in for the change to take effect.&lt;/p&gt;

&lt;h4&gt;
  
  
  Install Docker Compose
&lt;/h4&gt;

&lt;p&gt;Docker Compose is essential for managing multi-container applications with a simple YAML file.&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;docker-compose-plugin &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To verify the installation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose version
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  Conclusion
&lt;/h3&gt;

&lt;p&gt;And that's it for Part 1! We've successfully turned an old piece of hardware into a hardened, modern server running Debian and Docker with a reliable network configuration. We have a solid and secure foundation to build upon.&lt;/p&gt;

&lt;p&gt;In the next part of the series, we'll deploy our first critical service: a local, network-wide ad-blocking DNS resolver using AdGuard Home. Stay tuned!&lt;/p&gt;

</description>
      <category>debian</category>
      <category>docker</category>
      <category>linux</category>
      <category>selfhosting</category>
    </item>
    <item>
      <title>Your Personal Internet Guardian: How to Build a FREE Ad-Blocker in the Cloud! 🚀</title>
      <dc:creator>Prajwol Adhikari</dc:creator>
      <pubDate>Tue, 26 Aug 2025 04:21:23 +0000</pubDate>
      <link>https://forem.com/prajwol-ad/your-personal-internet-guardian-how-to-build-a-free-ad-blocker-in-the-cloud-43jb</link>
      <guid>https://forem.com/prajwol-ad/your-personal-internet-guardian-how-to-build-a-free-ad-blocker-in-the-cloud-43jb</guid>
      <description>&lt;p&gt;Hey everyone! A while back, I wrote a guide on setting up AdGuard Home on Linode. The world of tech moves fast, and it's time for an upgrade! Today, we're going to build our own powerful, network-wide ad-blocker using &lt;strong&gt;Amazon Web Services (AWS)&lt;/strong&gt;, and we'll make it secure with our own domain and SSL certificate.&lt;/p&gt;

&lt;p&gt;Think of this as building a digital gatekeeper for your internet. Before any ads, trackers, or malicious sites can reach your devices, our AdGuard Home server will slam the door shut. The best part? This works on your phone, laptop, smart TV—anything on your network—without installing a single app on them.&lt;/p&gt;

&lt;p&gt;This guide is for everyone, from seasoned tech wizards to curious beginners. We'll break down every step in simple terms, so grab a coffee, and let's build something awesome!&lt;/p&gt;




&lt;h3&gt;
  
  
  ## Chapter 1: Building Our Home in the AWS Cloud ☁️
&lt;/h3&gt;

&lt;p&gt;First, we need a server. We'll use an &lt;strong&gt;Amazon EC2 instance&lt;/strong&gt;, which is just a fancy name for a virtual computer that you rent.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Sign Up for AWS:&lt;/strong&gt; If you don't have an account, head to the &lt;a href="https://aws.amazon.com/" rel="noopener noreferrer"&gt;AWS website&lt;/a&gt; and sign up. You'll need a credit card for verification, but for this guide, we can often stay within the Free Tier.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Launch Your EC2 Instance:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Log in to your AWS Console and search for &lt;strong&gt;EC2&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;"Launch instance"&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Name:&lt;/strong&gt; Give your server a cool name, like &lt;code&gt;AdGuard-Server&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Application and OS Images:&lt;/strong&gt; In the search bar, type &lt;code&gt;Debian&lt;/code&gt; and select the latest version (e.g., Debian 12). Make sure it's marked "Free tier eligible".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Instance Type:&lt;/strong&gt; Choose &lt;strong&gt;&lt;code&gt;t2.micro&lt;/code&gt;&lt;/strong&gt;. This is your free, trusty little server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Key Pair (for login):&lt;/strong&gt; This is your digital key to the server's front door. Click &lt;strong&gt;"Create a new key pair"&lt;/strong&gt;, name it something like &lt;code&gt;my-adguard-key&lt;/code&gt;, and &lt;strong&gt;download the &lt;code&gt;.pem&lt;/code&gt; file&lt;/strong&gt;. Keep this file secret and safe!&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Network settings (The Firewall):&lt;/strong&gt; This is crucial. We need to tell our server which doors to open. Click &lt;strong&gt;"Edit"&lt;/strong&gt;.

&lt;ul&gt;
&lt;li&gt;Check the box for &lt;strong&gt;"Allow SSH traffic from"&lt;/strong&gt; and select &lt;strong&gt;&lt;code&gt;My IP&lt;/code&gt;&lt;/strong&gt;. This lets you securely log in.&lt;/li&gt;
&lt;li&gt;Check &lt;strong&gt;"Allow HTTPS traffic from the internet"&lt;/strong&gt; and &lt;strong&gt;"Allow HTTP traffic from the internet"&lt;/strong&gt;. We'll need these for our secure dashboard later.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Launch It!&lt;/strong&gt; Hit the "Launch instance" button and watch as your new cloud server comes to life.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Give Your Server a Permanent Address (Elastic IP):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;By default, your server's public IP address will change every time it reboots. Let's make it permanent!&lt;/li&gt;
&lt;li&gt;In the EC2 menu on the left, go to &lt;strong&gt;"Elastic IPs"&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;"Allocate Elastic IP address"&lt;/strong&gt; and then &lt;strong&gt;"Allocate"&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Select the new IP address from the list, click &lt;strong&gt;"Actions"&lt;/strong&gt;, and then &lt;strong&gt;"Associate Elastic IP address"&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Choose your &lt;code&gt;AdGuard-Server&lt;/code&gt; instance from the list and click &lt;strong&gt;"Associate"&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Your server now has a static IP address that will never change! Make a note of this new IP.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;




&lt;h3&gt;
  
  
  ## Chapter 2: Opening the Doors (Configuring the Firewall) 🚪
&lt;/h3&gt;

&lt;p&gt;Our server is running, but we need to open a few more specific doors for AdGuard Home to work.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; Go to your EC2 Instance details, click the &lt;strong&gt;"Security"&lt;/strong&gt; tab, and click on the &lt;strong&gt;Security Group name&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt; Click &lt;strong&gt;"Edit inbound rules"&lt;/strong&gt; and &lt;strong&gt;"Add rule"&lt;/strong&gt; for each of the following:

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Port &lt;code&gt;3000&lt;/code&gt;:&lt;/strong&gt; &lt;code&gt;Custom TCP&lt;/code&gt;, Port &lt;code&gt;3000&lt;/code&gt;, Source &lt;code&gt;My IP&lt;/code&gt;. (For the initial setup).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Port &lt;code&gt;53&lt;/code&gt; (TCP):&lt;/strong&gt; &lt;code&gt;Custom TCP&lt;/code&gt;, Port &lt;code&gt;53&lt;/code&gt;, Source &lt;code&gt;Anywhere-IPv4&lt;/code&gt;. (For DNS).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Port &lt;code&gt;53&lt;/code&gt; (UDP):&lt;/strong&gt; &lt;code&gt;Custom UDP&lt;/code&gt;, Port &lt;code&gt;53&lt;/code&gt;, Source &lt;code&gt;Anywhere-IPv4&lt;/code&gt;. (Also for DNS).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Port &lt;code&gt;853&lt;/code&gt;:&lt;/strong&gt; &lt;code&gt;Custom TCP&lt;/code&gt;, Port &lt;code&gt;853&lt;/code&gt;, Source &lt;code&gt;Anywhere-IPv4&lt;/code&gt;. (For DNS-over-TLS).&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt; Click &lt;strong&gt;"Save rules"&lt;/strong&gt;. Your firewall is now ready!&lt;/li&gt;
&lt;/ol&gt;




&lt;h3&gt;
  
  
  ## Chapter 3: Installing AdGuard Home 🛡️
&lt;/h3&gt;

&lt;p&gt;Now, let's connect to our server and install the magic software.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Connect via SSH:&lt;/strong&gt; Open a terminal (PowerShell on Windows, Terminal on Mac/Linux) and use the key you downloaded to connect. &lt;strong&gt;Use your new Elastic IP address!&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Replace the path and Elastic IP with your own&lt;/span&gt;
ssh &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"path/to/my-adguard-key.pem"&lt;/span&gt; admin@YOUR_ELASTIC_IP
&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Install AdGuard Home:&lt;/strong&gt; Run this one simple command. It downloads and installs everything for you.&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-S&lt;/span&gt; &lt;span class="nt"&gt;-L&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt;https://raw.githubusercontent.com/AdguardTeam/AdGuardHome/master/scripts/install.sh]&lt;span class="o"&gt;(&lt;/span&gt;https://raw.githubusercontent.com/AdguardTeam/AdGuardHome/master/scripts/install.sh&lt;span class="o"&gt;)&lt;/span&gt; | sh &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Run the Setup Wizard:&lt;/strong&gt; The script will give you a link, like &lt;code&gt;http://YOUR_ELASTIC_IP:3000&lt;/code&gt;. Open this in your browser. Follow the on-screen steps to create your admin username and password.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;h3&gt;
  
  
  ## Chapter 4: Teaching Your Guardian Who to Trust and What to Block
&lt;/h3&gt;

&lt;p&gt;With AdGuard Home installed, the next step is to configure its core brain: the DNS servers it gets its answers from and the blocklists it uses to protect your network.&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;1. Setting Up Upstream DNS Servers&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;Think of "Upstream DNS Servers" as the giant, public phonebooks of the internet. When your AdGuard server doesn't know an address (and it's not on a blocklist), it asks one of these upstreams. It's recommended to use a mix of the best encrypted DNS providers for security, privacy, and speed.&lt;/p&gt;

&lt;p&gt;In the AdGuard dashboard, go to &lt;strong&gt;Settings -&amp;gt; DNS settings&lt;/strong&gt;. In the &lt;strong&gt;"Upstream DNS servers"&lt;/strong&gt; box, enter the following, one per line:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dns.quad9.net/dns-query" rel="noopener noreferrer"&gt;https://dns.quad9.net/dns-query&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dns.google/dns-query" rel="noopener noreferrer"&gt;https://dns.google/dns-query&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://dns.cloudflare.com/dns-query" rel="noopener noreferrer"&gt;https://dns.cloudflare.com/dns-query&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Quad9:&lt;/strong&gt; Focuses heavily on security, blocking malicious domains.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Google:&lt;/strong&gt; Known for being very fast.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Cloudflare:&lt;/strong&gt; A great all-around choice with a strong focus on privacy.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;2. Optimizing DNS Performance&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;Still in the &lt;strong&gt;DNS settings&lt;/strong&gt; page, scroll down to optimize how your server queries the upstreams.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Parallel requests:&lt;/strong&gt; Select this option. This is the fastest and most resilient mode. It sends your DNS query to all three of your upstream servers at the same time and uses the answer from the very first one that responds. This ensures you always get the quickest possible result.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Enable EDNS client subnet (ECS):&lt;/strong&gt; Check this box. This is very important for services like Netflix, YouTube, and other content delivery networks (CDNs). It helps them give you content from a server that is geographically closest to you, resulting in faster speeds and a better experience.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;3. Enabling DNSSEC&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;Right below the upstream servers, there's a checkbox for &lt;strong&gt;"Enable DNSSEC"&lt;/strong&gt;. You should check this box. DNSSEC is like a digital wax seal on a letter; it verifies that the DNS answers you're getting are authentic and haven't been tampered with. It's a simple, one-click security boost.&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;4. Choosing Your Blocklists&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;This is the fun part—the actual ad-blocking! Go to &lt;strong&gt;Filters -&amp;gt; DNS blocklists&lt;/strong&gt;. For a "Balanced &amp;amp; Powerful" setup that blocks aggressively without a high risk of breaking websites, enable the following lists:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AdGuard DNS filter:&lt;/strong&gt; A great, well-maintained baseline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OISD Blocklist Big:&lt;/strong&gt; Widely considered one of the best all-in-one lists for blocking ads, trackers, and malware.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HaGeZi's Pro Blocklist:&lt;/strong&gt; A fantastic list that adds another layer of aggressive blocking for privacy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HaGeZi's Threat Intelligence Feed:&lt;/strong&gt; A crucial security-only list that focuses on protecting against active threats like phishing and malware.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This combination will give you robust protection against both annoyances and real dangers.&lt;/p&gt;




&lt;h3&gt;
  
  
  ## Chapter 5: Giving Your Server a Name (Free Domain with No-IP) 📛
&lt;/h3&gt;

&lt;p&gt;An IP address is hard to remember. Let's get a free, memorable name for our server.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;Sign Up at No-IP:&lt;/strong&gt; Go to &lt;a href="https://www.noip.com/" rel="noopener noreferrer"&gt;No-IP.com&lt;/a&gt;, create a free account, and create a &lt;strong&gt;hostname&lt;/strong&gt; (e.g., &lt;code&gt;my-dns.ddns.net&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Point it to Your Server:&lt;/strong&gt; When creating the hostname, enter your server's permanent &lt;strong&gt;Elastic IP address&lt;/strong&gt;. Confirm your account via email.&lt;/li&gt;
&lt;/ol&gt;




&lt;h3&gt;
  
  
  ## Chapter 6: Making It Secure with SSL/TLS 🔐
&lt;/h3&gt;

&lt;p&gt;We'll use &lt;strong&gt;Let's Encrypt&lt;/strong&gt; and &lt;strong&gt;Certbot&lt;/strong&gt; to get a free SSL certificate, which lets us use secure &lt;code&gt;https://&lt;/code&gt; and encrypted DNS.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Install Certbot:&lt;/strong&gt; In your SSH session, run these commands:&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 update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;certbot &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Get the Certificate:&lt;/strong&gt; Run this command, replacing the email and domain with your own.&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# This command will temporarily stop any service on port 80, get the certificate, and then finish.&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;certbot certonly &lt;span class="nt"&gt;--standalone&lt;/span&gt; &lt;span class="nt"&gt;--agree-tos&lt;/span&gt; &lt;span class="nt"&gt;--email&lt;/span&gt; YOUR_EMAIL@example.com &lt;span class="nt"&gt;-d&lt;/span&gt; your-no-ip-hostname.ddns.net
&lt;/code&gt;&lt;/pre&gt;


&lt;p&gt;If it's successful, it will tell you where your certificate files are saved (usually in &lt;code&gt;/etc/letsencrypt/live/your-no-ip-hostname.ddns.net/&lt;/code&gt;).&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Configure AdGuard Home Encryption:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Go to your AdGuard Home dashboard (&lt;strong&gt;Settings -&amp;gt; Encryption settings&lt;/strong&gt;).&lt;/li&gt;
&lt;li&gt;Check &lt;strong&gt;"Enable encryption"&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;In the &lt;strong&gt;"Server name"&lt;/strong&gt; field, enter your No-IP hostname.&lt;/li&gt;
&lt;li&gt;Under &lt;strong&gt;"Certificates"&lt;/strong&gt;, choose &lt;strong&gt;"Set a certificates file path"&lt;/strong&gt;.

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Certificate path:&lt;/strong&gt; &lt;code&gt;/etc/letsencrypt/live/your-no-ip-hostname.ddns.net/fullchain.pem&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Private key path:&lt;/strong&gt; &lt;code&gt;/etc/letsencrypt/live/your-no-ip-hostname.ddns.net/privkey.pem&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;"Save configuration"&lt;/strong&gt;. The page will reload on a secure &lt;code&gt;https://&lt;/code&gt; connection!&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;




&lt;h3&gt;
  
  
  ## Chapter 7: Automating SSL Renewal (Cron Job Magic) ✨
&lt;/h3&gt;

&lt;p&gt;Let's Encrypt certificates last for 90 days. We can tell our server to automatically renew them.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;Open the Cron Editor:&lt;/strong&gt; In SSH, run &lt;code&gt;sudo crontab -e&lt;/code&gt; and choose &lt;code&gt;nano&lt;/code&gt; as your editor.&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Add the Renewal Job:&lt;/strong&gt; Add this line to the bottom of the file. It tells the server to try renewing the certificate every day at 2:30 AM.&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;30 2 * * * /usr/bin/certbot renew --quiet
&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Save and exit (&lt;code&gt;Ctrl+X&lt;/code&gt;, then &lt;code&gt;Y&lt;/code&gt;, then &lt;code&gt;Enter&lt;/code&gt;). Your server will now keep its certificate fresh forever!&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;h3&gt;
  
  
  ## Chapter 8: Testing Your New Superpowers (DoH &amp;amp; DoT) 🧪
&lt;/h3&gt;

&lt;p&gt;For a direct confirmation, I used these commands on my computer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;DNS-over-HTTPS (DoH) Test:&lt;/strong&gt; This test checks if the secure web endpoint for DNS is alive.&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt;https://your-no-ip-hostname.ddns.net/dns-query]&lt;span class="o"&gt;(&lt;/span&gt;https://your-no-ip-hostname.ddns.net/dns-query&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;


&lt;p&gt;I got a "405 Method Not Allowed" error, which sounds bad but is actually &lt;strong&gt;great news&lt;/strong&gt;. It means I successfully connected to the server, which correctly told me I didn't send a real query. The connection works!&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;DNS-over-TLS (DoT) Test:&lt;/strong&gt; This checks the dedicated secure port for DNS. I used a tool called &lt;code&gt;kdig&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# I had to install it first with: sudo apt install knot-dnsutils&lt;/span&gt;
kdig @your-no-ip-hostname.ddns.net +tls-ca +tls-host&lt;span class="o"&gt;=&lt;/span&gt;your-no-ip-hostname.ddns.net example.com
&lt;/code&gt;&lt;/pre&gt;


&lt;p&gt;The command returned a perfect DNS answer for &lt;code&gt;example.com&lt;/code&gt;, confirming the secure tunnel was working.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  ## Chapter 9: Protecting Your Kingdom (Router &amp;amp; Phone Setup) 🏰
&lt;/h3&gt;

&lt;p&gt;Now, let's point your devices to their new guardian.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;On Your Home Router:&lt;/strong&gt; Log in to your router's admin page, find the DNS settings, and enter your server's &lt;strong&gt;Elastic IP&lt;/strong&gt; as the primary DNS server. Leave the secondary field blank! This forces all devices on your Wi-Fi to be protected. Then, restart your router.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On Your Mobile Phone:&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Android:&lt;/strong&gt; Go to &lt;strong&gt;Settings -&amp;gt; Network -&amp;gt; Private DNS&lt;/strong&gt;. Choose "Private DNS provider hostname" and enter your No-IP hostname (&lt;code&gt;my-dns.ddns.net&lt;/code&gt;). This gives you ad-blocking everywhere, even on cellular data!&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;iOS:&lt;/strong&gt; You can use a profile to configure DoH. A simple way is to use a site like &lt;a href="https://adguard-dns.io/en/public-dns.html" rel="noopener noreferrer"&gt;AdGuard's DNS profile generator&lt;/a&gt;, but enter your own server's DoH address (&lt;code&gt;https://my-dns.ddns.net/dns-query&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;/ul&gt;




&lt;h3&gt;
  
  
  ## Chapter 10: The Ultimate Safety Net (Creating a Snapshot) 📸
&lt;/h3&gt;

&lt;p&gt;Finally, let's back up our perfect setup.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; In the EC2 Console, go to your instance details.&lt;/li&gt;
&lt;li&gt; Click the &lt;strong&gt;"Storage"&lt;/strong&gt; tab and click the &lt;strong&gt;"Volume ID"&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt; Click &lt;strong&gt;"Actions" -&amp;gt; "Create snapshot"&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt; Give it a description, like &lt;code&gt;AdGuard-Working-Setup-Backup&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you ever mess something up, you can use this snapshot to restore your server to this exact working state in minutes.&lt;/p&gt;

&lt;h3&gt;
  
  
  ## Bonus Chapter: Common Troubleshooting Tips
&lt;/h3&gt;

&lt;p&gt;If things aren't working, here are a few common pitfalls to check:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Browser Overrides Everything:&lt;/strong&gt; If one device isn't blocking ads, check its browser settings! Modern browsers like Chrome have a "Secure DNS" feature that can bypass your custom setup. You may need to turn this off.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check Your Laptop's DNS:&lt;/strong&gt; Make sure your computer's network settings are set to "Obtain DNS automatically" so it listens to the router. A manually set DNS on your PC will ignore the router's settings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Beware of IPv6:&lt;/strong&gt; If you run into trouble on one device, try disabling IPv6 in that device's Wi-Fi adapter properties to force it to use your working IPv4 setup.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  ## It’s a Wrap!
&lt;/h3&gt;

&lt;p&gt;And there you have it! You've successfully built a personal, secure, ad-blocking DNS server in the cloud. You've learned about cloud computing, firewalls, DNS, SSL, and automation. Go enjoy a faster, cleaner, and more private internet experience.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>adguard</category>
      <category>dns</category>
      <category>ssl</category>
    </item>
    <item>
      <title>Running Private Adguard Server on Cloud (Linode)</title>
      <dc:creator>Prajwol Adhikari</dc:creator>
      <pubDate>Sat, 01 Mar 2025 04:44:00 +0000</pubDate>
      <link>https://forem.com/prajwol-ad/running-private-adguard-server-on-cloud-linode-18ef</link>
      <guid>https://forem.com/prajwol-ad/running-private-adguard-server-on-cloud-linode-18ef</guid>
      <description>&lt;h1&gt;
  
  
  What's the buzz about AdGuard Home?
&lt;/h1&gt;

&lt;p&gt;Imagine AdGuard Home as your personal internet guardian. This versatile tool blocks ads, trackers, and other online nuisances across all devices connected to your network. Whether you're browsing on your phone, tablet, or computer, AdGuard Home has your back.&lt;/p&gt;

&lt;p&gt;In today's digital landscape, robust security measures are paramount. Protecting each device shields your family from accidental clicks and malicious attacks, ensuring peace of mind and a secure online environment.&lt;/p&gt;




&lt;h1&gt;
  
  
  Why on the Cloud?
&lt;/h1&gt;

&lt;p&gt;While setting up AdGuard Home on your home network is great, installing it on a cloud server like Linode takes things up a notch. Here's why:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;On-the-Go Protection&lt;/strong&gt;: Your devices stay protected from ads and trackers, no matter where you are, you can even share it with your family.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Centralized Control&lt;/strong&gt;: Manage and customize your ad-blocking settings from a single dashboard.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enhanced Privacy&lt;/strong&gt;: Keep your browsing data away from prying eyes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Ready to embark on this ad-free adventure? Let's get started!&lt;/p&gt;




&lt;h1&gt;
  
  
  Setting Up The Environment
&lt;/h1&gt;

&lt;h2&gt;
  
  
  Step 1: Create a Linode Cloud Account
&lt;/h2&gt;

&lt;p&gt;Why choose Linode? Through &lt;a href="https://linode.com/networkchuck" rel="noopener noreferrer"&gt;NetworkChuck's referral link&lt;/a&gt;, you receive a generous $100 cloud credit - a fantastic start!&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sign Up&lt;/strong&gt;: Navigate to &lt;a href="https://linode.com/networkchuck" rel="noopener noreferrer"&gt;Linode's signup page&lt;/a&gt; and register.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Access the Dashboard&lt;/strong&gt;: Log in and select 'Linodes' from the left-side menu.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Create a Linode&lt;/strong&gt;: Click 'Create Linode,' choose your preferred region, and select an operating system (Debian 11 is a solid choice).
&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%2Favylkbj21se58s82ir2w.jpg" alt=" " width="800" height="452"&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Choose a Plan&lt;/strong&gt;: The Shared 1GB Nanode instance is sufficient for AdGuard Home.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Label and Secure&lt;/strong&gt;: Assign a label to your Linode and set a strong root password.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deploy&lt;/strong&gt;: Click 'Create Linode' and wait for it to initialize.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once your Linode is up and running, access it via the LISH Console or SSH. (use root as localhost login)&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: Installing AdGuard Home on Linode
&lt;/h2&gt;

&lt;p&gt;Yes, we're already into setting up at this point. &lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Log In&lt;/strong&gt;: Access your Linode using SSH or the LISH Console with your root credentials.&lt;/li&gt;
&lt;li&gt; Update the system:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sudo apt update &amp;amp;&amp;amp; apt upgrade -y
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Go ahead and copy this command to Install Adguard Home:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;curl -s -S -L https://raw.githubusercontent.com/AdguardTeam/AdGuardHome/master/scripts/install.sh | sh -s -- -v
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;AdGuard Home is installed and running. You can use &lt;strong&gt;CTRL+Shift+V&lt;/strong&gt; to paste into the terminal.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: Configure AdGuard Home
&lt;/h2&gt;

&lt;p&gt;Post-installation, you'll see a list of IP addresses with port &lt;code&gt;:3000&lt;/code&gt;.&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%2Fg762szyxj4cn71wgdx4w.jpg" 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%2Fg762szyxj4cn71wgdx4w.jpg" alt=" " width="800" height="494"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Access the Web Interface&lt;/strong&gt;: Open your browser and navigate to the IP address followed by &lt;code&gt;:3000&lt;/code&gt;. If you encounter a security warning, proceed by clicking "Continue to site."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Initial Setup&lt;/strong&gt;: Click 'Get Started' and follow the prompts. When uncertain, default settings are typically fine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set Credentials&lt;/strong&gt;: Set up the Username and Password.&lt;/li&gt;
&lt;/ol&gt;


&lt;h2&gt;
  
  
  Step 4: Integrate AdGuard Home with Your Router
&lt;/h2&gt;

&lt;p&gt;After this your AdGuard Home is running, but in order to use it on your devices you need to setup inside your home router for all your devices to be protected. For that, I can't walk you through each and every router settings, but the steps are pretty similar. &lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Find your router IP address, you should be able to find it on the back of your router (commonly 192.168.0.1 or 192.169.1.1) enter it into your browser.&lt;/li&gt;
&lt;li&gt;Login into your router using the credentials mentioned in the back of your router; the default is often admin for both username and password. I suggest you change your default password.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Configure DNS Settings&lt;/strong&gt;:

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Enable DHCP Server&lt;/strong&gt;: Ensure your router's DHCP server is active.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set DNS Addresses&lt;/strong&gt;: Input your AdGuard Home server's IP as the primary DNS (mine was 96.126.113.207). For secondary DNS, options like &lt;code&gt;1.1.1.1&lt;/code&gt; (Cloudflare), &lt;code&gt;9.9.9.9&lt;/code&gt; (Quad9), or &lt;code&gt;8.8.8.8&lt;/code&gt; (Google) are reliable.&lt;/li&gt;
&lt;li&gt;Save and apply the changes.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;


&lt;h1&gt;
  
  
  Fine-Tuning AdGuard Home
&lt;/h1&gt;

&lt;p&gt;If you've done everything till here you should be good, but for those who enjoy customization, AdGuard Home offers a plethora of settings. Some of the customizations I did are:&lt;/p&gt;
&lt;h2&gt;
  
  
  Settings
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Go to Settings -&amp;gt; General Settings: You can enable Parental Control and Safe Search.&lt;/li&gt;
&lt;li&gt;You can also make your Statistics last longer than 24hrs which is default.&lt;/li&gt;
&lt;li&gt;Now on Settings -&amp;gt; DNS Settings

&lt;ul&gt;
&lt;li&gt;By default it uses DNS from quad9 which is pretty good but I suggest you add more.&lt;/li&gt;
&lt;li&gt;You can click on list of known DNS providers, where you can choose from.&lt;/li&gt;
&lt;li&gt;I used: 

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dns.quad9.net/dns-query" rel="noopener noreferrer"&gt;https://dns.quad9.net/dns-query&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dns.google/dns-query" rel="noopener noreferrer"&gt;https://dns.google/dns-query&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dns.cloudflare.com/dns-query" rel="noopener noreferrer"&gt;https://dns.cloudflare.com/dns-query&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Enable 'Load Balancing' to distribute queries evenly.&lt;/li&gt;
&lt;li&gt;Scroll down to 'DNS server configuration' and enable DNSSEC for enhanced security.&lt;/li&gt;
&lt;li&gt;Click on Save.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;


&lt;h2&gt;
  
  
  Filters
&lt;/h2&gt;
&lt;h3&gt;
  
  
  DNS blocklists
&lt;/h3&gt;

&lt;p&gt;Go to Filters -&amp;gt; DNS blocklists, here you can add blocklist that people have created and use it to block even more things. By default AdGuard uses AdGuard DNS filter, and you can add more.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Click on Add blocklist -&amp;gt; Choose from the list&lt;/li&gt;
&lt;li&gt;Don't choose too many from the list cause it may slow your internet requests.
&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%2F2o089a01w7g6goeqzr40.png" alt=" " width="800" height="498"&gt;
These are the blocklists I added. And just like that you are blocking more and more things.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  DNS rewrites
&lt;/h3&gt;

&lt;p&gt;Go to Filters -&amp;gt; DNS rewrites, here you can add your own DNS entries, so I added AdGuard here.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Click on Add DNS rewrite&lt;/li&gt;
&lt;li&gt;Type in domain adguardforme.local and your IP address for AdGuard Home.
&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%2Fqcthakwgoisle9bc5z5y.jpg" alt=" " width="745" height="770"&gt;
&lt;/li&gt;
&lt;li&gt;And save it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now, when I want to go on AdGuard Home dashboard I just type in adguardforme.local and I'm into AdGuard, I don't have to remember the IP address.&lt;/p&gt;
&lt;h3&gt;
  
  
  Custom filtering rules
&lt;/h3&gt;

&lt;p&gt;Go to Filters -&amp;gt; Custom filtering rules. For some reason when I use Facebook on mobile device stories and videos does not load up, so I added custom filtering rules.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@@||graph.facebook.com^$important
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>adguard</category>
      <category>linode</category>
      <category>dns</category>
      <category>doh</category>
    </item>
    <item>
      <title>Dive into AI Fun: Running DeepSeek-R1 on Docker Container on Ubuntu</title>
      <dc:creator>Prajwol Adhikari</dc:creator>
      <pubDate>Sat, 01 Mar 2025 04:23:28 +0000</pubDate>
      <link>https://forem.com/prajwol-ad/dive-into-ai-fun-running-deepseek-r1-on-docker-container-on-ubuntu-2b89</link>
      <guid>https://forem.com/prajwol-ad/dive-into-ai-fun-running-deepseek-r1-on-docker-container-on-ubuntu-2b89</guid>
      <description>&lt;h1&gt;
  
  
  What's a Docker Container?
&lt;/h1&gt;

&lt;p&gt;Before we dive into setting up DeepSeek-R1, let me explain what a Docker container is. Imagine you have a toy that works perfectly on your birthday but gets broken if you move it to another room. A Docker container is like a magic box that keeps your AI model (the toy) in perfect condition wherever you take it, whether it's running as a background task, on a web server, or even in the cloud.&lt;/p&gt;

&lt;p&gt;Docker containers encapsulate everything required to run an application: the code, dependencies, and environment settings. This ensures consistency across different machines, which is super important for AI models that rely on precise configurations.&lt;/p&gt;

&lt;h1&gt;
  
  
  Setting Up The Environment
&lt;/h1&gt;

&lt;h2&gt;
  
  
  Step 1: Install Ubuntu on Windows (If You Haven't Already)
&lt;/h2&gt;

&lt;p&gt;If you're using Windows, the easiest way to get an Ubuntu environment is through the Microsoft Store. Here's how:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open the Microsoft Store and search for Ubuntu.&lt;/li&gt;
&lt;li&gt;Click Get and let it install.&lt;/li&gt;
&lt;li&gt;Once installed, open Ubuntu from the Start menu and follow the setup instructions.&lt;/li&gt;
&lt;li&gt;Update the system:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sudo apt update &amp;amp;&amp;amp; sudo apt upgrade
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now, you have an Ubuntu terminal running on Windows!&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: Install Docker (If You Haven't Already)
&lt;/h2&gt;

&lt;p&gt;First, let's check if you have Docker installed. Open a terminal and run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;docker --version
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that returns a version number, congrats! If not, install Docker:&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 update &amp;amp;&amp;amp; sudo apt install docker.io -y
sudo systemctl enable --now docker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 3: Prerequisites for NVIDIA GPU
&lt;/h2&gt;

&lt;p&gt;Install NVIDIA Container Toolkit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Configuring the production repository:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey | sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg \
&amp;amp;&amp;amp; curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list | \
sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' | \
sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Update the package list:
&lt;/li&gt;
&lt;/ul&gt;

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

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Install the NVIDIA Container Toolkit:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sudo apt-get install -y nvidia-container-toolkit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h1&gt;
  
  
  Running Ollama Inside Docker
&lt;/h1&gt;

&lt;p&gt;Run these commands(P.S. shoutout to NetworkChuck):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;docker run -d \
--gpus all \
-v ollama:/root/.ollama \
-p 11434:11434 \
--security-opt=no-new-privileges \
--cap-drop=ALL \
--cap-add=SYS_NICE \
--memory=8g \
--memory-swap=8g \
--cpus=4 \
--read-only \
--name ollama \
ollama/ollama
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h1&gt;
  
  
  Running DeepSeek-R1 Locally
&lt;/h1&gt;

&lt;p&gt;Time to bring DeepSeek-R1 to life locally and containerized:&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 ollama ollama run deepseek-r1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or you can run other versions of deepseek-r1 just by typing in the version at the end after a colon(:)&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 ollama ollama run deepseek-r1:7b
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After this, play around with the AI, if you wanna exit just type:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;h1&gt;
  
  
  Starting Deepseek-R1
&lt;/h1&gt;

&lt;p&gt;To Start Deepseek-R1 from next time go to Ubuntu and type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;docker start ollama
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;this will start ollama docker container; then type:&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 ollama ollama run deepseek-r1:7b
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>deepseek</category>
      <category>docker</category>
      <category>opensource</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
