$ cat ~/homelab/README

$NoxLab

$ uname
self-hosted security lab

I build, automate, and defend infrastructure - security-first. NoxLab is where I prove it hands-on: one cheap second-hand workstation running a small org's worth of services end to end - a segmented network, a tiered Docker platform, a self-hosted GitLab with a security-conscious publishing pipeline, a SIEM with local-LLM alert triage, and a tested rebuild-from-bare-metal recovery path.

NoxLab network topology: edge router and management LAN, VPN-only inbound, and a core server hosting tiered Docker plus bridged VMs, with an outbound tunnel for public services
// network topology - abstracted (example addresses, role names)
12+
services self-hosted
5
VMs on the core
2
CI runners, egress-split
1
inbound port (VPN)

One box, built and run end to end - and what each part is evidence of. Every claim links to the write-up that backs it.

DevSecOps · CI/CD
Build & ship securely
Untrusted CI builds run DNS-gapped; only one audited step gets egress; secrets are SOPS single-source; only a squashed, sanitised commit ever reaches public GitHub.
Infrastructure · SysAdmin
Run the platform
Tiered Docker on isolated bridges, boot-ordered by systemd, behind one TLS reverse proxy with a wildcard cert that auto-renews and fans out to every consumer.
Network · Access
Design the network
Segmented trust, one inbound port (VPN), public services via outbound-only tunnels (zero inbound), self-hosted recursive DNS + DoH pinned to survive reboots.
Blue team · SOC
Detect & respond
Wazuh + Suricata with custom MITRE-mapped rules and threat-intel enrichment; real-time Telegram alerts on high severity; a local-LLM L1 analyst triages overnight and escalates only what it cannot resolve to an L2 Claude review.
Resilience · DR · IaC
Recover from bare metal
An Ansible playbook rebuilds the whole box from a bare machine - tested end to end - backed by layered backups. The DR artifact is the playbook, not a snapshot you hope restores.
Security Eng · IR
Harden & analyse
Host hardened with auditd, fail2ban, lynis, ufw and YubiKey-gated sudo; a NAT-isolated REMnux box for malware analysis; purple-team validation that caught a Sysmon-to-Wazuh ingestion gap on one host - detection looked tuned, but the data never arrived.

The "servers" are mostly VMs on a single second-hand workstation. It does almost everything.

modelHP Z420
cpuXeon E5-1650 v2 (6c/12t)
memory64 GB DDR3 ECC
gpuGTX 1050 Ti
system driveKingston 980 GB SSD
osUbuntu 24.04 LTS
storage (staged)+2x 1TB + 128GB cache

Picked because it was cheap and takes DDR3 ECC. Ubuntu LTS because I wanted to stay in the Debian family and LTS was the boring, correct choice.

It shipped with a Quadro K2000 whose nouveau driver crashed the desktop every ~3 days like clockwork - a GNOME bug marked "won't fix". A GTX 1050 Ti fixed that with solid proprietary drivers and gave me headroom - which is exactly what unlocked the overnight Gemma triage, with a Jellyfin server next on the roadmap.

The whole staged storage set - two 1TB laptop drives plus a 128GB cache disk - cost about EUR 30.

Everything here runs on that one box. Each tile opens a console with the detail - what it does, and why it earns its place.

ingress
git + ci/cd
detection + secrets
dns + remote access
platform + automation
knowledge base
the lab - 5 VMs on the core

All five run on the one box (VirtualBox): the Wazuh SIEM appliance, Windows/Ubuntu/Fedora targets bridged to the LAN, and a NAT-isolated REMnux box for malware analysis. Click any for its real specs.

The full write-up, design decisions, and copy-adaptable templates. Each page is the real reference, not a teaser.

Traefik · reverse proxy

# one TLS front door; sensitive apps fenced to LAN + VPN

cat tier0/dynamic.yml
http: middlewares: lan-vpn-only: ipAllowList: sourceRange: - "192.0.2.0/24" # management LAN - "198.51.100.0/24" # VPN clients routers: gitlab: rule: "Host(`gitlab.example.com`)" middlewares: [lan-vpn-only] # 403 from anywhere else tls: { certResolver: letsencrypt }

A new service gets HTTPS and its access rule from a handful of labels. Public things never open a port - they leave over an outbound tunnel.

→ full write-up: Network
GitLab CE · self-hosted forge

