Initial monorepo scaffold

Turborepo + pnpm monorepo for k3s homelab cluster on Intel NUCs.

- Apps: Next.js web frontend, Express API (TypeScript, Dockerfiles, k8s manifests)
- Packages: shared UI, ESLint config, TypeScript config, Drizzle DB schemas
- Infra/Ansible: bare-metal provisioning with roles for common, k3s-server, k3s-agent, hardening
- Infra/Kubernetes: ArgoCD GitOps (app-of-apps + ApplicationSets), platform components
  (cert-manager, Traefik, CloudNativePG, Valkey, Longhorn, Sealed Secrets), namespaces
- Observability: kube-prometheus-stack, Loki, Promtail as ArgoCD Applications
- CI/CD: GitHub Actions for PR builds, preview deploys, production deploys
- DX: Taskfile, utility scripts, copier templates, Ubiquiti network docs
This commit is contained in:
Julia McGhee
2026-03-19 22:24:56 +00:00
commit 96e3f32f28
118 changed files with 2681 additions and 0 deletions

View File

@@ -0,0 +1,22 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: app-of-apps
namespace: argocd
finalizers:
- resources-finalizer.argocd.argoproj.io
spec:
project: default
source:
repoURL: https://github.com/OWNER/homelab.git
targetRevision: main
path: infra/kubernetes/argocd
destination:
server: https://kubernetes.default.svc
namespace: argocd
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true

View File

@@ -0,0 +1,30 @@
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
name: apps-production
namespace: argocd
spec:
goTemplate: true
goTemplateOptions: ["missingkey=error"]
generators:
- git:
repoURL: https://github.com/OWNER/homelab.git
revision: main
directories:
- path: apps/*/k8s/overlays/production
template:
metadata:
name: "{{ index .path.segments 1 }}-production"
spec:
project: default
source:
repoURL: https://github.com/OWNER/homelab.git
targetRevision: main
path: "{{ .path.path }}"
destination:
server: https://kubernetes.default.svc
namespace: apps
syncPolicy:
automated:
prune: true
selfHeal: true

View File

@@ -0,0 +1,32 @@
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
name: platform
namespace: argocd
spec:
goTemplate: true
goTemplateOptions: ["missingkey=error"]
generators:
- git:
repoURL: https://github.com/OWNER/homelab.git
revision: main
directories:
- path: infra/kubernetes/platform/*
template:
metadata:
name: "platform-{{ .path.basename }}"
spec:
project: default
source:
repoURL: https://github.com/OWNER/homelab.git
targetRevision: main
path: "{{ .path.path }}"
destination:
server: https://kubernetes.default.svc
namespace: platform
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true

View File

@@ -0,0 +1,34 @@
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
name: apps-preview
namespace: argocd
spec:
goTemplate: true
goTemplateOptions: ["missingkey=error"]
generators:
- pullRequest:
github:
owner: OWNER
repo: homelab
requeueAfterSeconds: 60
template:
metadata:
name: "preview-{{ .number }}"
spec:
project: default
source:
repoURL: https://github.com/OWNER/homelab.git
targetRevision: "{{ .branch }}"
path: apps/*/k8s/overlays/preview
kustomize:
nameSuffix: "-pr{{ .number }}"
destination:
server: https://kubernetes.default.svc
namespace: "preview-{{ .number }}"
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true

View File

@@ -0,0 +1,2 @@
# ArgoCD is installed via Kustomize remote base.
# See kustomization.yaml for the version-pinned reference.

View File

@@ -0,0 +1,22 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: argocd
resources:
- namespace.yaml
- https://raw.githubusercontent.com/argoproj/argo-cd/v2.13.3/manifests/install.yaml
- app-of-apps.yaml
- appsets/platform.yaml
- appsets/apps.yaml
- appsets/previews.yaml
patches:
- target:
kind: ConfigMap
name: argocd-cm
patch: |
apiVersion: v1
kind: ConfigMap
metadata:
name: argocd-cm
data:
url: https://argocd.homelab.local
application.resourceTrackingMethod: annotation

