K3s cluster on Proxmox with WireGuard mesh networking

Replaced Headscale (too buggy in 0.28.x — random node drops) with direct
WireGuard hub-and-spoke + full mesh. 7 Proxmox VMs across 3 hosts form a
K3s v1.34.6 cluster: 3 control-plane/etcd nodes, 4 workers.

Running services: postgres, mariadb, ghost (x3), forgejo, authentik.
All unpinned services use local-path StorageClass. Databases pinned to
pve-worker and adder-worker with local PVs.

Includes VM provisioning scripts (create-debian-template.sh, clone-vm.sh),
K3s manifests for all services, and full deployment docs in k3s/README.md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Samantha Atkins 2026-04-07 01:23:13 -04:00
parent a9876bf5b5
commit 759ef949bc
26 changed files with 2231 additions and 0 deletions

142
K3s-SESSION-STATE.md Normal file
View file

@ -0,0 +1,142 @@
# K3s Session State
# Saved: 2026-04-06 (end of session 3)
## Current State
New Proxmox-based K3s cluster in progress. VirtualBox cluster retired.
All 7 Proxmox VMs created and on WireGuard mesh. K3s not yet installed.
Old VirtualBox services (ghost, forgejo, postgres, mariadb) still running on old cluster until migration complete.
## Proxmox VMs
| Node | vmbr1 IP | WG IP | Proxmox Host | Role |
|---|---|---|---|---|
| pve-control | 10.10.10.151 | 10.0.0.6 | pve | k3s control plane |
| pve-worker | 10.10.10.126 | 10.0.0.7 | pve | k3s worker |
| adder-control | 10.10.10.185 | 10.0.0.8 | adder | k3s control plane |
| adder-worker | 10.10.10.83 | 10.0.0.9 | adder | k3s worker |
| game-control | 10.10.10.158 | 10.0.0.10 | game | k3s control plane |
| game-worker-hdd | 10.10.10.186 | 10.0.0.11 | game | k3s worker (local-lvm/HDD) |
| game-worker-ssd | 10.10.10.153 | 10.0.0.12 | game | k3s worker (game-ssd/NVMe) |
WG IPs 10.0.0.210.0.0.5 reserved (old VirtualBox nodes, do not reuse).
Hub: DO droplet at 138.197.87.251:51820, WG IP 10.0.0.1
## VM Specs
| Node | vCPUs | RAM | Disk | Storage |
|---|---|---|---|---|
| pve-control | 2 | 2GB | 20G | local-lvm |
| pve-worker | 6 | 8GB | 100G | local-lvm |
| adder-control | 2 | 2GB | 20G | local-lvm |
| adder-worker | 6 | 8GB | 100G | local-lvm |
| game-control | 2 | 2GB | 20G | local-lvm |
| game-worker-hdd | 6 | 8GB | 200G | local-lvm (HDD) |
| game-worker-ssd | 10 | 8GB | 200G | game-ssd (NVMe) |
## Network Architecture
- All VMs on vmbr1 (10.10.10.0/24), DHCP
- WireGuard mesh via DO hub — all nodes have static WG IPs (10.0.0.0/24)
- Full mesh: all nodes have each other as explicit WireGuard peers (not just hub-and-spoke)
- K3s will use --flannel-iface=wg0 so all cluster traffic runs over WireGuard
- Caddy at DO hub proxies external traffic to any node's WG IP + NodePort
- Tailscale/Headscale abandoned — too unreliable for cluster networking
## Proxmox Host Specs
- pve: workstation i9-13900KF, 96GB RAM
- adder: Proxmox node with RTX 2070, 4TB NVMe available
- game: Proxmox node with RTX 2070, 16GB RAM, 256GB NVMe (game-ssd) + 2TB HDD (local-lvm)
## VM Provisioning
### Template & Clone Scripts
Scripts at `~/private/Knowledge/repos/homelab/proxmox/scripts/`:
- `create-debian-template.sh <VMID> <NAME> [STORAGE] [BRIDGE]`
- Defaults: STORAGE=local-lvm, BRIDGE=vmbr1
- Bakes in: qemu-guest-agent, curl, wget, nano, rsync, htop, tmux, emacs-nox, nfs-common, tailscale
- Zeroes /etc/machine-id, removes /etc/ssh/ssh_host_* (Cloud-Init regenerates on first boot)
- Does NOT create .ssh or set keys — done post-boot via qm set
- `clone-vm.sh <TEMPLATE_VMID> <NEW_VMID> <NAME> [CORES] [MEMORY_MB] [DISK_SIZE] [STORAGE]`
- Defaults: 2 cores, 2048MB RAM, 20G disk, local-lvm storage
- Full clone, auto-starts the VM
### Post-Clone Formula (confirmed working)
1. Clone: `./clone-vm.sh <template> <vmid> <name> [cores] [mem] [disk] [storage]`
2. Get IP: `qm guest cmd <vmid> network-get-interfaces`
3. Set SSH key: `qm set <vmid> --sshkeys <pubkey-file>`
4. Reboot VM: `qm reboot <vmid>`
5. SSH in: `ssh samantha@<ip>`
6. Configure WireGuard on the VM
### VMID Convention
- pve: 100-199 (templates at 199)
- adder: 200-299 (templates at 299 — currently 200 exists, destroy after use)
- game: 300-399 (templates at 399 — currently 300 exists, destroy after use)
### Useful Proxmox CLI
- `qm guest cmd <VMID> network-get-interfaces` — get VM IP
- `qm set <VMID> --vga std --delete serial0` — fix serial console
- `qm destroy <VMID> --purge` — remove VM
- `qm list` — list all VMs
- `vgs` — check local-lvm free space
- `pvesh get /nodes/<nodename>/status` — CPU/memory usage
## Immediate Next Steps
1. Install K3s on pve-control first (--cluster-init)
2. Join adder-control and game-control as control plane peers
3. Join all 4 workers
4. Label workers and GPU nodes
5. Create namespaces: sjasoft, fulfillment, privacy-practice
6. Migrate services from old VirtualBox cluster
## K3s Install — see k3s/README.md for full commands
- Control plane uses --cluster-init on first node, --server on subsequent nodes
- All nodes use --flannel-iface=wg0 and --node-ip=<wg-ip>
- Traefik disabled on all nodes
- 3 control plane nodes for HA etcd (tolerates 1 failure)
## Running Services (old VirtualBox cluster — not yet migrated)
- postgres:16 — ClusterIP:5432
- mariadb:11 — ClusterIP:3306
- ghost1/2/3 — NodePorts 32368/32369/32370
- forgejo:9 — NodePort 32371, git.sjasoft.com
## NodePort Registry
| Port | Service | Namespace |
|---|---|---|
| 32368 | ghost1 | fulfillment |
| 32369 | ghost2 | fulfillment |
| 32370 | ghost3 | fulfillment |
| 32371 | forgejo | sjasoft |
## Manifests
All in Knowledge/repos/homelab/k3s/:
- k3s/postgres/postgres.yaml
- k3s/mariadb/mariadb.yaml
- k3s/ghost/ghost.yaml
- k3s/forgejo/forgejo.yaml
- k3s/README.md (authoritative WG mesh table + K3s install commands)
## Remaining Services to Port (from Proxmox Docker stack)
- authentik.yml — SSO (postgres)
- n8n.yml — automation (postgres)
- vaultwarden.yml — passwords
- nats.yml — messaging
- monerod.yml — monero node
- snikket.yml — XMPP
- synapse.yml — Matrix
## Known Issues / Notes
- Tailscale/Headscale abandoned — unreliable, randomly drops nodes, requires manual reconnect
- WireGuard full mesh is the correct approach for K3s cluster networking
- kubectl requires KUBECONFIG=~/.kube/config in ~/.bashrc on control nodes
- Cross-namespace secrets not supported — keep secrets in same namespace as consumer
- game node only has 16GB RAM — allocate worker VMs conservatively
- game-ssd is only 256GB NVMe — keep disk allocations conservative on game-worker-ssd
- Templates should be destroyed after all clones are complete on each node

248
k3s/README.md Normal file
View file