# self-hosted forge; what GitHub receives is one squashed commit

git log --oneline # the published public mirror
e3f9a21 (HEAD -> main) NoxLab - sanitised public snapshot

Everything lives on the internal GitLab first. Publishing strips the CI config and force-pushes a single orphan commit, so there is no history - and no old-commit secret - to leak.

→ full write-up: CI/CD Publishing
GitLab Runners · build + publish

# two runners, split by network egress - which jobs can reach the internet?

grep -B1 -A2 'tags:' .gitlab-ci.yml
sanitisation-gate: stage: check tags: [internal] # DNS-gapped runner: no route out publish-to-github: stage: publish tags: [external] # the only job allowed egress when: manual

Egress is a capability granted to one job, not a default for every build. A poisoned dependency pulled during check has nowhere to phone home.

→ full write-up: CI/CD Publishing
Wazuh · siem

# agents on every machine report here; the single pane

/var/ossec/bin/agent_control -lc
ID Name Status 000 wazuh-manager Active/Local 001 workstation Active 002 laptop Active 003 win11-lab Active

Custom rules plus abuse.ch threat-intel lists raise the alerts. Overnight a local Gemma model triages them (see Ollama + Gemma).

→ full write-up: Security Stack
Suricata · ids

# network IDS on the wire; matches flow into Wazuh

suricata -V
This is Suricata version 8.0.4 RELEASE
jq -r .alert.signature eve.json | sort | uniq -c | sort -rn | head -3
42 ET SCAN Potential SSH Scan 18 ET POLICY curl/wget User-Agent Outbound 5 ET DNS Query for .top TLD

Signature + protocol analysis on the wire; matches become Wazuh alerts, so network and host detections share one timeline.

→ full write-up: Security Stack
Host hardening · auditd / fail2ban / lynis / ufw

# the host that runs everything is a target too

systemctl is-active auditd fail2ban ufw
active # auditd - syscall auditing active # fail2ban - brute-force bans active # ufw - host firewall
lynis audit system --quiet | grep 'Hardening index'
Hardening index : [ scheduled CIS-style audit ]

Auditing, automated bans, a host firewall, and periodic hardening audits - the base layer everything else sits on.

→ full write-up: Security Stack
SOPS + age · secrets

# secrets encrypted at rest; decrypted only in memory, where needed

cat secrets.env
GITLAB_PAT=ENC[AES256_GCM,data:9Fb2…,type:str] # sops: age recipients + mac below
sops -d secrets.env | head -1
GITLAB_PAT=glpat-••••••••••• # plaintext never hits disk

Committed as ciphertext, single source of truth per secret. The CI publish token, webhook secrets, and service env all live this way.

→ full write-up: Architecture
Unbound · recursive dns

# own recursive resolver, pinned to a loopback alias

grep -vE '^\s*(#|$)' /etc/unbound/unbound.conf
server: interface: 127.0.0.11 # loopback alias: DNS up before the NIC access-control: 192.0.2.0/24 allow # LAN access-control: 198.51.100.0/24 allow # VPN access-control: 0.0.0.0/0 refuse include: "/etc/unbound/blocklist.conf"

Survives reboots and link changes, blocks ads/trackers, and keeps resolution entirely in-house. Pin the things that must not move.

→ full write-up: Network
dnsproxy · doh

# DoH frontend so clients resolve over HTTPS, not plaintext :53

ss -lntp | grep dnsproxy
LISTEN 127.0.0.12:443 (DoH) -> upstream 127.0.0.11 (Unbound)

Terminates DNS-over-HTTPS on its own loopback alias and forwards to Unbound - encrypted resolution for clients, recursion stays local.

→ full write-up: Network
OpenVPN · vpn

# the only inbound port on the whole network (on the MikroTik edge)

/ip firewall nat print where action=dst-nat
chain=dstnat proto=udp dst-port=•••• action=dst-nat to-addresses=192.0.2.10 # -> OpenVPN on the core

One UDP port forwarded to OpenVPN; everything else is outbound-only. Public services dial out over a tunnel, so they have zero inbound surface.

→ full write-up: Network
Docker (tiered) · platform

# boot-ordered tiers on isolated bridges

docker network ls
NAME DRIVER SCOPE tier0_ingress bridge local tier1_apps bridge local # no route to tier0 internals
systemctl cat docker-tier1 | grep -E 'After|Requires'
After=docker-tier0.service Requires=docker-tier0.service