View File

@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: argocd

View File

@@ -0,0 +1,6 @@
apiVersion: v1
kind: Namespace
metadata:
name: apps
labels:
managed-by: argocd

View File

@@ -0,0 +1,6 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- apps.yaml
- platform.yaml
- observability.yaml

View File

@@ -0,0 +1,6 @@
apiVersion: v1
kind: Namespace
metadata:
name: observability
labels:
managed-by: argocd

View File

@@ -0,0 +1,6 @@
apiVersion: v1
kind: Namespace
metadata:
name: platform
labels:
managed-by: argocd

View File

@@ -0,0 +1,95 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: kube-prometheus-stack
namespace: argocd
spec:
project: default
source:
repoURL: https://prometheus-community.github.io/helm-charts
chart: kube-prometheus-stack
targetRevision: 67.9.0
helm:
valuesObject:
prometheus:
prometheusSpec:
retention: 15d
resources:
requests:
memory: 512Mi
cpu: 250m
limits:
memory: 2Gi
storageSpec:
volumeClaimTemplate:
spec:
storageClassName: longhorn
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 20Gi
serviceMonitorSelectorNilUsesHelmValues: false
podMonitorSelectorNilUsesHelmValues: false
grafana:
adminPassword: "changeme"
ingress:
enabled: true
ingressClassName: traefik
annotations:
cert-manager.io/cluster-issuer: letsencrypt-production
hosts:
- grafana.homelab.local
tls:
- secretName: grafana-tls
hosts:
- grafana.homelab.local
sidecar:
dashboards:
enabled: true
searchNamespace: ALL
label: grafana_dashboard
datasources:
enabled: true
searchNamespace: ALL
label: grafana_datasource
resources:
requests:
memory: 128Mi
cpu: 100m
limits:
memory: 512Mi
alertmanager:
alertmanagerSpec:
resources:
requests:
memory: 64Mi
cpu: 50m
limits:
memory: 256Mi
storage:
volumeClaimTemplate:
spec:
storageClassName: longhorn
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 5Gi
nodeExporter:
enabled: true
kubeStateMetrics:
enabled: true
destination:
server: https://kubernetes.default.svc
namespace: observability
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
- ServerSideApply=true

View File

@@ -0,0 +1,69 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: cluster-overview-dashboard
namespace: observability
labels:
grafana_dashboard: "1"
data:
cluster-overview.json: |
{
"annotations": { "list": [] },
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
"id": null,
"links": [],
"panels": [
{
"title": "CPU Usage by Node",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
"targets": [
{
"expr": "100 - (avg by(instance) (rate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100)",
"legendFormat": "{{ instance }}"
}
]
},
{
"title": "Memory Usage by Node",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
"targets": [
{
"expr": "(1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100",
"legendFormat": "{{ instance }}"
}
]
},
{
"title": "Disk Usage by Node",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
"targets": [
{
"expr": "(1 - (node_filesystem_avail_bytes{mountpoint=\"/\"} / node_filesystem_size_bytes{mountpoint=\"/\"})) * 100",
"legendFormat": "{{ instance }}"
}
]
},
{
"title": "Pod Count by Namespace",
"type": "bargauge",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
"targets": [
{
"expr": "count by(namespace) (kube_pod_info)",
"legendFormat": "{{ namespace }}"
}
]
}
],
"schemaVersion": 39,
"tags": ["homelab", "cluster"],
"templating": { "list": [] },
"time": { "from": "now-6h", "to": "now" },
"title": "Cluster Overview",
"uid": "cluster-overview"
}

View File

@@ -0,0 +1,17 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: loki-datasource
namespace: observability
labels:
grafana_datasource: "1"
data:
loki-datasource.yaml: |
apiVersion: 1
datasources:
- name: Loki
type: loki
access: proxy
url: http://loki.observability.svc:3100
jsonData:
maxLines: 1000

View File

@@ -0,0 +1,6 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- application.yaml
- grafana-datasources.yaml
- dashboards/cluster-overview.yaml