@ -0,0 +1,248 @@
# K3s Cluster — Setup & Deployment Notes
This is the production cluster running on Proxmox VMs, connected via WireGuard hub-and-spoke.
The VirtualBox learning cluster this replaced is retired.
---
## WireGuard Mesh — Node Assignments
Hub: DO droplet at 138.197.87.251:51820, WG IP 10.0.0.1/24
| Node | vmbr1 IP | WG IP | Proxmox Host |
|---|---|---|---|
| pve-control | 10.10.10.151 | 10.0.0.6 | pve |
| pve-worker | 10.10.10.126 | 10.0.0.7 | pve |
| adder-control | 10.10.10.185 | 10.0.0.8 | adder |
| adder-worker | 10.10.10.83 | 10.0.0.9 | adder |
| game-control | 10.10.10.158 | 10.0.0.10 | game |
| game-worker-hdd | 10.10.10.186 | 10.0.0.11 | game |
| game-worker-ssd | 10.10.10.153 | 10.0.0.12 | game |
IPs 10.0.0.210.0.0.5 are reserved (old VirtualBox K3s nodes, leave alone).
All VMs are Debian Trixie on vmbr1 (10.10.10.0/24). Inter-node traffic runs over WireGuard (10.0.0.0/24).
---
## K3s Install
### Prerequisites — each VM must be on the WireGuard mesh first
WireGuard is configured via wg0.conf on each node (hub-and-spoke through DO droplet).
Verify connectivity: `ping 10.0.0.1` from the node.
### First control plane node (cluster init)
```bash
curl -sfL https://get.k3s.io | INSTALL_K3S_EXEC="--cluster-init --disable traefik \
--node-ip=<10.0.0.x> --flannel-iface=wg0" sh -
# Get token for other nodes to join
sudo cat /var/lib/rancher/k3s/server/node-token
```
### Second and third control plane nodes
```bash
curl -sfL https://get.k3s.io | K3S_URL=https://<control-1-mesh-ip>:6443 K3S_TOKEN=<token> \
INSTALL_K3S_EXEC="--server https://<control-1-mesh-ip>:6443 --disable traefik \
--node-ip=<this-node-mesh-ip> --flannel-iface=wg0" sh -
```
Note: use `--server` not just `K3S_URL` — this is what makes it a control plane peer, not a worker.
etcd requires odd numbers — 3 control nodes tolerates 1 failure. Never stop at 2.
### Workers
```bash
curl -sfL https://get.k3s.io | K3S_URL=https://<any-control-mesh-ip>:6443 K3S_TOKEN=<token> \
INSTALL_K3S_EXEC="--node-ip=<this-node-mesh-ip> --flannel-iface=wg0" sh -
```
### kubeconfig for normal user (on any control node)
```bash
mkdir -p ~/.kube
sudo cp /etc/rancher/k3s/k3s.yaml ~/.kube/config
sudo chown samantha:samantha ~/.kube/config
export KUBECONFIG=~/.kube/config # also add to ~/.bashrc
# Update server IP in config if needed:
sed -i 's/127.0.0.1/<control-1-mesh-ip>/' ~/.kube/config
```
### Label workers
```bash
kubectl label node <name> node-role.kubernetes.io/worker=worker
```
---
## GPU Worker Nodes — adder and game
Both Proxmox hosts `adder` and `game` have RTX 2070 GPUs available for PCIe passthrough.
### Proxmox PCIe passthrough setup (on each Proxmox host)
```bash
# Enable IOMMU in /etc/default/grub:
GRUB_CMDLINE_LINUX_DEFAULT="quiet intel_iommu=on iommu=pt"
# (use amd_iommu=on for AMD hosts)
update-grub
reboot
# Blacklist nvidia drivers on host so GPU is free for passthrough:
echo "blacklist nouveau" >> /etc/modprobe.d/blacklist.conf
echo "blacklist nvidia" >> /etc/modprobe.d/blacklist.conf
update-initramfs -u
reboot
```
In Proxmox UI: VM Hardware → Add → PCI Device → select the RTX 2070 → check "All Functions" and "Primary GPU" if it is the only GPU.
### Inside the GPU worker VM — install NVIDIA drivers
```bash
apt-get install -y linux-headers-$(uname -r)
# Add non-free repo if needed:
apt-get install -y nvidia-driver firmware-misc-nonfree
reboot
# Verify:
nvidia-smi
```
### Install NVIDIA device plugin in K3s
```bash
kubectl create -f https://raw.githubusercontent.com/NVIDIA/k8s-device-plugin/v0.14.0/nvidia-device-plugin.yml
```
### Label GPU nodes
```bash
kubectl label node k3s-adder nvidia.com/gpu=true
kubectl label node k3s-game nvidia.com/gpu=true
```
### Verify GPU is schedulable
```bash
kubectl get nodes -o json | jq '.items[].status.capacity'
# Should show nvidia.com/gpu: "1" on adder and game
```
### Scheduling a workload to a GPU node
```yaml
resources:
limits:
nvidia.com/gpu: 1
```
---
## Namespaces — one per venture
```bash
kubectl create namespace sjasoft
kubectl create namespace fulfillment
kubectl create namespace privacy-practice
```
Secrets are always created per namespace — never share secrets across namespaces.
---
## Secrets
Never stored in files with real values. Always create directly on a control node.
```bash
# Pattern — adapt per service and namespace
kubectl create secret generic <name> \
--namespace <namespace> \
--from-literal=<key>='<value>'
# Generate passwords with:
openssl rand -base64 24
```
---
## NodePort Registry
NodePorts must be unique across the entire cluster (range 30000-32767).
Any NodePort is reachable on any node's WireGuard IP — K3s routes internally.
Caddy on each venture ingress VPS proxies to any node's WG IP + NodePort.
| Port | Service | Notes |
|---|---|---|
| 32368 | ghost1 | blog.the-fulfillment.org |
| 32369 | ghost2 | blog.privacy-practice.com |
| 32370 | ghost3 | blog.sjasoft.com |
| 32371 | forgejo | git.sjasoft.com |
| 32372 | authentik (HTTP) | auth.sjasoft.com — use this behind Caddy |
| 32373 | authentik (HTTPS) | skip — Caddy handles TLS |
---
## Caddy Pattern — venture ingress VPS
Each venture has its own ingress VPS with its own public IP. Caddy on each proxies
to a different node's mesh IP for the same cluster — ventures look unrelated from outside.
```
# Example — any node's WG IP works for any NodePort
blog.the-fulfillment.org {
reverse_proxy 10.0.0.6:32368
}
git.sjasoft.com {
reverse_proxy 10.0.0.8:32371
}
auth.sjasoft.com {
reverse_proxy 10.0.0.10:32372
}
```
Pick any node's WG IP per service — they all work. Use different nodes per venture
so ventures look unrelated from outside. See the WireGuard mesh table above for IPs.
---
## Current Deployment Status (2026-04-07)
K3s v1.34.6 cluster fully operational. WireGuard full mesh (direct peer-to-peer over vmbr1,
hub for external traffic). Headscale removed — too buggy (0.28.x dropped nodes randomly).
### Cluster Nodes
| Node | Role | WG IP | Proxmox Host | Resources |
|---|---|---|---|---|
| pve-control | control-plane, etcd | 10.0.0.6 | pve | 2 CPU, 2GB RAM, 20GB |
| pve-worker | worker | 10.0.0.7 | pve | 8 CPU, 58GB RAM, 3.3TB |
| adder-control | control-plane, etcd | 10.0.0.8 | adder | 2 CPU, 2GB RAM, 20GB |
| adder-worker | worker | 10.0.0.9 | adder | 10 CPU, 58GB RAM, 1.7TB |
| game-control | control-plane, etcd | 10.0.0.10 | game | 2 CPU, 2GB RAM, 20GB |
| game-worker-hdd | worker | 10.0.0.11 | game | 4 CPU, 6GB RAM, 1.4TB HDD |
| game-worker-ssd | worker | 10.0.0.12 | game | 10 CPU, 8GB RAM, 200GB SSD |
### Running Services
| Service | Node | NodePort | Domain | Status |
|---|---|---|---|---|
| postgres:16 | pve-worker (pinned) | ClusterIP | — | running |
| mariadb:11 | adder-worker (pinned) | ClusterIP | — | running |
| ghost1 | unpinned | 32368 | blog.the-fulfillment.org | running |
| ghost2 | unpinned | 32369 | blog.privacy-practice.com | running |
| ghost3 | unpinned | 32370 | blog.sjasoft.com | running |
| forgejo:9 | unpinned | 32371 | git.sjasoft.com | running |
| authentik server | unpinned | 32372 | auth.sjasoft.com | running |
| authentik worker | unpinned | — | — | running |
### Remaining Services to Deploy
n8n, nats, vaultwarden, synapse, snikket, monerod
### Next Steps
- Add VirtualBox workstation VMs as workers to this cluster
- Wire up remaining Ghost blogs in Caddy
- Deploy remaining services from k3s/ manifests
### Install Method
K3s was installed using `/etc/rancher/k3s/config.yaml` on each node (not INSTALL_K3S_EXEC env vars,
which get lost in nested SSH). Binary was downloaded once to pve and distributed via scp.
Use `INSTALL_K3S_SKIP_DOWNLOAD=true` when binary is pre-staged.

