Compare commits

...

7 Commits

Author SHA1 Message Date
Keith Solomon
cd9351599e feature: Add repos page 2026-01-25 14:03:32 -06:00
Keith Solomon
2992c17fd7 feature: Reorganize notes, and add section descriptions 2026-01-25 12:23:41 -06:00
Keith Solomon
5465665c26 🐞 fix: Update backup script for configurable backup retention 2025-12-28 11:13:48 -06:00
Keith Solomon
95dece016f feature: Add gateway stack documentation 2025-12-06 14:35:56 -06:00
Keith Solomon
6c2f77d5b7 🐞 fix: Remove email address 2025-12-06 13:43:17 -06:00
Keith Solomon
b0d760c3ab feature: Add basic Traefik/Authelia support files 2025-12-06 13:41:33 -06:00
Keith Solomon
43bd37c552 📄 docs: Add per-machine details for external-facing containers 2025-12-06 13:30:51 -06:00
16 changed files with 817 additions and 29 deletions

View File

@@ -0,0 +1,88 @@
---
theme: dark
server:
address: tcp://0.0.0.0:9091
buffers:
read: 65536
write: 65536
access_control:
default_policy: deny
networks:
- name: internal
networks:
- 192.168.2.0/24
rules:
- domain: "*.yourdomain.com"
policy: bypass
networks:
- internal
- domain: "*.yourdomain.com"
policy: one_factor
log:
level: info
file_path: /logs/authelia.log
format: json
keep_stdout: true
#default_redirection_url: https://auth.yourdomain.com
totp:
issuer: authelia.com
identity_validation:
reset_password:
jwt_secret: "{{ env `AUTHELIA_JWT_SECRET` }}"
# docker run authelia/authelia:latest authelia crypto hash generate argon2 -i 1 -p 8 -v argon2id --password 'yourpassword'
authentication_backend:
refresh_interval: 5m
file:
path: /config/users_database.yml
password:
algorithm: argon2id
iterations: 1
salt_length: 16
parallelism: 8
memory: 64
session:
name: authelia_session
secret: "{{ env `AUTHELIA_SESSION_SECRET` }}"
inactivity: 5d # 5 days
expiration: 30d # 30 days
remember_me: 60d # 60 days
cookies:
- domain: 'yourdomain.com'
authelia_url: 'https://auth.yourdomain.com'
default_redirection_url: 'https://yourdomain.com'
name: 'authelia_session'
inactivity: 5d # 5 days
expiration: 30d # 30 days
remember_me: 60d # 60 days
regulation:
max_retries: 5
find_time: 120m
ban_time: 300m
storage:
encryption_key: "{{ env `AUTHELIA_STORAGE_ENCRYPTION_KEY` }}}"
local:
path: /config/db.sqlite3
notifier:
smtp:
address: submission://smtp.gmail.com:587
username: "{{ env `AUTHELIA_NOTIFIER_USERNAME` }}"
password: "{{ env `AUTHELIA_NOTIFIER_PASSWORD` }}"
sender: ksolomon+authelia@gmail.com
ntp:
address: time.cloudflare.com:123
version: 3
max_desync: 3s
disable_startup_check: false
disable_failure: false

View File

@@ -0,0 +1,9 @@
users:
user:
disabled: false
displayname: 'Your Name'
# to generate, run docker run authelia/authelia:latest authelia crypto hash generate argon2 -i 1 -p 8 -v argon2id --password your-password
password: 'your-hashed-password-here'
email: 'your-email@yourdomain.com'
groups:
- 'admins'

View File