View File

@@ -0,0 +1,6 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- kube-prometheus-stack/
- loki/
- promtail/

View File

@@ -0,0 +1,71 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: loki
namespace: argocd
spec:
project: default
source:
repoURL: https://grafana.github.io/helm-charts
chart: loki
targetRevision: 6.24.0
helm:
valuesObject:
deploymentMode: SingleBinary
loki:
auth_enabled: false
commonConfig:
replication_factor: 1
storage:
type: filesystem
schemaConfig:
configs:
- from: "2024-01-01"
store: tsdb
object_store: filesystem
schema: v13
index:
prefix: loki_index_
period: 24h
limits_config:
retention_period: 168h
max_query_series: 500
max_query_parallelism: 2
singleBinary:
replicas: 1
resources:
requests:
memory: 256Mi
cpu: 100m
limits:
memory: 1Gi
persistence:
enabled: true
storageClass: longhorn
size: 10Gi
gateway:
enabled: false
backend:
replicas: 0
read:
replicas: 0
write:
replicas: 0
chunksCache:
enabled: false
resultsCache:
enabled: false
destination:
server: https://kubernetes.default.svc
namespace: observability
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true

View File

@@ -0,0 +1,4 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- application.yaml

View File

@@ -0,0 +1,38 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: promtail
namespace: argocd
spec:
project: default
source:
repoURL: https://grafana.github.io/helm-charts
chart: promtail
targetRevision: 6.16.6
helm:
valuesObject:
config:
clients:
- url: http://loki.observability.svc:3100/loki/api/v1/push
snippets:
pipelineStages:
- cri: {}
- multiline:
firstline: '^\d{4}-\d{2}-\d{2}'
max_wait_time: 3s
resources:
requests:
memory: 64Mi
cpu: 50m
limits:
memory: 256Mi
tolerations:
- effect: NoSchedule
operator: Exists
destination:
server: https://kubernetes.default.svc
namespace: observability
syncPolicy:
automated:
prune: true
selfHeal: true

View File

@@ -0,0 +1,4 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- application.yaml

View File

@@ -0,0 +1,37 @@
# Prerequisites: cert-manager must be installed via Helm first.
# Install: helm install cert-manager jetstack/cert-manager --namespace cert-manager --set crds.enabled=true --version v1.16.3
# This file configures the Let's Encrypt issuers after cert-manager is running.
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-staging
spec:
acme:
server: https://acme-staging-v02.api.letsencrypt.org/directory
email: admin@homelab.local
privateKeySecretRef:
name: letsencrypt-staging-key
solvers:
- dns01:
cloudflare:
apiTokenSecretRef:
name: cloudflare-api-token
key: api-token
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-production
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: admin@homelab.local
privateKeySecretRef:
name: letsencrypt-production-key
solvers:
- dns01:
cloudflare:
apiTokenSecretRef:
name: cloudflare-api-token
key: api-token

View File

@@ -0,0 +1,5 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespace.yaml
- clusterissuer-letsencrypt.yaml

View File

@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: cert-manager

View File

@@ -0,0 +1,45 @@
# Prerequisites: CloudNativePG operator must be installed first.
# Install: helm install cnpg cloudnative-pg/cloudnative-pg --namespace cnpg-system --create-namespace
---
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: homelab-pg
namespace: platform
spec:
instances: 2
primaryUpdateStrategy: unsupervised
storage:
storageClass: longhorn
size: 10Gi
postgresql:
parameters:
max_connections: "100"
shared_buffers: 256MB
effective_cache_size: 512MB
work_mem: 4MB
bootstrap:
initdb:
database: homelab
owner: homelab
secret:
name: homelab-pg-credentials
backup:
barmanObjectStore:
destinationPath: s3://homelab-pg-backups/
endpointURL: http://minio.platform.svc:9000
s3Credentials:
accessKeyId:
name: pg-backup-s3-credentials
key: ACCESS_KEY_ID
secretAccessKey:
name: pg-backup-s3-credentials
key: SECRET_ACCESS_KEY
retentionPolicy: "30d"
monitoring:
enablePodMonitor: true