View file

@ -0,0 +1,45 @@
# Authentik DB Init Job
# Creates authentik database and user in PostgreSQL.
# Run once before deploying Authentik.
#
# Deploy:
# kubectl create secret generic authentik-secret \
# --namespace <ns> \
# --from-literal=db-password='<password>' \
# --from-literal=secret-key='<random-50-chars>'
# kubectl apply -f authentik-db-init.yaml -n <ns>
#
# Watch completion:
# kubectl get jobs -n <ns> -w
# kubectl logs job/authentik-db-init -n <ns>
apiVersion: batch/v1
kind: Job
metadata:
name: authentik-db-init
spec:
template:
spec:
restartPolicy: OnFailure
containers:
- name: authentik-db-init
image: postgres:16
env:
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: postgres-secret
key: password
- name: AUTHENTIK_DB_PASSWORD
valueFrom:
secretKeyRef:
name: authentik-secret
key: db-password
command:
- /bin/sh
- -c
- |
psql -h postgres -U postgres <<EOF
CREATE USER authentik_user WITH PASSWORD '${AUTHENTIK_DB_PASSWORD}';
CREATE DATABASE authentik_db OWNER authentik_user;
EOF

View file

@ -0,0 +1,163 @@
# Authentik — SSO / identity provider
# PostgreSQL backend via cluster DNS: postgres
# No Redis required as of 2026.2.x
# Unpinned — scheduler places freely, local-path PVCs
# NodePort 32372 (HTTP), 32373 (HTTPS)
#
# Deploy:
# kubectl create secret generic authentik-secret \
# --namespace <ns> \
# --from-literal=db-password='<password>' \
# --from-literal=secret-key='<random-50-chars>'
# kubectl apply -f authentik-db-init.yaml -n <ns>
# kubectl get jobs -n <ns> -w # wait for completion
# kubectl apply -f authentik.yaml -n <ns>
#
# Initial setup wizard: http://<any-node-mesh-ip>:32372/if/flow/initial-setup/
#
# Generate secret-key with: openssl rand -base64 36
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: authentik-media-pvc
spec:
accessModes:
- ReadWriteOnce
storageClassName: local-path
resources:
requests:
storage: 5Gi
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: authentik-certs-pvc
spec:
accessModes:
- ReadWriteOnce
storageClassName: local-path
resources:
requests:
storage: 1Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: authentik-server
spec:
replicas: 1
selector:
matchLabels:
app: authentik-server
template:
metadata:
labels:
app: authentik-server
spec:
containers:
- name: authentik-server
image: ghcr.io/goauthentik/server:2026.2.1
command: ["ak", "server"]
env:
- name: AUTHENTIK_SECRET_KEY
valueFrom:
secretKeyRef:
name: authentik-secret
key: secret-key
- name: AUTHENTIK_POSTGRESQL__HOST
value: postgres
- name: AUTHENTIK_POSTGRESQL__PORT
value: "5432"
- name: AUTHENTIK_POSTGRESQL__NAME
value: authentik_db
- name: AUTHENTIK_POSTGRESQL__USER
value: authentik_user
- name: AUTHENTIK_POSTGRESQL__PASSWORD
valueFrom:
secretKeyRef:
name: authentik-secret
key: db-password
ports:
- containerPort: 9000
- containerPort: 9443
volumeMounts:
- name: media
mountPath: /media
volumes:
- name: media
persistentVolumeClaim:
claimName: authentik-media-pvc
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: authentik-worker
spec:
replicas: 1
selector:
matchLabels:
app: authentik-worker
template:
metadata:
labels:
app: authentik-worker
spec:
containers:
- name: authentik-worker
image: ghcr.io/goauthentik/server:2026.2.1
command: ["ak", "worker"]
env:
- name: AUTHENTIK_SECRET_KEY
valueFrom:
secretKeyRef:
name: authentik-secret
key: secret-key
- name: AUTHENTIK_POSTGRESQL__HOST
value: postgres
- name: AUTHENTIK_POSTGRESQL__PORT
value: "5432"
- name: AUTHENTIK_POSTGRESQL__NAME
value: authentik_db
- name: AUTHENTIK_POSTGRESQL__USER
value: authentik_user
- name: AUTHENTIK_POSTGRESQL__PASSWORD
valueFrom:
secretKeyRef:
name: authentik-secret
key: db-password
volumeMounts:
- name: media
mountPath: /media
- name: certs
mountPath: /certs
volumes:
- name: media
persistentVolumeClaim:
claimName: authentik-media-pvc
- name: certs
persistentVolumeClaim:
claimName: authentik-certs-pvc
---
apiVersion: v1
kind: Service
metadata:
name: authentik
spec:
selector:
app: authentik-server
ports:
- name: http
port: 9000
targetPort: 9000
nodePort: 32372
- name: https
port: 9443
targetPort: 9443
nodePort: 32373
type: NodePort

View file

@ -0,0 +1,43 @@
# Forgejo DB Init Job
# Creates forgejo database and user in PostgreSQL.
# Run once before deploying Forgejo.
#
# Deploy:
# kubectl create secret generic forgejo-secret \
# --from-literal=db-password='<password>'
# kubectl apply -f forgejo-db-init.yaml
#
# Watch completion:
# kubectl get jobs -w
# kubectl logs job/forgejo-db-init
apiVersion: batch/v1
kind: Job
metadata:
name: forgejo-db-init
spec:
template:
spec:
restartPolicy: OnFailure
containers:
- name: forgejo-db-init
image: postgres:16
env:
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: postgres-secret
key: password
- name: FORGEJO_DB_PASSWORD
valueFrom:
secretKeyRef:
name: forgejo-secret
key: db-password
command:
- /bin/sh
- -c
- |
psql -h postgres -U postgres <<EOF
CREATE USER forgejo_user WITH PASSWORD '${FORGEJO_DB_PASSWORD}';
CREATE DATABASE forgejo_db OWNER forgejo_user;
EOF

86
k3s/forgejo/forgejo.yaml Normal file
View file

@ -0,0 +1,86 @@
# Forgejo — self-hosted Git forge
# PostgreSQL backend via cluster DNS: postgres
# Unpinned — scheduler places freely, local-path PVC for /data
# NodePort 32371
#
# Deploy:
# kubectl create secret generic forgejo-secret \
# --from-literal=db-password='<password>'
# kubectl apply -f forgejo-db-init.yaml
# kubectl get jobs -w # wait for completion
# kubectl apply -f forgejo.yaml
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: forgejo-pvc
spec:
accessModes:
- ReadWriteOnce
storageClassName: local-path
resources:
requests:
storage: 10Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: forgejo
spec:
replicas: 1
selector:
matchLabels:
app: forgejo
template:
metadata:
labels:
app: forgejo
spec:
containers:
- name: forgejo
image: codeberg.org/forgejo/forgejo:9
env:
- name: USER_UID
value: "1000"
- name: USER_GID
value: "1000"
- name: FORGEJO__database__DB_TYPE
value: postgres
- name: FORGEJO__database__HOST
value: postgres:5432
- name: FORGEJO__database__NAME
value: forgejo_db
- name: FORGEJO__database__USER
value: forgejo_user
- name: FORGEJO__database__PASSWD
valueFrom:
secretKeyRef:
name: forgejo-secret
key: db-password
- name: FORGEJO__server__HTTP_PORT
value: "3000"
ports:
- containerPort: 3000
volumeMounts:
- name: forgejo-data
mountPath: /data
volumes:
- name: forgejo-data
persistentVolumeClaim:
claimName: forgejo-pvc
---
apiVersion: v1
kind: Service
metadata:
name: forgejo
spec:
selector:
app: forgejo
ports:
- port: 3000
targetPort: 3000
nodePort: 32371
type: NodePort