@@ -4,7 +4,11 @@ set -euo pipefail
# Paths — adjust if your layout changes # Paths — adjust if your layout changes
GITEA_DIR="/gitea/gitea" GITEA_DIR="/gitea/gitea"
BACKUP_DIR="/gitea/backups" BACKUP_DIR="/gitea/backups"
CONTAINER="gitea" CONTAINER="Gitea"
# Retention
KEEP_COUNT=7
B2_REMOTE="B2:SoloForge-backup"
mkdir -p "$BACKUP_DIR" mkdir -p "$BACKUP_DIR"
@@ -22,9 +26,68 @@ mv "$GITEA_DIR/$dump_file" "$BACKUP_DIR/$dump_file"
echo "[backup] Dump created at $BACKUP_DIR/$dump_file" echo "[backup] Dump created at $BACKUP_DIR/$dump_file"
# Optional: Upload to Backblaze B2 via rclone # Upload to Backblaze B2 via rclone
# Make sure you configured a remote named 'B2' rclone copy "$BACKUP_DIR/$dump_file" "$B2_REMOTE"
rclone copy "$BACKUP_DIR/$dump_file" B2:soloforge-backups
echo "[backup] Uploaded $dump_file to Backblaze B2" echo "[backup] Uploaded $dump_file to Backblaze B2"
# -----------------------
# Cleanup: keep latest N
# -----------------------
echo "[cleanup] Keeping newest $KEEP_COUNT local backups in $BACKUP_DIR"
mapfile -t localBackups < <(ls -1t "$BACKUP_DIR"/gitea-dump-*.zip 2>/dev/null || true)
if (( ${#localBackups[@]} > KEEP_COUNT )); then
for oldPath in "${localBackups[@]:KEEP_COUNT}"; do
oldFile="$(basename "$oldPath")"
# Extra safety: only delete files matching our exact dump pattern
if [[ "$oldFile" =~ ^gitea-dump-[0-9]{8}-[0-9]{6}\.zip$ ]]; then
echo "[cleanup] Deleting local: $oldPath"
rm -f -- "$oldPath"
else
echo "[cleanup] Skipping unexpected local filename (won't delete): $oldPath" >&2
fi
done
else
echo "[cleanup] Local backups <= $KEEP_COUNT, nothing to delete."
fi
echo "[cleanup] Keeping newest $KEEP_COUNT backups in B2 ($B2_REMOTE)"
# Safety guard: require remote to be in the form "REMOTE:bucket"
# (bucket root only, no trailing slash/path)
if ! [[ "$B2_REMOTE" =~ ^[^:]+:[^/]+$ ]]; then
echo "[cleanup] Refusing remote cleanup: B2_REMOTE must be bucket-root like 'B2:SoloForge-backup' (got: $B2_REMOTE)" >&2
exit 1
fi
# Remote prune: list only root-level filenames, newest-first (timestamped name makes this safe).
# rclone lsf on a bucket root returns immediate entries only (non-recursive by default).
mapfile -t remoteBackups < <(
rclone lsf "$B2_REMOTE" \
--files-only \
--max-depth 1 \
--include "gitea-dump-*.zip" 2>/dev/null \
| sed 's:/$::' \
| sort -r
)
if (( ${#remoteBackups[@]} > KEEP_COUNT )); then
for old in "${remoteBackups[@]:KEEP_COUNT}"; do
# Extra safety: ensure we only ever delete matching root-level dump zips
if [[ "$old" =~ ^gitea-dump-[0-9]{8}-[0-9]{6}\.zip$ ]]; then
echo "[cleanup] Deleting remote: $old"
rclone deletefile "$B2_REMOTE/$old"
else
echo "[cleanup] Skipping unexpected remote filename (won't delete): $old" >&2
fi
done
else
echo "[cleanup] Remote backups <= $KEEP_COUNT, nothing to delete."
fi
echo "[backup] All done." echo "[backup] All done."
Update backup sctip

View File

@@ -0,0 +1,35 @@
Containers - Banshee (plex)
Calibre-Web-Automated - http://192.168.2.51:8083
duplicati - http://192.168.2.51:8200
KaraKeep - http://192.168.2.51:3849
Ollama - http://192.168.2.51:11434
Open-WebUI - http://192.168.2.51:3838
Paperless - http://192.168.2.51:8010
Tautulli - http://192.168.2.51:8181
YTDL-App - http://192.168.2.51:8998
Containers - Scylla (seedbox, use labels until Traefik is set up on another machine)
Beszel - http://192.168.2.9:8090
Dozzle - http://192.168.2.9:8889
Filebrowser-Quantum - http://192.168.2.9:5080
Flood - http://192.168.2.9:3000
Glance - http://192.168.2.9:4444
Komga - http://192.168.2.9:25600
OpenBooks - http://192.168.2.9:8875
Prowlarr - http://192.168.2.9:9696
qBittorrent-NoX - http://192.168.2.9:3434
Radarr - http://192.168.2.9:7878
Sabnzbd - http://192.168.2.9:4567
Sonarr - http://192.168.2.9:8989
Transmission - http://192.168.2.9:9091
Containers - Hydra (jukebox)
CommaFeed - http://192.168.2.23:8082
OpenGist - http://192.168.2.23:6157
PromptBase - http://192.168.2.23:4321
Uptime-Kuma - http://192.168.2.23:3031

View File

@@ -0,0 +1,9 @@
CLOUDFLARE_API_KEY=your_cloudflare_api_key_here
CLOUDFLARE_EMAIL=your_cloudflare_email_here
# Gebnerate these secrets using openssl rand -hex 64
AUTHELIA_SESSION_SECRET=your_session_secret_here
AUTHELIA_STORAGE_ENCRYPTION_KEY=your_storage_encryption_key_here
AUTHELIA_JWT_SECRET=your_jwt_secret_here
AUTHELIA_NOTIFIER_USERNAME=your_email_here
AUTHELIA_NOTIFIER_PASSWORD=your_email_app_password_here

View File

@@ -0,0 +1,93 @@
services:
traefik:
container_name: Traefik
image: traefik:latest
restart: always
networks:
traefik_macvlan:
ipv4_address: 192.168.2.253 # Traefik's LAN IP
proxy: {}
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./traefik/traefik.yml:/traefik.yml:ro
- ./traefik/config:/traefik_config
- ./traefik/cert:/traefik_cert
- ./traefik/logs:/logs
environment:
- TZ=America/Winnipeg
- CLOUDFLARE_API_KEY=${CLOUDFLARE_API_KEY}
- CLOUDFLARE_EMAIL=${CLOUDFLARE_EMAIL}
labels:
- "traefik.enable=true"
# Dashboard (behind Authelia)
- "traefik.http.routers.traefik.rule=Host(`tfk.yourdomain.com`)"
- "traefik.http.routers.traefik.entrypoints=https"
- "traefik.http.routers.traefik.tls.certresolver=cloudflare"
- "traefik.http.routers.traefik.service=api@internal"
- "traefik.http.routers.traefik.middlewares=authelia@docker"
authelia:
container_name: Authelia
restart: always
image: authelia/authelia:latest
networks:
- proxy
volumes:
- ./authelia/config:/config
- ./authelia/secrets:/secrets:ro
- ./authelia/logs:/var/log/authelia
environment:
- TZ=America/Winnipeg
- X_AUTHELIA_CONFIG_FILTERS=template
- AUTHELIA_SESSION_SECRET=${AUTHELIA_SESSION_SECRET}
- AUTHELIA_STORAGE_ENCRYPTION_KEY=${AUTHELIA_STORAGE_ENCRYPTION_KEY}
- AUTHELIA_JWT_SECRET=${AUTHELIA_JWT_SECRET}
labels:
- "traefik.enable=true"
- "traefik.docker.network=proxy"
- "traefik.http.routers.authelia.rule=Host(`auth.yourdomain.com`)"
- "traefik.http.routers.authelia.entrypoints=https"
- "traefik.http.routers.authelia.tls.certresolver=cloudflare"
- "traefik.http.middlewares.authelia.forwardAuth.address=http://authelia:9091/api/authz/forward-auth"
- "traefik.http.middlewares.authelia.forwardAuth.trustForwardHeader=true"
- "traefik.http.middlewares.authelia.forwardAuth.authResponseHeaders=Remote-User,Remote-Groups,Remote-Name,Remote-Email"
traefik-gui:
container_name: Traefik-GUI
restart: always
image: ghcr.io/rahn-it/traefik-gui:master
networks:
- proxy
volumes:
- ./traefik-gui/db:/app/db
- ./traefik/config:/app/traefik
environment:
- TZ=America/Winnipeg
labels:
- "traefik.enable=true"
- "traefik.docker.network=proxy"
- "traefik.http.routers.traefik-gui.rule=Host(`tfk-admin.yourdomain.com`)"
- "traefik.http.routers.traefik-gui.entrypoints=https"
- "traefik.http.routers.traefik-gui.tls.certresolver=cloudflare"
- "traefik.http.routers.traefik-gui.middlewares=authelia@docker"
networks:
traefik_macvlan:
external: true
proxy:
name: proxy
driver: bridge

View File

@@ -0,0 +1,95 @@
global:
# Send anonymous usage data
sendAnonymousUsage: true
checkNewVersion: true
api:
dashboard: true
debug: true
disableDashboardAd: true
log:
filePath: "/logs/logfile.log"
level: INFO
format: common
accessLog:
filePath: "/logs/access.log"
# bufferingSize: 20
fields:
defaultMode: keep
names:
StartUTC: drop
entryPoints:
http:
address: ":80"
http:
redirections:
entryPoint:
to: https
scheme: https
permanent: true
https:
address: ":443"
http:
tls:
# Generate a wildcard domain certificate
certResolver: cloudflare
domains:
- main: '*.yourdomain.com'
forwardedHeaders:
trustedIPs:
- "173.245.48.0/20"
- "103.21.244.0/22"
- "103.22.200.0/22"
- "103.31.4.0/22"
- "141.101.64.0/18"
- "108.162.192.0/18"
- "190.93.240.0/20"
- "188.114.96.0/20"
- "197.234.240.0/22"
- "198.41.128.0/17"
- "162.158.0.0/15"
- "104.16.0.0/13"
- "104.24.0.0/14"
- "172.64.0.0/13"
- "131.0.72.0/22"
- "2400:cb00::/32"
- "2606:4700::/32"
- "2803:f800::/32"
- "2405:b500::/32"
- "2405:8100::/32"
- "2a06:98c0::/29"
- "2c0f:f248::/32"
serversTransport:
insecureSkipVerify: true
providers:
providersThrottleDuration: 2s
docker:
endpoint: "unix:///var/run/docker.sock"
exposedByDefault: false
watch: true
network: proxy
file:
directory: "/traefik_config"
watch: true
certificatesResolvers:
cloudflare:
acme:
email: 'your-email@yourdomain.com'
storage: /traefik_cert/acme.json
dnsChallenge:
provider: cloudflare
resolvers:
- "1.1.1.1:53"
- "1.0.0.1:53"

View File

@@ -9,6 +9,7 @@ const CONTENT_DIR = path.join(ROOT, 'content');
const DIST_DIR = path.join(ROOT, 'dist'); const DIST_DIR = path.join(ROOT, 'dist');
const TEMPLATE_DIR = path.join(ROOT, 'templates'); const TEMPLATE_DIR = path.join(ROOT, 'templates');
const ASSETS_DIR = path.join(ROOT, 'assets'); const ASSETS_DIR = path.join(ROOT, 'assets');
const SECTIONS_FILE = path.join(TEMPLATE_DIR, 'sections.json');
const md = new MarkdownIt({ const md = new MarkdownIt({
html: true, html: true,
@@ -57,6 +58,25 @@ const cleanDir = (dir) => {
const stripMarkdown = (text) => text.replace(/[`*_>#\-]/g, '').replace(/\s+/g, ' ').trim(); const stripMarkdown = (text) => text.replace(/[`*_>#\-]/g, '').replace(/\s+/g, ' ').trim();
const loadSectionMeta = () => {
if (!fs.existsSync(SECTIONS_FILE)) return [];
const raw = fs.readFileSync(SECTIONS_FILE, 'utf8');
try {
const data = JSON.parse(raw);
if (Array.isArray(data)) return data;
if (data && typeof data === 'object') {
return Object.entries(data).map(([slug, meta]) => ({
slug,
name: meta?.name || slug,
description: meta?.description || '',
}));
}
} catch (err) {
console.warn('Invalid sections.json, skipping section descriptions.', err);
}
return [];
};
const loadNotes = () => { const loadNotes = () => {
const files = walkMarkdownFiles(CONTENT_DIR); const files = walkMarkdownFiles(CONTENT_DIR);
return files.map((filePath) => { return files.map((filePath) => {
@@ -131,19 +151,39 @@ const buildPages = () => {
cleanDir(DIST_DIR); cleanDir(DIST_DIR);
const notes = loadNotes(); const notes = loadNotes();
const sectionMeta = loadSectionMeta();
const sectionMetaMap = new Map(
sectionMeta.map((item) => {
const slug = slugify(item.slug || item.name || '');
return [slug, { ...item, slug }];
})
);
const sectionsMap = new Map(); const sectionsMap = new Map();
notes.forEach((note) => { notes.forEach((note) => {
if (!sectionsMap.has(note.sectionSlug)) { if (!sectionsMap.has(note.sectionSlug)) {
const meta = sectionMetaMap.get(note.sectionSlug);
sectionsMap.set(note.sectionSlug, { sectionsMap.set(note.sectionSlug, {
name: note.section, name: meta?.name || note.section,
slug: note.sectionSlug, slug: note.sectionSlug,
description: meta?.description || '',
notes: [], notes: [],
}); });
} }
sectionsMap.get(note.sectionSlug).notes.push(note); sectionsMap.get(note.sectionSlug).notes.push(note);
}); });
sectionMetaMap.forEach((meta) => {
if (!sectionsMap.has(meta.slug)) {
sectionsMap.set(meta.slug, {
name: meta.name || meta.slug,
slug: meta.slug,
description: meta.description || '',
notes: [],
});
}
});
const sections = Array.from(sectionsMap.values()).sort((a, b) => a.name.localeCompare(b.name)); const sections = Array.from(sectionsMap.values()).sort((a, b) => a.name.localeCompare(b.name));
sections.forEach((section) => section.notes.sort((a, b) => (a.nav - b.nav) || a.title.localeCompare(b.title))); sections.forEach((section) => section.notes.sort((a, b) => (a.nav - b.nav) || a.title.localeCompare(b.title)));
@@ -165,17 +205,17 @@ const buildPages = () => {
<a class="card" href="/${note.sectionSlug}/${note.slug}/"> <a class="card" href="/${note.sectionSlug}/${note.slug}/">
<div class="badge">${section.name}</div> <div class="badge">${section.name}</div>
<h3>${note.title}</h3> <h3>${note.title}</h3>
<p class="muted">${note.summary}</p>
<div class="tag-list">${note.tags.map((tag) => `<span class="tag">#${tag}</span>`).join('')}</div> <div class="tag-list">${note.tags.map((tag) => `<span class="tag">#${tag}</span>`).join('')}</div>
</a> </a>
`) `)
.join(''); .join('');
const sectionDescription = section.description || `Notes grouped by ${section.name}.`;
const sectionContent = ` const sectionContent = `
<div class="hero"> <div class="hero">
<p class="eyebrow">Section</p> <p class="eyebrow">Section</p>
<h1>${section.name}</h1> <h1>${section.name}</h1>
<p class="muted">Notes grouped by ${section.name}. Use the sidebar or search to jump in.</p> <p class="muted">${sectionDescription}</p>
</div> </div>
<div class="card-grid">${sectionList}</div> <div class="card-grid">${sectionList}</div>
`; `;
@@ -227,8 +267,7 @@ const buildPages = () => {
<a class="card" href="/${section.slug}/"> <a class="card" href="/${section.slug}/">
<div class="badge">${section.notes.length} note${section.notes.length === 1 ? '' : 's'}</div> <div class="badge">${section.notes.length} note${section.notes.length === 1 ? '' : 's'}</div>
<h3>${section.name}</h3> <h3>${section.name}</h3>
<p class="muted">${section.notes[0]?.summary || 'Section overview'} <p class="muted">${section.description || 'Section overview'}</p>
</p>
</a> </a>
`) `)
.join(''); .join('');
@@ -236,8 +275,8 @@ const buildPages = () => {
const homeContent = ` const homeContent = `
<div class="hero"> <div class="hero">
<p class="eyebrow">Workspace</p> <p class="eyebrow">Workspace</p>
<h1>Developer Notes Hub</h1> <h1>Development Notes Hub</h1>
<p class="muted">Markdown-first notes rendered into a fast static site. Use search or browse by section.</p> <p class="muted">Notes related to various aspects of my projects and homelab.</p>
</div> </div>
<div class="card-grid">${summaryCards}</div> <div class="card-grid">${summaryCards}</div>
`; `;

View File

@@ -1,13 +1,11 @@
--- ---
title: Cloudflare Pages Workflow title: Cloudflare Pages Workflow
section: infra section: dev-notes
summary: Steps to publish the static notes site to Cloudflare Pages using the provided workflow and build output. summary: Steps to publish the static notes site to Cloudflare Pages using the provided workflow and build output
tags: [cloudflare, ci, deploy] tags: [cloudflare, ci, deploy]
nav: 1 nav: 3
--- ---
# Cloudflare Pages Workflow
## Overview ## Overview
This note captures the build and deploy flow for the static notes site. The site compiles Markdown into static HTML under the `dist/` directory. This note captures the build and deploy flow for the static notes site. The site compiles Markdown into static HTML under the `dist/` directory.

View File

@@ -1,17 +1,16 @@
--- ---
title: Markdown Authoring Guide title: Markdown Authoring Guide
section: docs section: dev-notes
summary: Conventions for writing notes, front matter fields, and embedding code or media in the site. summary: Conventions for writing notes, front matter fields, and embedding code or media in the site
tags: [markdown, style, notes] tags: [markdown, style, notes]
nav: 1 nav: 2
--- ---
# Markdown Authoring Guide
## Front matter ## Front matter
Provide metadata at the top of every note: Provide metadata at the top of every note:
``` ```markdown
--- ---
title: Example Title title: Example Title
section: docs section: docs
@@ -25,13 +24,15 @@ nav: 1
- `nav`: optional integer to influence ordering within a section; lower numbers show first. - `nav`: optional integer to influence ordering within a section; lower numbers show first.
## Writing tips ## Writing tips
- Start with an `#` heading matching the title.
- Keep paragraphs short; use bullet lists for tasks. - Keep paragraphs short; use bullet lists for tasks.
- Use fenced code blocks with language hints (` ```bash `, ` ```js `) for highlighting. - Use fenced code blocks with language hints (` ```bash `, ` ```js `) for highlighting.
- Link to related notes with absolute paths, e.g., `/docs/markdown-authoring-guide/`. - Link to related notes with absolute paths, e.g., `/docs/markdown-authoring-guide/`.
## Media ## Media
Place images next to the note or in an `/assets/media` folder and reference relatively. Place images next to the note or in an `/assets/media` folder and reference relatively.
## Testing locally ## Testing locally
Run `npm run build` to regenerate HTML. Open `dist/index.html` in a browser to review layout and syntax highlighting. Run `npm run build` to regenerate HTML. Open `dist/index.html` in a browser to review layout and syntax highlighting.

View File

@@ -1,12 +1,12 @@
--- ---
title: Developer Onboarding title: Developer Onboarding
section: ops section: dev-notes
summary: Quick start steps to clone, install dependencies, and generate the site locally. summary: Quick start steps to clone, install dependencies, and generate the site locally
tags: [onboarding, setup] tags: [onboarding, setup]
nav: 1 nav: 1
--- ---
# Developer Onboarding ## Getting started
1. Clone the repository and install Node 20+. 1. Clone the repository and install Node 20+.
2. Run `npm install` to pull dependencies. 2. Run `npm install` to pull dependencies.
@@ -14,11 +14,14 @@ nav: 1
4. Serve `dist/` via any static server (e.g., `npx serve dist`). 4. Serve `dist/` via any static server (e.g., `npx serve dist`).
## Directory overview ## Directory overview
- `content/`: Markdown notes with front matter. - `content/`: Markdown notes with front matter.
- `templates/`: HTML shells for header, footer, and layout. - `templates/`: HTML shells for header, footer, and layout.
- `assets/`: CSS and JavaScript shared across pages. - `assets/`: CSS and JavaScript shared across pages.
## Conventions ## Conventions
- Use kebab-case filenames. - Use kebab-case filenames.
- For organization, use sections defined in `sections.json` and include section in filename.
- Keep summaries short; they populate listings and the search index. - Keep summaries short; they populate listings and the search index.
- Add tags to improve search results. - Add tags to improve search results.

View File

@@ -0,0 +1,309 @@
---
title: Gateway Documentation
section: docs
summary: Homelab Gateway — Traefik Reverse Proxy, Authelia SSO, LAN Routing, macvlan IP assignment, and Docker-managed services
tags: [networking, traefik, authelia, infrastructure, docker, home-lab]
nav: 2
---
## Homelab Gateway Stack (Traefik + Authelia)
This document describes the architecture and configuration of the homelab gateway stack — the system responsible for HTTPS termination, reverse proxying, SSO-protected internal services, LAN-to-LAN routing, and stable external exposure.
This documentation intentionally mirrors the SoloForge Gitea documentation format for consistency across the infrastructure stack.
---
## 1. Overview
The Gateway Stack provides:
- **Reverse proxying via Traefik v3**
- **Authentication & SSO via Authelia v4**
- **Per-service routing with Docker labels or file-provider YAML**
- **macvlan-based static IP assignment (Traefik appears as its own LAN host)**
- **Secure exposure of internal services to the outside world**
- **ForwardAuth protection for otherwise unauthenticated apps**
- **Ability to proxy both Docker-based and remote LAN-based services**
This system replaces the previous Traefik + YAML editor stack and centralizes the “front door” of the homelab under a configuration that is fully self-documented, reproducible, and Git-tracked.
---
## 2. System Layout
### 2.1 Host Machine
- Bare-metal machine (temporarily — will migrate back into Proxmox)
- Debian/Ubuntu-based environment
- Docker + Docker Compose v2+
- macvlan network configured so Traefik has a **dedicated LAN IP** (`192.168.2.253`)
### 2.2 Static Network Assignment (macvlan)
Traefik receives its own IP on the LAN:
```bash
docker network create -d macvlan \
--subnet=192.168.2.0/24 \
--gateway=192.168.2.1 \
-o parent=eth0 \
traefik_macvlan
```
**Rationale:**
- Makes Traefik behave like a true router on the network
- Allows the router to port-forward directly to Traefik instead of the host
- Eliminates host→Traefik conflicts
- Clean separation once this stack eventually lives in a VM or LXC again
### 2.3 Directory Structure
```bash
/gateway/
├── traefik/
│ ├── traefik.yml # Static config
│ ├── config/ # File provider configs (GUI writes here)
│ ├── cert/ # ACME cert store
│ └── logs/ # Traefik logs
├── authelia/
│ ├── config/ # Authelia configuration.yml + users file
│ ├── secrets/ # Env-based secrets, if used
│ └── logs/ # Authelia logs
├── traefik-gui/
│ └── db/ # GUI internal sqlite db
└── traefik-stack.yml # Main stack
```
---
## 3. Traefik Deployment
### 3.1 Traefik Service
Launched via Compose with two network attachments:
- `traefik_macvlan` → gives Traefik its LAN IP
- `proxy` → internal Docker network for app connectivity
Traefik is responsible for:
- HTTPS certificates via Lets Encrypt (DNS-01 with Cloudflare)
- Routing per-service via hostnames
- Applying Authelia ForwardAuth checks
- Proxying internal Docker services and remote LAN machines
### 3.2 File Provider
The file provider watches:
`/traefik/config/`
Traefik GUI writes dynamic route definitions into this folder.
### 3.3 Docker Provider
Configured with:
```yaml
providers:
docker:
endpoint: "unix:///var/run/docker.sock"
exposedByDefault: false
network: proxy
```
Allows Traefik to auto-discover containers with labels on the `proxy` network.
---
## 4. Authelia (SSO / Authentication)
### 4.1 Purpose
Authelia handles:
- Single-sign-on for internal apps
- Multi-factor auth (optional)
- Protecting services with ForwardAuth (even those without native authentication)
- Login portal at `auth.keithsolomon.net`
### 4.2 Configuration Notes
- Config lives at `/authelia/config/configuration.yml`
- Secrets (session, storage encryption, reset-password JWT) supplied via environment variables
- SMTP notifier is required — missing credentials cause Authelia to crash-loop
- Logging set to stdout during debugging, file logging available once stable
### 4.3 ForwardAuth Middleware
Exposed to Traefik via labels:
`traefik.http.middlewares.authelia.forwardAuth.address=http://authelia:9091/api/authz/forward-auth`
Any protected service adds:
`traefik.http.routers.<service>.middlewares=authelia@docker`
---
## 5. LAN Services & Routing
### 5.1 Dockerized Services (local to gateway)
Containers running on the same host as Traefik should:
1. Join the `proxy` network
2. Use Traefik Docker labels
3. NOT be accessed via the host IP (192.168.2.9) when Traefik is on macvlan
- macvlan cannot reliably reach the hosts bridge interfaces
Example (Sonarr):
```yaml
labels:
- "traefik.enable=true"
- "traefik.docker.network=proxy"
- "traefik.http.routers.sonarr.rule=Host(`sonarr.keithsolomon.net`)"
- "traefik.http.services.sonarr.loadbalancer.server.port=8989"
```
Traefik will connect to `http://sonarr:8989` over the internal Docker network.
### 5.2 Remote LAN Services (other machines)
These should be added via the Traefik GUI (file provider), using the machines actual LAN IP:
Example:
```yaml
services:
docs:
loadBalancer:
servers:
- url: "http://192.168.2.51:8083"
```
These are not affected by the macvlan limitation.
---
## 6. Gateway Compose Stack
**Services included:**
- traefik
- authelia
- traefik-gui
- eventually all internal homelab UIs
**Networks:**
```yaml
networks:
traefik_macvlan:
external: true
proxy:
name: proxy
driver: bridge
```
**Important: .env File**
Stores secrets such as Authelia session keys and SMTP passwords. Generate strong random hex values for the secrets using `openssl rand -hex 64`.
```bash
AUTHELIA_SESSION_SECRET=<hex>
AUTHELIA_STORAGE_ENCRYPTION_KEY=<hex>
AUTHELIA_IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET=<hex>
AUTHELIA_NOTIFIER_SMTP_PASSWORD=<app-password>
```
---
## 7. Operational Notes
### 7.1 Authelia Startup Failures
Authelia will crash-loop if:
- SMTP notifier missing password
- storage encryption key missing
- identity_validation.reset_password.jwt_secret missing
- configuration.yml malformed
Always check:
```bash
docker logs Authelia
```
### 7.2 macvlan Gotcha (Critical)
Traefik on macvlan **cannot reach the hosts own IP (192.168.2.9)**.
Use container names on the `proxy` network for same-host services.
### 7.3 Debugging Backend Connectivity
To simulate Traefiks point of view:
```bash
docker run --rm -it --network=proxy alpine sh
apk add curl
curl -v http://sonarr:8989
curl -v http://192.168.2.51:8083
```
If container → hostIP fails, but container → container works, its macvlan isolation.
### 7.4 Traefik Dashboard
Available at `https://tfk.keithsolomon.net`
Protected by Authelia.
Check:
- Routers → status, errors, middlewares
- Services → backend URLs
- Middlewares → ensure authelia@docker exists
---
## 8. Future Improvements
- Migrate gateway stack into Proxmox VM/LXC
- Replace Traefik-GUI with a cleaner UI (or maintain YAML by hand)
- Add Prometheus metrics for request/latency monitoring
- Add fail2ban or rate-limiting middleware
- Add OIDC provider configuration to Authelia for full single sign-on
- Automate propagation of routes from remote hosts (pull or push model)
---
## TL;DR Cheat Sheet
**Traefik not routing?**
- → Check router → check service → check backend URL
- → If backend is on the same machine: use container name, not host IP
**Authelia crash-loop?**
- → Missing SMTP password
- → Missing storage/session/jwt secrets
- → Look inside /authelia/logs/authelia.log
**Adding a remote LAN service?**
- → Use Traefik GUI
- → Backend = `http://192.168.2.X:PORT`
- → Protect via Authelia middleware as needed
**Adding a local Docker service?**
- → Put it on the `proxy` network
- → Add Traefik labels
- → Use `loadbalancer.server.port`, not host IP
- → Do NOT use `192.168.2.9` when Traefik is on macvlan

View File

@@ -3,7 +3,7 @@ title: SoloForge Documentation
section: docs section: docs
summary: SoloForge Infrastructure Documentation for Self-Hosted Gitea with Actions Runner on Hetzner summary: SoloForge Infrastructure Documentation for Self-Hosted Gitea with Actions Runner on Hetzner
tags: [servers, infrasctructure, gitea, ci/cd, hetzner, docker] tags: [servers, infrasctructure, gitea, ci/cd, hetzner, docker]
nav: 1 nav: 2
--- ---
## Self-Hosted Gitea + Actions Runner (Hetzner Deployment) ## Self-Hosted Gitea + Actions Runner (Hetzner Deployment)

View File

@@ -1,7 +1,7 @@
--- ---
title: My Servers title: My Servers
section: network section: infra
summary: My Servers. summary: Documentation relating to the various servers I use and maintain
tags: [hardware, servers, infrasctructure] tags: [hardware, servers, infrasctructure]
nav: 1 nav: 1
--- ---

View File

@@ -0,0 +1,28 @@
---
title: Repos of Note
section: other
summary: My personal collection of repositories, both mine and by others, that I find useful
tags: [repos, projects, tools]
nav: 1
---
## My Repositories
### Public Repositories
- **[Dev Notes](https://git.keithsolomon.net/keith/dev-notes)** (This repo)
- Lightweight static notes site built from Markdown with front matter.
- **[Dotfiles](https://github.com/ksolomon/dotfiles)**
- My personal configuration files for various tools and environments.
- **[Random Scripts](https://github.com/ksolomon/Random-Scripts)**
- A collection of miscellaneous scripts I've written for various tasks and platforms.
- **[TODO-Sync](https://github.com/Solo-Web-Works/TODO-Sync)**
- Sync TODO comments in code to GitHub Issues.
### Private Repositories
- **[Project Notes](https://github.com/ksolomon/notes)**
- Notes and documentation for various projects I've worked on.
---

18
templates/sections.json Normal file
View File

@@ -0,0 +1,18 @@
[
{
"name": "Dev Notes",
"description": "Documentation relating to the Dev Notes project."
},
{
"name": "Docs",
"description": "Documentation relating to various aspects of my projects."
},
{
"name": "Infra",
"description": "Network and server documentation."
},
{
"name": "Other",
"description": "Miscellaneous stuff that doesn't fit in other categories."
}
]