View File

@@ -0,0 +1,4 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- cluster.yaml

View File

@@ -0,0 +1,5 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespace.yaml
- storageclass.yaml

View File

@@ -0,0 +1,6 @@
# Prerequisites: Longhorn must be installed via Helm first.
# Install: helm install longhorn longhorn/longhorn --namespace longhorn-system --create-namespace --version 1.7.2
apiVersion: v1
kind: Namespace
metadata:
name: longhorn-system

View File

@@ -0,0 +1,14 @@
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: longhorn
annotations:
storageclass.kubernetes.io/is-default-class: "true"
provisioner: driver.longhorn.io
allowVolumeExpansion: true
reclaimPolicy: Delete
volumeBindingMode: Immediate
parameters:
numberOfReplicas: "2"
staleReplicaTimeout: "2880"
dataLocality: best-effort

View File

@@ -0,0 +1,4 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespace.yaml

View File

@@ -0,0 +1,9 @@
# Prerequisites: Sealed Secrets must be installed via Helm first.
# Install: helm install sealed-secrets sealed-secrets/sealed-secrets --namespace kube-system --version 2.16.2
# The controller runs in kube-system; this is just the config namespace.
apiVersion: v1
kind: Namespace
metadata:
name: sealed-secrets
labels:
managed-by: argocd

View File

@@ -0,0 +1,26 @@
# HelmChartConfig customizes the k3s-bundled Traefik deployment
apiVersion: helm.cattle.io/v1
kind: HelmChartConfig
metadata:
name: traefik
namespace: kube-system
spec:
valuesContent: |-
ports:
web:
redirectTo:
port: websecure
websecure:
tls:
enabled: true
providers:
kubernetesCRD:
allowCrossNamespace: true
logs:
access:
enabled: true
metrics:
prometheus:
entryPoint: metrics
additionalArguments:
- "--entrypoints.websecure.http.tls.certresolver=letsencrypt"

View File

@@ -0,0 +1,5 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- helmchartconfig.yaml
- middleware-default-headers.yaml

View File

@@ -0,0 +1,16 @@
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: default-headers
namespace: platform
spec:
headers:
browserXssFilter: true
contentTypeNosniff: true
frameDeny: true
stsIncludeSubdomains: true
stsPreload: true
stsSeconds: 31536000
customFrameOptionsValue: SAMEORIGIN
customRequestHeaders:
X-Forwarded-Proto: https

View File

@@ -0,0 +1,71 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: valkey
namespace: platform
labels:
app: valkey
spec:
replicas: 1
selector:
matchLabels:
app: valkey
template:
metadata:
labels:
app: valkey
spec:
containers:
- name: valkey
image: valkey/valkey:8.0-alpine
ports:
- containerPort: 6379
args:
- "--requirepass"
- "$(VALKEY_PASSWORD)"
- "--maxmemory"
- "256mb"
- "--maxmemory-policy"
- "allkeys-lru"
env:
- name: VALKEY_PASSWORD
valueFrom:
secretKeyRef:
name: valkey-credentials
key: password
resources:
requests:
memory: 128Mi
cpu: 100m
limits:
memory: 512Mi
readinessProbe:
tcpSocket:
port: 6379
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
tcpSocket:
port: 6379
initialDelaySeconds: 15
periodSeconds: 20
volumeMounts:
- name: data
mountPath: /data
volumes:
- name: data
persistentVolumeClaim:
claimName: valkey-data
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: valkey-data
namespace: platform
spec:
accessModes:
- ReadWriteOnce
storageClassName: longhorn
resources:
requests:
storage: 2Gi

View File

@@ -0,0 +1,5 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- deployment.yaml
- service.yaml

View File

@@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: valkey
namespace: platform
labels:
app: valkey
spec:
type: ClusterIP
ports:
- port: 6379
targetPort: 6379
protocol: TCP
selector:
app: valkey