View file

@ -0,0 +1,60 @@
# Ghost DB Init Job
# Creates Ghost databases and users in MariaDB.
# Run once before deploying Ghost instances.
# Runs in default namespace — can access all secrets directly.
#
# Apply with:
# kubectl apply -f ghost-db-init.yaml
#
# Watch completion:
# kubectl get jobs -w
# kubectl logs job/ghost-db-init
apiVersion: batch/v1
kind: Job
metadata:
name: ghost-db-init
spec:
template:
spec:
restartPolicy: OnFailure
containers:
- name: ghost-db-init
image: mariadb:11
env:
- name: MARIADB_ROOT_PASSWORD
valueFrom:
secretKeyRef:
name: mariadb-secret
key: root-password
- name: GHOST1_PASSWORD
valueFrom:
secretKeyRef:
name: ghost-secrets
key: ghost1-db-password
- name: GHOST2_PASSWORD
valueFrom:
secretKeyRef:
name: ghost-secrets
key: ghost2-db-password
- name: GHOST3_PASSWORD
valueFrom:
secretKeyRef:
name: ghost-secrets
key: ghost3-db-password
command:
- /bin/sh
- -c
- |
mariadb -h mariadb -u root -p"${MARIADB_ROOT_PASSWORD}" <<EOF
CREATE DATABASE IF NOT EXISTS ghost1_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE DATABASE IF NOT EXISTS ghost2_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE DATABASE IF NOT EXISTS ghost3_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS 'ghost1_user'@'%' IDENTIFIED BY '${GHOST1_PASSWORD}';
CREATE USER IF NOT EXISTS 'ghost2_user'@'%' IDENTIFIED BY '${GHOST2_PASSWORD}';
CREATE USER IF NOT EXISTS 'ghost3_user'@'%' IDENTIFIED BY '${GHOST3_PASSWORD}';
GRANT ALL ON ghost1_db.* TO 'ghost1_user'@'%';
GRANT ALL ON ghost2_db.* TO 'ghost2_user'@'%';
GRANT ALL ON ghost3_db.* TO 'ghost3_user'@'%';
FLUSH PRIVILEGES;
EOF

View file

@ -0,0 +1,26 @@
# Ghost DB passwords
# Replace CHANGEME values before applying.
# Generate with: openssl rand -base64 24
#
# Apply with:
# kubectl apply -f ghost-secrets.yaml
#
# Or create directly without a file:
# kubectl create secret generic ghost-secrets \
# --namespace ghost \
# --from-literal=ghost1-db-password='<password>' \
# --from-literal=ghost2-db-password='<password>' \
# --from-literal=ghost3-db-password='<password>'
#
# NOTE: Do not commit this file with real passwords to git.
apiVersion: v1
kind: Secret
metadata:
name: ghost-secrets
namespace: ghost
type: Opaque
stringData:
ghost1-db-password: CHANGEME
ghost2-db-password: CHANGEME
ghost3-db-password: CHANGEME

243
k3s/ghost/ghost.yaml Normal file
View file

@ -0,0 +1,243 @@
# Ghost blogs — three instances
# Unpinned — scheduler places them anywhere
# Storage via local-path (K3s default provisioner)
# MariaDB connection via cluster DNS: mariadb
# Runs in default namespace
#
# Deploy:
# kubectl create secret generic ghost-secrets \
# --from-literal=ghost1-db-password='<password>' \
# --from-literal=ghost2-db-password='<password>' \
# --from-literal=ghost3-db-password='<password>'
# kubectl apply -f ghost-db-init.yaml
# kubectl get jobs -w # wait for completion
# kubectl apply -f ghost.yaml
---
# Ghost 1 — blog.the-fulfillment.org — port 2368
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: ghost1-pvc
spec:
accessModes:
- ReadWriteOnce
storageClassName: local-path
resources:
requests:
storage: 5Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: ghost1
spec:
replicas: 1
selector:
matchLabels:
app: ghost1
template:
metadata:
labels:
app: ghost1
spec:
containers:
- name: ghost1
image: ghost:5-alpine
env:
- name: database__client
value: mysql
- name: database__connection__host
value: mariadb
- name: database__connection__port
value: "3306"
- name: database__connection__user
value: ghost1_user
- name: database__connection__password
valueFrom:
secretKeyRef:
name: ghost-secrets
key: ghost1-db-password
- name: database__connection__database
value: ghost1_db
- name: url
value: https://blog.the-fulfillment.org
ports:
- containerPort: 2368
volumeMounts:
- name: ghost1-storage
mountPath: /var/lib/ghost/content
volumes:
- name: ghost1-storage
persistentVolumeClaim:
claimName: ghost1-pvc
---
apiVersion: v1
kind: Service
metadata:
name: ghost1
spec:
selector:
app: ghost1
ports:
- port: 2368
targetPort: 2368
nodePort: 32368
type: NodePort
---
# Ghost 2 — port 2369
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: ghost2-pvc
spec:
accessModes:
- ReadWriteOnce
storageClassName: local-path
resources:
requests:
storage: 5Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: ghost2
spec:
replicas: 1
selector:
matchLabels:
app: ghost2
template:
metadata:
labels:
app: ghost2
spec:
containers:
- name: ghost2
image: ghost:5-alpine
env:
- name: database__client
value: mysql
- name: database__connection__host
value: mariadb
- name: database__connection__port
value: "3306"
- name: database__connection__user
value: ghost2_user
- name: database__connection__password
valueFrom:
secretKeyRef:
name: ghost-secrets
key: ghost2-db-password
- name: database__connection__database
value: ghost2_db
- name: url
value: https://blog.privacy-practice.com
- name: server__port
value: "2369"
ports:
- containerPort: 2369
volumeMounts:
- name: ghost2-storage
mountPath: /var/lib/ghost/content
volumes:
- name: ghost2-storage
persistentVolumeClaim:
claimName: ghost2-pvc
---
apiVersion: v1
kind: Service
metadata:
name: ghost2
spec:
selector:
app: ghost2
ports:
- port: 2369
targetPort: 2369
nodePort: 32369
type: NodePort
---
# Ghost 3 — port 2370
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: ghost3-pvc
spec:
accessModes:
- ReadWriteOnce
storageClassName: local-path
resources:
requests:
storage: 5Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: ghost3
spec:
replicas: 1
selector:
matchLabels:
app: ghost3
template:
metadata:
labels:
app: ghost3
spec:
containers:
- name: ghost3
image: ghost:5-alpine
env:
- name: database__client
value: mysql
- name: database__connection__host
value: mariadb
- name: database__connection__port
value: "3306"
- name: database__connection__user
value: ghost3_user
- name: database__connection__password
valueFrom:
secretKeyRef:
name: ghost-secrets
key: ghost3-db-password
- name: database__connection__database
value: ghost3_db
- name: url
value: https://blog.sjasoft.com
- name: server__port
value: "2370"
ports:
- containerPort: 2370
volumeMounts:
- name: ghost3-storage
mountPath: /var/lib/ghost/content
volumes:
- name: ghost3-storage
persistentVolumeClaim:
claimName: ghost3-pvc
---
apiVersion: v1
kind: Service
metadata:
name: ghost3
spec:
selector:
app: ghost3
ports:
- port: 2370
targetPort: 2370
nodePort: 32370
type: NodePort