Ingress (Traefik) comes up first; apps sit behind it on a separate bridge with no lateral path across.

→ full write-up: Docker Platform
Ollama + Gemma · local llm triage

# overnight L1 triage of the day's Wazuh alerts - in-house, on the GPU

ollama list
NAME SIZE gemma4:e4b 9.6 GB # the triage model gemma4:26b 17 GB gemma4:31b 19 GB
soc-agent.sh --report today # 3-stage pipeline
[stage 1] classify 14 high/critical alerts -> KNOWN 11 · SUSPICIOUS 2 · UNKNOWN 1 [stage 2] posture: ELEVATED -> action items written to the daily report [stage 3] 3 unresolved >= threshold -> flag for L2 (Claude) review

Gemma is L1: it classifies each alert, keeps a self-updating correlation memory, pulls context from the Obsidian vault, and escalates what it cannot resolve to an L2 Claude review. The human keeps every verdict; telemetry never leaves the box.

→ full write-up: Security Stack
Ansible · disaster recovery

# the rebuild playbook is the real DR artifact

ansible-playbook site.yml --list-tasks
play: rebuild core (project0) base : apt, users, ufw, auditd docker : engine + tier0/tier1 compose dns : unbound + blocklist restore : SOPS secrets, configs from backup

Backups restore data; this restores the system - bare metal to running services. Layered backups (config + encrypted secrets) feed it.

→ full write-up: Backup & DR
Obsidian · network kb

# the network's knowledge base - and the SOC agent's memory

# soc-agent.sh enriches each alert from the vault (RAG)
vault_lookup "Rule 5710" -> Security/.../ssh-auth.md: "expected from the VPN range - benign"

The living docs of the whole lab - topology, runbooks, decisions - exposed over an MCP server so tooling (and the overnight LLM) can query it. These public pages are its sanitised cut.

→ full write-up: Architecture
Vault MCP · kb access layer

# the knowledge base as a typed API, not a copy

# an MCP client calls a tool; the vault answers
search_vault "Rule 5710" -> Security/.../ssh-auth.md read_note "Wazuh" -> { metadata, content }

A small FastMCP server that exposes the Obsidian vault over the Model Context Protocol - typed read / search / write / status tools, over stdio or streamable-HTTP. It is how the overnight SOC agent does its RAG lookups and how I maintain the docs with Claude: the single source of truth is queried, never duplicated. Built security-first - localhost-bound by default, DNS-rebinding protection, secrets kept out of the code.

→ source: mcp-vault-ligament ↗
Windows 11 · lab vm / workstation target

# Windows endpoint-detection target, bridged to the LAN

VBoxManage showvminfo win11-lab --machinereadable | grep ostype
ostype="Windows11_64"

Sysmon + a Wazuh agent ship Windows events to the SIEM. A snapshot-and-revert target - nothing of value lives on it.

→ full write-up: Network
Ubuntu lab · lab vm / linux target

# general-purpose Linux lab, bridged to the LAN

VBoxManage showvminfo ubuntu-lab --machinereadable | grep ostype
ostype="Ubuntu_64"

Where configs, packages, and exploits get tried before they touch anything real.

→ full write-up: Network
REMnux · vm / malware analysis

# malware analysis - deliberately NOT on the LAN

VBoxManage showvminfo remnux --machinereadable | grep -E 'ostype|nic1'
ostype="Ubuntu_64" # REMnux (Ubuntu-based) nic1="nat" # isolated: no bridge to the LAN

REMnux is the reverse-engineering / malware-analysis distro. Kept NAT-isolated so a live sample can't reach the rest of the lab.

→ full write-up: Network
Fedora lab · lab vm / linux target

# a second distro family for cross-distro testing

VBoxManage showvminfo fedora-lab --machinereadable | grep ostype
ostype="Fedora_64"

Different package manager, SELinux on by default - so detections and playbooks get exercised beyond the Debian family.

→ full write-up: Network
Wazuh · vm / siem appliance

# the SIEM appliance itself - the heaviest workload on the box

VBoxManage showvminfo wazuh --machinereadable | grep ostype
ostype="Linux_64"

Wazuh runs as a VM on the core. Agents across the host and the other VMs report in here; the overnight Gemma triage reads from it.

→ full write-up: Network