View file

@ -0,0 +1,17 @@
# MariaDB secret
# Replace CHANGEME with your actual password before applying.
# Generate a good one with: openssl rand -base64 24
#
# Apply with:
# kubectl apply -f mariadb-secret.yaml
#
# NOTE: Do not commit this file with a real password to git.
apiVersion: v1
kind: Secret
metadata:
name: mariadb-secret
namespace: databases
type: Opaque
stringData:
root-password: CHANGEME

91
k3s/mariadb/mariadb.yaml Normal file
View file

@ -0,0 +1,91 @@
# MariaDB 11 — pinned to node: adder-worker
# Local PersistentVolume: 25GB on adder's disk
# Runs in default namespace
#
# Deploy:
# kubectl create secret generic mariadb-secret --from-literal=root-password='<password>'
# kubectl apply -f mariadb.yaml
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: mariadb-pv
spec:
capacity:
storage: 25Gi
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: local-storage
local:
path: /var/lib/k3s-data/mariadb
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- adder-worker
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mariadb-pvc
spec:
accessModes:
- ReadWriteOnce
storageClassName: local-storage
resources:
requests:
storage: 25Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: mariadb
spec:
replicas: 1
selector:
matchLabels:
app: mariadb
template:
metadata:
labels:
app: mariadb
spec:
nodeName: adder-worker
containers:
- name: mariadb
image: mariadb:11
env:
- name: MYSQL_ROOT_PASSWORD
valueFrom:
secretKeyRef:
name: mariadb-secret
key: root-password
ports:
- containerPort: 3306
volumeMounts:
- name: mariadb-storage
mountPath: /var/lib/mysql
volumes:
- name: mariadb-storage
persistentVolumeClaim:
claimName: mariadb-pvc
---
apiVersion: v1
kind: Service
metadata:
name: mariadb
spec:
selector:
app: mariadb
ports:
- port: 3306
targetPort: 3306
type: ClusterIP

82
k3s/monerod/monerod.yaml Normal file
View file

@ -0,0 +1,82 @@
# Monerod — Monero full node (pruned)
# NAS-backed PVC for blockchain data — unpinned, free to migrate across nodes
# Ban list stored on NAS alongside blockchain data
# NodePorts: 32379 (P2P), 32380 (restricted RPC)
#
# Prerequisites:
# NAS share /volume1/k3s/monerod must exist on Synology
# Copy ban list to NAS: /volume1/k3s/monerod/ban_list.txt
# nas-pv.yaml must be applied first
# nfs-common installed on all worker VMs
#
# Deploy:
# kubectl apply -f ../storage/nas-pv.yaml # once, if not already applied
# kubectl apply -f monerod.yaml -n <ns>
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: monerod-pvc
spec:
accessModes:
- ReadWriteMany
storageClassName: nas-nfs
resources:
requests:
storage: 200Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: monerod
spec:
replicas: 1
selector:
matchLabels:
app: monerod
template:
metadata:
labels:
app: monerod
spec:
containers:
- name: monerod
image: ghcr.io/sethforprivacy/simple-monerod:latest
args:
- --rpc-restricted-bind-ip=0.0.0.0
- --rpc-restricted-bind-port=18089
- --no-igd
- --enable-dns-blocklist
- --ban-list=/home/monero/.bitmonero/ban_list.txt
- --prune-blockchain
ports:
- containerPort: 18080
- containerPort: 18089
volumeMounts:
- name: monerod-data
mountPath: /home/monero/.bitmonero
volumes:
- name: monerod-data
persistentVolumeClaim:
claimName: monerod-pvc
---
apiVersion: v1
kind: Service
metadata:
name: monerod
spec:
selector:
app: monerod
ports:
- name: p2p
port: 18080
targetPort: 18080
nodePort: 32379
- name: rpc
port: 18089
targetPort: 18089
nodePort: 32380
type: NodePort

44
k3s/n8n/n8n-db-init.yaml Normal file
View file

@ -0,0 +1,44 @@
# n8n DB Init Job
# Creates n8n database and user in PostgreSQL.
# Run once before deploying n8n.
#
# Deploy:
# kubectl create secret generic n8n-secret \
# --namespace <ns> \
# --from-literal=db-password='<password>'
# kubectl apply -f n8n-db-init.yaml -n <ns>
#
# Watch completion:
# kubectl get jobs -n <ns> -w
# kubectl logs job/n8n-db-init -n <ns>
apiVersion: batch/v1
kind: Job
metadata:
name: n8n-db-init
spec:
template:
spec:
restartPolicy: OnFailure
containers:
- name: n8n-db-init
image: postgres:16
env:
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: postgres-secret
key: password
- name: N8N_DB_PASSWORD
valueFrom:
secretKeyRef:
name: n8n-secret
key: db-password
command:
- /bin/sh
- -c
- |
psql -h postgres -U postgres <<EOF
CREATE USER n8n_user WITH PASSWORD '${N8N_DB_PASSWORD}';
CREATE DATABASE n8n_db OWNER n8n_user;
EOF

83
k3s/n8n/n8n.yaml Normal file
View file

@ -0,0 +1,83 @@
# n8n — workflow automation
# PostgreSQL backend via cluster DNS: postgres
# Unpinned — scheduler places freely, local-path PVC
# NodePort 32374
#
# Deploy:
# kubectl create secret generic n8n-secret \
# --namespace <ns> \
# --from-literal=db-password='<password>'
# kubectl apply -f n8n-db-init.yaml -n <ns>
# kubectl get jobs -n <ns> -w # wait for completion
# kubectl apply -f n8n.yaml -n <ns>
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: n8n-pvc
spec:
accessModes:
- ReadWriteOnce
storageClassName: local-path
resources:
requests:
storage: 5Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: n8n
spec:
replicas: 1
selector:
matchLabels:
app: n8n
template:
metadata:
labels:
app: n8n
spec:
containers:
- name: n8n
image: n8nio/n8n:latest
env:
- name: DB_TYPE
value: postgresdb
- name: DB_POSTGRESDB_HOST
value: postgres
- name: DB_POSTGRESDB_PORT
value: "5432"
- name: DB_POSTGRESDB_DATABASE
value: n8n_db
- name: DB_POSTGRESDB_USER
value: n8n_user
- name: DB_POSTGRESDB_PASSWORD
valueFrom:
secretKeyRef:
name: n8n-secret
key: db-password
ports:
- containerPort: 5678
volumeMounts:
- name: n8n-data
mountPath: /home/node/.n8n
volumes:
- name: n8n-data
persistentVolumeClaim:
claimName: n8n-pvc
---
apiVersion: v1
kind: Service
metadata:
name: n8n
spec:
selector:
app: n8n
ports:
- port: 5678
targetPort: 5678
nodePort: 32374
type: NodePort

101
k3s/nats/nats.yaml Normal file
View file

@ -0,0 +1,101 @@
# NATS — JetStream-enabled message broker
# JetStream enabled with persistent storage via local-path PVC
# Unpinned — scheduler places freely
# NodePorts: 32376 (client), 32377 (websocket), 32378 (monitoring)
#
# Deploy:
# kubectl apply -f nats.yaml -n <ns>
#
# Internal cluster DNS: nats:4222
# WebSocket: nats:8080
# Monitoring: nats:8222
---
apiVersion: v1
kind: ConfigMap
metadata:
name: nats-config
data:
nats.conf: |
jetstream {
store_dir: /data
}
http_port: 8222
websocket {
port: 8080
no_tls: true
}
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: nats-pvc
spec:
accessModes:
- ReadWriteOnce
storageClassName: local-path
resources:
requests:
storage: 10Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: nats
spec:
replicas: 1
selector:
matchLabels:
app: nats
template:
metadata:
labels:
app: nats
spec:
containers:
- name: nats
image: nats:latest
command: ["-c", "/etc/nats/nats.conf"]
ports:
- containerPort: 4222
- containerPort: 8080
- containerPort: 8222
volumeMounts:
- name: nats-config
mountPath: /etc/nats
- name: nats-data
mountPath: /data
volumes:
- name: nats-config
configMap:
name: nats-config
- name: nats-data
persistentVolumeClaim:
claimName: nats-pvc
---
apiVersion: v1
kind: Service
metadata:
name: nats
spec:
selector:
app: nats
ports:
- name: client
port: 4222
targetPort: 4222
nodePort: 32376
- name: websocket
port: 8080
targetPort: 8080
nodePort: 32377
- name: monitoring
port: 8222
targetPort: 8222
nodePort: 32378
type: NodePort

View file

@ -0,0 +1,17 @@
# PostgreSQL secret
# Replace CHANGEME with your actual password before applying.
# Generate a good one with: openssl rand -base64 24
#
# Apply with:
# kubectl apply -f postgres-secret.yaml
#
# NOTE: Do not commit this file with a real password to git.
apiVersion: v1
kind: Secret
metadata:
name: postgres-secret
namespace: databases
type: Opaque
stringData:
password: CHANGEME

View file

@ -0,0 +1,93 @@
# PostgreSQL 16 — pinned to node: pve-worker
# Local PersistentVolume: 50GB on pve's disk
# Runs in default namespace
#
# Deploy:
# kubectl create secret generic postgres-secret --from-literal=password='<password>'
# kubectl apply -f postgres.yaml
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: postgres-pv
spec:
capacity:
storage: 50Gi
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: local-storage
local:
path: /var/lib/k3s-data/postgres
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- pve-worker
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: postgres-pvc
spec:
accessModes:
- ReadWriteOnce
storageClassName: local-storage
resources:
requests:
storage: 50Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres
spec:
replicas: 1
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
nodeName: pve-worker
containers:
- name: postgres
image: postgres:16
env:
- name: POSTGRES_USER
value: postgres
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: postgres-secret
key: password
ports:
- containerPort: 5432
volumeMounts:
- name: postgres-storage
mountPath: /var/lib/postgresql/data
volumes:
- name: postgres-storage
persistentVolumeClaim:
claimName: postgres-pvc
---
apiVersion: v1
kind: Service
metadata:
name: postgres
spec:
selector:
app: postgres
ports:
- port: 5432
targetPort: 5432
type: ClusterIP

121
k3s/snikket/snikket.yaml Normal file
View file

@ -0,0 +1,121 @@
# Snikket — XMPP server (Prosody-based)
# Unpinned — scheduler places freely, local-path PVC
# TLS terminated externally by Caddy at venture ingress VPS
# NodePorts: 32381 (web/admin), 32382 (XMPP client), 32383 (XMPP federation), 32384 (file transfer proxy)
#
# Deploy:
# kubectl apply -f snikket.yaml -n <ns>
#
# Caddy must proxy port 80 traffic (invites/admin portal) via NodePort 32381
# XMPP client connections on 32382 must be reachable directly (not HTTP — raw TCP)
# XMPP federation on 32383 must be reachable directly (raw TCP)
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: snikket-pvc
spec:
accessModes:
- ReadWriteOnce
storageClassName: local-path
resources:
requests:
storage: 10Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: snikket-web
spec:
replicas: 1
selector:
matchLabels:
app: snikket-web
template:
metadata:
labels:
app: snikket-web
spec:
containers:
- name: snikket-web
image: snikket/snikket-server:latest
command: ["web"]
ports:
- containerPort: 80
volumeMounts:
- name: snikket-data
mountPath: /snikket
volumes:
- name: snikket-data
persistentVolumeClaim:
claimName: snikket-pvc
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: snikket-server
spec:
replicas: 1
selector:
matchLabels:
app: snikket-server
template:
metadata:
labels:
app: snikket-server
spec:
containers:
- name: snikket-server
image: snikket/snikket-server:latest
command: ["server"]
ports:
- containerPort: 5222
- containerPort: 5269
- containerPort: 5000
volumeMounts:
- name: snikket-data
mountPath: /snikket
volumes:
- name: snikket-data
persistentVolumeClaim:
claimName: snikket-pvc
---
apiVersion: v1
kind: Service
metadata:
name: snikket-web
spec:
selector:
app: snikket-web
ports:
- port: 80
targetPort: 80
nodePort: 32381
type: NodePort
---
apiVersion: v1
kind: Service
metadata:
name: snikket
spec:
selector:
app: snikket-server
ports:
- name: xmpp-client
port: 5222
targetPort: 5222
nodePort: 32382
- name: xmpp-federation
port: 5269
targetPort: 5269
nodePort: 32383
- name: file-transfer
port: 5000
targetPort: 5000
nodePort: 32384
type: NodePort

47
k3s/storage/nas-pv.yaml Normal file
View file

@ -0,0 +1,47 @@
# NAS PersistentVolume — Synology 425+ at 192.168.40.96
# NFS share mounted cluster-wide — any pod can claim storage from it via PVC
# ReadWriteMany — multiple pods on different nodes can mount simultaneously
#
# Prerequisites on every K3s worker VM:
# apt install nfs-common
#
# Deploy (once, cluster-scoped — no namespace):
# kubectl apply -f nas-pv.yaml
#
# Then any service can claim NAS storage with a PVC like:
# storageClassName: nas-nfs
# accessModes: [ReadWriteMany]
#
# Replace /volume1/k3s with your actual NAS share path.
# Create subdirectories on the NAS per service to keep data organised:
# /volume1/k3s/monerod
# /volume1/k3s/vaultwarden
# etc.
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: nas-pv
spec:
capacity:
storage: 40Ti
accessModes:
- ReadWriteMany
persistentVolumeReclaimPolicy: Retain
storageClassName: nas-nfs
mountOptions:
- hard
- nfsvers=4.1
nfs:
server: 192.168.40.96
path: /volume1/k3s
---
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: nas-nfs
provisioner: kubernetes.io/no-provisioner
volumeBindingMode: Immediate
reclaimPolicy: Retain

View file

@ -0,0 +1,50 @@
# Synapse DB Init Job
# Creates synapse database and user in PostgreSQL.
# Synapse requires UTF-8 encoding and C locale — standard CREATE DATABASE won't work.
# Run once before deploying Synapse.
#
# Deploy:
# kubectl create secret generic synapse-secret \
# --namespace <ns> \
# --from-literal=db-password='<password>'
# kubectl apply -f synapse-db-init.yaml -n <ns>
#
# Watch completion:
# kubectl get jobs -n <ns> -w
# kubectl logs job/synapse-db-init -n <ns>
apiVersion: batch/v1
kind: Job
metadata:
name: synapse-db-init
spec:
template:
spec:
restartPolicy: OnFailure
containers:
- name: synapse-db-init
image: postgres:16
env:
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: postgres-secret
key: password
- name: SYNAPSE_DB_PASSWORD
valueFrom:
secretKeyRef:
name: synapse-secret
key: db-password
command:
- /bin/sh
- -c
- |
psql -h postgres -U postgres <<EOF
CREATE USER synapse_user WITH PASSWORD '${SYNAPSE_DB_PASSWORD}';
CREATE DATABASE synapse_db
ENCODING 'UTF8'
LC_COLLATE='C'
LC_CTYPE='C'
template=template0
OWNER synapse_user;
EOF

91
k3s/synapse/synapse.yaml Normal file
View file

@ -0,0 +1,91 @@
# Synapse — Matrix homeserver
# PostgreSQL backend via cluster DNS: postgres
# Unpinned — scheduler places freely, local-path PVC
# NodePort 32385
#
# Deploy:
# kubectl create secret generic synapse-secret \
# --namespace <ns> \
# --from-literal=db-password='<password>'
# kubectl apply -f synapse-db-init.yaml -n <ns>
# kubectl get jobs -n <ns> -w # wait for completion
# kubectl apply -f synapse.yaml -n <ns>
#
# First boot generates /data/homeserver.yaml automatically.
# After first boot, exec into the pod and update homeserver.yaml
# to add the PostgreSQL database config (replaces default SQLite):
#
# database:
# name: psycopg2
# args:
# user: synapse_user
# password: <from synapse-secret>
# database: synapse_db
# host: postgres
# cp_min: 5
# cp_max: 10
#
# Then restart the deployment:
# kubectl rollout restart deployment/synapse -n <ns>
#
# Set SYNAPSE_SERVER_NAME to the actual Matrix domain before deploying.
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: synapse-pvc
spec:
accessModes:
- ReadWriteOnce
storageClassName: local-path
resources:
requests:
storage: 20Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: synapse
spec:
replicas: 1
selector:
matchLabels:
app: synapse
template:
metadata:
labels:
app: synapse
spec:
containers:
- name: synapse
image: matrixdotorg/synapse:latest
env:
- name: SYNAPSE_SERVER_NAME
value: "matrix.sjasoft.com"
- name: SYNAPSE_REPORT_STATS
value: "no"
ports:
- containerPort: 8008
volumeMounts:
- name: synapse-data
mountPath: /data
volumes:
- name: synapse-data
persistentVolumeClaim:
claimName: synapse-pvc
---
apiVersion: v1
kind: Service
metadata:
name: synapse
spec:
selector:
app: synapse
ports:
- port: 8008
targetPort: 8008
nodePort: 32385
type: NodePort

View file

@ -0,0 +1,83 @@
# Vaultwarden — self-hosted Bitwarden-compatible password manager
# SQLite backend — data persisted in local-path PVC
# Unpinned — scheduler places freely
# NodePort 32375
# Signups disabled — use admin panel to invite users
#
# Deploy:
# kubectl create secret generic vaultwarden-secret \
# --namespace <ns> \
# --from-literal=admin-token='<token>'
# kubectl apply -f vaultwarden.yaml -n <ns>
#
# Generate admin token with: openssl rand -base64 48
# Admin panel: http://<any-node-mesh-ip>:32375/admin
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: vaultwarden-pvc
spec:
accessModes:
- ReadWriteOnce
storageClassName: local-path
resources:
requests:
storage: 5Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: vaultwarden
spec:
replicas: 1
selector:
matchLabels:
app: vaultwarden
template:
metadata:
labels:
app: vaultwarden
spec:
containers:
- name: vaultwarden
image: vaultwarden/server:latest
env:
- name: SIGNUPS_ALLOWED
value: "false"
- name: INVITATIONS_ALLOWED
value: "true"
- name: SHOW_PASSWORD_HINT
value: "false"
- name: ROCKET_PORT
value: "8222"
- name: ADMIN_TOKEN
valueFrom:
secretKeyRef:
name: vaultwarden-secret
key: admin-token
ports:
- containerPort: 8222
volumeMounts:
- name: vaultwarden-data
mountPath: /data
volumes:
- name: vaultwarden-data
persistentVolumeClaim:
claimName: vaultwarden-pvc
---
apiVersion: v1
kind: Service
metadata:
name: vaultwarden
spec:
selector:
app: vaultwarden
ports:
- port: 8222
targetPort: 8222
nodePort: 32375
type: NodePort

47
proxmox/adder/clone-vm.sh Normal file
View file

@ -0,0 +1,47 @@
#!/bin/bash
# clone-vm.sh
# Clones a Proxmox VM template into a new full VM.
# Run this on the same node where the template lives (local storage).
#
# Usage:
# ./clone-vm.sh <TEMPLATE_VMID> <NEW_VMID> <NAME> [CORES] [MEMORY_MB] [DISK_SIZE]
#
# Examples:
# ./clone-vm.sh 100 101 k3s-control-1
# ./clone-vm.sh 100 202 k3s-worker-1 6 8192 100G
#
# Defaults:
# CORES = 2
# MEMORY_MB = 2048
# DISK_SIZE = 20G
#
# Note: Template must exist on the local node (local-lvm).
# Run create-debian-template.sh on each node first.
set -euo pipefail
TEMPLATE_VMID="${1:?Usage: $0 <TEMPLATE_VMID> <NEW_VMID> <NAME> [CORES] [MEMORY_MB] [DISK_SIZE]}"
NEW_VMID="${2:?Usage: $0 <TEMPLATE_VMID> <NEW_VMID> <NAME> [CORES] [MEMORY_MB] [DISK_SIZE]}"
NAME="${3:?Usage: $0 <TEMPLATE_VMID> <NEW_VMID> <NAME> [CORES] [MEMORY_MB] [DISK_SIZE]}"
CORES="${4:-2}"
MEMORY="${5:-2048}"
DISK_SIZE="${6:-20G}"
echo "==> Cloning template ${TEMPLATE_VMID} to ${NEW_VMID} (${NAME})..."
qm clone "${TEMPLATE_VMID}" "${NEW_VMID}" --name "${NAME}" --full
echo "==> Setting resources: ${CORES} cores, ${MEMORY}MB RAM..."
qm set "${NEW_VMID}" --cores "${CORES}" --memory "${MEMORY}"
echo "==> Resizing disk to ${DISK_SIZE}..."
qm resize "${NEW_VMID}" scsi0 "${DISK_SIZE}"
echo "==> Setting hostname via Cloud-Init..."
qm set "${NEW_VMID}" --ciuser samantha --ipconfig0 ip=dhcp
echo "==> Starting VM..."
qm start "${NEW_VMID}"
echo ""
echo "Done. VM ${NEW_VMID} (${NAME}) started."
echo "Find its IP: qm guest cmd ${NEW_VMID} network-get-interfaces"

View file

@ -0,0 +1,80 @@
#!/bin/bash
# create-debian-template.sh
# Creates a Debian Trixie Cloud-Init VM template on Proxmox.
#
# Usage:
# ./create-debian-template.sh <VMID> <n> [STORAGE] [BRIDGE]
#
# Examples:
# ./create-debian-template.sh 9000 debian-trixie-template
# ./create-debian-template.sh 9000 debian-trixie-template local-lvm vmbr1
#
# Defaults:
# STORAGE = local-lvm
# BRIDGE = vmbr1
set -euo pipefail
VMID="${1:?Usage: $0 <VMID> <n> [STORAGE] [BRIDGE]}"
NAME="${2:?Usage: $0 <VMID> <n> [STORAGE] [BRIDGE]}"
STORAGE="${3:-local-lvm}"
BRIDGE="${4:-vmbr1}"
IMAGE="debian-13-genericcloud-amd64.qcow2"
IMAGE_URL="https://cloud.debian.org/images/cloud/trixie/latest/${IMAGE}"
TMPDIR="/tmp"
echo "==> Downloading Debian Trixie cloud image..."
wget -q --show-progress -O "${TMPDIR}/${IMAGE}" "${IMAGE_URL}"
echo "==> Installing libguestfs-tools if needed..."
apt-get install -y libguestfs-tools > /dev/null
echo "==> Customizing image (installing base tools + Tailscale repo)..."
virt-customize -a "${TMPDIR}/${IMAGE}" \
--install qemu-guest-agent,curl,wget,nano,rsync,htop,tmux,emacs-nox,nfs-common \
--run-command 'curl -fsSL https://pkgs.tailscale.com/stable/debian/trixie.noarmor.gpg -o /usr/share/keyrings/tailscale-archive-keyring.gpg' \
--run-command 'echo "deb [signed-by=/usr/share/keyrings/tailscale-archive-keyring.gpg] https://pkgs.tailscale.com/stable/debian trixie main" > /etc/apt/sources.list.d/tailscale.list' \
--run-command 'apt-get update -qq' \
--install tailscale \
--run-command 'mkdir -p /home/samantha/.ssh && echo "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPJq4fOOrPQKYAq5olwKWAXKGO5zv/rujveyTORxMrDp root@pve" > /home/samantha/.ssh/authorized_keys && chmod 700 /home/samantha/.ssh && chmod 600 /home/samantha/.ssh/authorized_keys' \
--run-command 'chown -R samantha:samantha /home/samantha' \
--run-command 'truncate -s 0 /etc/machine-id' \
--run-command 'rm -f /etc/ssh/ssh_host_*' \
--quiet
echo "==> Creating VM ${VMID} (${NAME})..."
qm create "${VMID}" \
--name "${NAME}" \
--memory 2048 \
--cores 2 \
--net0 "virtio,bridge=${BRIDGE}" \
--ostype l26
echo "==> Importing disk to ${STORAGE}..."
qm importdisk "${VMID}" "${TMPDIR}/${IMAGE}" "${STORAGE}"
echo "==> Configuring VM..."
qm set "${VMID}" --scsihw virtio-scsi-pci --scsi0 "${STORAGE}:vm-${VMID}-disk-0"
qm set "${VMID}" --boot c --bootdisk scsi0
qm set "${VMID}" --ide2 "${STORAGE}:cloudinit"
qm set "${VMID}" --vga std
qm set "${VMID}" --agent enabled=1
PUBKEY_FILE=$(mktemp)
echo "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPJq4fOOrPQKYAq5olwKWAXKGO5zv/rujveyTORxMrDp root@pve" > "${PUBKEY_FILE}"
qm set "${VMID}" --ciuser samantha --cipassword "changeme" --sshkeys "${PUBKEY_FILE}"
rm -f "${PUBKEY_FILE}"
qm set "${VMID}" --ipconfig0 ip=dhcp
echo "==> Converting to template..."
qm template "${VMID}"
echo "==> Cleaning up..."
rm -f "${TMPDIR}/${IMAGE}"
echo ""
echo "Done. Template ${VMID} (${NAME}) created."
echo "Clone with: ./clone-vm.sh ${VMID} <NEW_VMID> <NEW_NAME> [CORES] [MEMORY_MB] [DISK_SIZE]"
echo ""
echo "After cloning, join the Headscale mesh:"
echo " tailscale up --login-server <headscale_server> --authkey mkey:..."

View file

@ -0,0 +1,50 @@
#!/bin/bash
# clone-vm.sh
# Clones a Proxmox VM template into a new full VM.
# Run this on the same node where the template lives (local storage).
#
# Usage:
# ./clone-vm.sh <TEMPLATE_VMID> <NEW_VMID> <n> [CORES] [MEMORY_MB] [DISK_SIZE] [STORAGE]
#
# Examples:
# ./clone-vm.sh 100 101 k3s-control-1
# ./clone-vm.sh 100 202 k3s-worker-1 6 8192 100G
# ./clone-vm.sh 300 303 game-worker-ssd 10 61440 200G game-ssd
#
# Defaults:
# CORES = 2
# MEMORY_MB = 2048
# DISK_SIZE = 20G
# STORAGE = local-lvm
#
# Note: Template must exist on the local node.
# Run create-debian-template.sh on each node first.
set -euo pipefail
TEMPLATE_VMID="${1:?Usage: $0 <TEMPLATE_VMID> <NEW_VMID> <n> [CORES] [MEMORY_MB] [DISK_SIZE] [STORAGE]}"
NEW_VMID="${2:?Usage: $0 <TEMPLATE_VMID> <NEW_VMID> <n> [CORES] [MEMORY_MB] [DISK_SIZE] [STORAGE]}"
NAME="${3:?Usage: $0 <TEMPLATE_VMID> <NEW_VMID> <n> [CORES] [MEMORY_MB] [DISK_SIZE] [STORAGE]}"
CORES="${4:-2}"
MEMORY="${5:-2048}"
DISK_SIZE="${6:-20G}"
STORAGE="${7:-local-lvm}"
echo "==> Cloning template ${TEMPLATE_VMID} to ${NEW_VMID} (${NAME})..."
qm clone "${TEMPLATE_VMID}" "${NEW_VMID}" --name "${NAME}" --full
echo "==> Setting resources: ${CORES} cores, ${MEMORY}MB RAM..."
qm set "${NEW_VMID}" --cores "${CORES}" --memory "${MEMORY}"
echo "==> Resizing disk to ${DISK_SIZE} on ${STORAGE}..."
qm resize "${NEW_VMID}" scsi0 "${DISK_SIZE}"
echo "==> Setting hostname via Cloud-Init..."
qm set "${NEW_VMID}" --ciuser samantha --ipconfig0 ip=dhcp
echo "==> Starting VM..."
qm start "${NEW_VMID}"
echo ""
echo "Done. VM ${NEW_VMID} (${NAME}) started."
echo "Find its IP: qm guest cmd ${NEW_VMID} network-get-interfaces"

View file

@ -0,0 +1,78 @@
#!/bin/bash
# create-debian-template.sh
# Creates a Debian Trixie Cloud-Init VM template on Proxmox.
#
# Usage:
# ./create-debian-template.sh <VMID> <n> [STORAGE] [BRIDGE]
#
# Examples:
# ./create-debian-template.sh 9000 debian-trixie-template
# ./create-debian-template.sh 9000 debian-trixie-template local-lvm vmbr1
#
# Defaults:
# STORAGE = local-lvm
# BRIDGE = vmbr1
set -euo pipefail
VMID="${1:?Usage: $0 <VMID> <n> [STORAGE] [BRIDGE]}"
NAME="${2:?Usage: $0 <VMID> <n> [STORAGE] [BRIDGE]}"
STORAGE="${3:-local-lvm}"
BRIDGE="${4:-vmbr1}"
IMAGE="debian-13-genericcloud-amd64.qcow2"
IMAGE_URL="https://cloud.debian.org/images/cloud/trixie/latest/${IMAGE}"
TMPDIR="/tmp"
echo "==> Downloading Debian Trixie cloud image..."
wget -q --show-progress -O "${TMPDIR}/${IMAGE}" "${IMAGE_URL}"
echo "==> Installing libguestfs-tools if needed..."
apt-get install -y libguestfs-tools > /dev/null
echo "==> Customizing image (installing base tools + Tailscale repo)..."
virt-customize -a "${TMPDIR}/${IMAGE}" \
--install qemu-guest-agent,curl,wget,nano,rsync,htop,tmux,emacs-nox,nfs-common \
--run-command 'curl -fsSL https://pkgs.tailscale.com/stable/debian/trixie.noarmor.gpg -o /usr/share/keyrings/tailscale-archive-keyring.gpg' \
--run-command 'echo "deb [signed-by=/usr/share/keyrings/tailscale-archive-keyring.gpg] https://pkgs.tailscale.com/stable/debian trixie main" > /etc/apt/sources.list.d/tailscale.list' \
--run-command 'apt-get update -qq' \
--install tailscale \
--run-command 'truncate -s 0 /etc/machine-id' \
--run-command 'rm -f /etc/ssh/ssh_host_*' \
--quiet
echo "==> Creating VM ${VMID} (${NAME})..."
qm create "${VMID}" \
--name "${NAME}" \
--memory 2048 \
--cores 2 \
--net0 "virtio,bridge=${BRIDGE}" \
--ostype l26
echo "==> Importing disk to ${STORAGE}..."
qm importdisk "${VMID}" "${TMPDIR}/${IMAGE}" "${STORAGE}"
echo "==> Configuring VM..."
qm set "${VMID}" --scsihw virtio-scsi-pci --scsi0 "${STORAGE}:vm-${VMID}-disk-0"
qm set "${VMID}" --boot c --bootdisk scsi0
qm set "${VMID}" --ide2 "${STORAGE}:cloudinit"
qm set "${VMID}" --vga std
qm set "${VMID}" --agent enabled=1
PUBKEY_FILE=$(mktemp)
echo "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPJq4fOOrPQKYAq5olwKWAXKGO5zv/rujveyTORxMrDp root@pve" > "${PUBKEY_FILE}"
qm set "${VMID}" --ciuser samantha --cipassword "changeme" --sshkeys "${PUBKEY_FILE}"
rm -f "${PUBKEY_FILE}"
qm set "${VMID}" --ipconfig0 ip=dhcp
echo "==> Converting to template..."
qm template "${VMID}"
echo "==> Cleaning up..."
rm -f "${TMPDIR}/${IMAGE}"
echo ""
echo "Done. Template ${VMID} (${NAME}) created."
echo "Clone with: ./clone-vm.sh ${VMID} <NEW_VMID> <NEW_NAME> [CORES] [MEMORY_MB] [DISK_SIZE]"
echo ""
echo "After cloning, join the Headscale mesh:"
echo " tailscale up --login-server <headscale_server> --authkey mkey:..."