Add K3s dev deployment setup for single-node VPS

Mirrors the prod deploy-k3s/ setup but runs all services in-cluster
on a single node: PostgreSQL (replaces Neon), MinIO S3-compatible
storage (replaces B2), Redis, API, worker, and admin.

Includes fully automated setup scripts (00-init through 04-verify),
server hardening (SSH, fail2ban, ufw), Let's Encrypt TLS via Traefik,
network policies, RBAC, and security contexts matching prod.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-03-30 21:30:39 -05:00
parent 00fd674b56
commit 34553f3bec
52 changed files with 5319 additions and 0 deletions

View File

@@ -0,0 +1,94 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: admin
namespace: honeydue
labels:
app.kubernetes.io/name: admin
app.kubernetes.io/part-of: honeydue
spec:
replicas: 1
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
selector:
matchLabels:
app.kubernetes.io/name: admin
template:
metadata:
labels:
app.kubernetes.io/name: admin
app.kubernetes.io/part-of: honeydue
spec:
serviceAccountName: admin
imagePullSecrets:
- name: ghcr-credentials
securityContext:
runAsNonRoot: true
runAsUser: 1001
runAsGroup: 1001
fsGroup: 1001
seccompProfile:
type: RuntimeDefault
containers:
- name: admin
image: IMAGE_PLACEHOLDER # Replaced by 03-deploy.sh
ports:
- containerPort: 3000
protocol: TCP
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
env:
- name: PORT
value: "3000"
- name: HOSTNAME
value: "0.0.0.0"
- name: NEXT_PUBLIC_API_URL
valueFrom:
configMapKeyRef:
name: honeydue-config
key: NEXT_PUBLIC_API_URL
volumeMounts:
- name: nextjs-cache
mountPath: /app/.next/cache
- name: tmp
mountPath: /tmp
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 500m
memory: 256Mi
startupProbe:
httpGet:
path: /admin/
port: 3000
failureThreshold: 12
periodSeconds: 5
readinessProbe:
httpGet:
path: /admin/
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 5
livenessProbe:
httpGet:
path: /admin/
port: 3000
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 10
volumes:
- name: nextjs-cache
emptyDir:
sizeLimit: 256Mi
- name: tmp
emptyDir:
sizeLimit: 64Mi

View File

@@ -0,0 +1,16 @@
apiVersion: v1
kind: Service
metadata:
name: admin
namespace: honeydue
labels:
app.kubernetes.io/name: admin
app.kubernetes.io/part-of: honeydue
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: admin
ports:
- port: 3000
targetPort: 3000
protocol: TCP

View File

@@ -0,0 +1,54 @@
# API Ingress — Cloudflare-only + security headers + rate limiting
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: honeydue-api
namespace: honeydue
labels:
app.kubernetes.io/part-of: honeydue
annotations:
traefik.ingress.kubernetes.io/router.middlewares: honeydue-cloudflare-only@kubernetescrd,honeydue-security-headers@kubernetescrd,honeydue-rate-limit@kubernetescrd
spec:
tls:
- hosts:
- api.myhoneydue.com
secretName: cloudflare-origin-cert
rules:
- host: api.myhoneydue.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: api
port:
number: 8000
---
# Admin Ingress — Cloudflare-only + security headers + rate limiting + basic auth
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: honeydue-admin
namespace: honeydue
labels:
app.kubernetes.io/part-of: honeydue
annotations:
traefik.ingress.kubernetes.io/router.middlewares: honeydue-cloudflare-only@kubernetescrd,honeydue-security-headers@kubernetescrd,honeydue-rate-limit@kubernetescrd,honeydue-admin-auth@kubernetescrd
spec:
tls:
- hosts:
- admin.myhoneydue.com
secretName: cloudflare-origin-cert
rules:
- host: admin.myhoneydue.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: admin
port:
number: 3000

View File

@@ -0,0 +1,82 @@
# Traefik CRD middleware for rate limiting
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: rate-limit
namespace: honeydue
spec:
rateLimit:
average: 100
burst: 200
period: 1m
---
# Security headers
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: security-headers
namespace: honeydue
spec:
headers:
frameDeny: true
contentTypeNosniff: true
browserXssFilter: true
referrerPolicy: "strict-origin-when-cross-origin"
customResponseHeaders:
X-Content-Type-Options: "nosniff"
X-Frame-Options: "DENY"
Strict-Transport-Security: "max-age=31536000; includeSubDomains"
Content-Security-Policy: "default-src 'self'; frame-ancestors 'none'"
Permissions-Policy: "camera=(), microphone=(), geolocation=()"
X-Permitted-Cross-Domain-Policies: "none"
---
# Cloudflare IP allowlist (restrict origin to Cloudflare only)
# https://www.cloudflare.com/ips-v4 and /ips-v6
# Update periodically: curl -s https://www.cloudflare.com/ips-v4 && curl -s https://www.cloudflare.com/ips-v6
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: cloudflare-only
namespace: honeydue
spec:
ipAllowList:
sourceRange:
# Cloudflare IPv4 ranges
- 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
# Cloudflare IPv6 ranges
- 2400:cb00::/32
- 2606:4700::/32
- 2803:f800::/32
- 2405:b500::/32
- 2405:8100::/32
- 2a06:98c0::/29
- 2c0f:f248::/32
---
# Admin basic auth — additional auth layer for admin panel
# Secret created by 02-setup-secrets.sh from config.yaml credentials
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: admin-auth
namespace: honeydue
spec:
basicAuth:
secret: admin-basic-auth
realm: "honeyDue Admin"

View File

@@ -0,0 +1,6 @@
apiVersion: v1
kind: Namespace
metadata:
name: honeydue
labels:
app.kubernetes.io/part-of: honeydue

View File

@@ -0,0 +1,202 @@
# Network Policies — default-deny with explicit allows
# Apply AFTER namespace and deployments are created.
# Verify: kubectl get networkpolicy -n honeydue
# --- Default deny all ingress and egress ---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: honeydue
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
---
# --- Allow DNS for all pods (required for service discovery) ---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-dns
namespace: honeydue
spec:
podSelector: {}
policyTypes:
- Egress
egress:
- to: []
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
---
# --- API: allow ingress from Traefik (kube-system namespace) ---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-ingress-to-api
namespace: honeydue
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: api
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: TCP
port: 8000
---
# --- Admin: allow ingress from Traefik (kube-system namespace) ---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-ingress-to-admin
namespace: honeydue
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: admin
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: TCP
port: 3000
---
# --- Redis: allow ingress ONLY from api + worker pods ---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-ingress-to-redis
namespace: honeydue
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: redis
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app.kubernetes.io/name: api
- podSelector:
matchLabels:
app.kubernetes.io/name: worker
ports:
- protocol: TCP
port: 6379
---
# --- API: allow egress to Redis, external services (Neon DB, APNs, FCM, B2, SMTP) ---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-egress-from-api
namespace: honeydue
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: api
policyTypes:
- Egress
egress:
# Redis (in-cluster)
- to:
- podSelector:
matchLabels:
app.kubernetes.io/name: redis
ports:
- protocol: TCP
port: 6379
# External services: Neon DB (5432), SMTP (587), HTTPS (443 — APNs, FCM, B2, PostHog)
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
ports:
- protocol: TCP
port: 5432
- protocol: TCP
port: 587
- protocol: TCP
port: 443
---
# --- Worker: allow egress to Redis, external services ---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-egress-from-worker
namespace: honeydue
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: worker
policyTypes:
- Egress
egress:
# Redis (in-cluster)
- to:
- podSelector:
matchLabels:
app.kubernetes.io/name: redis
ports:
- protocol: TCP
port: 6379
# External services: Neon DB (5432), SMTP (587), HTTPS (443 — APNs, FCM, B2)
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
ports:
- protocol: TCP
port: 5432
- protocol: TCP
port: 587
- protocol: TCP
port: 443
---
# --- Admin: allow egress to API (internal) for SSR ---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-egress-from-admin
namespace: honeydue
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: admin
policyTypes:
- Egress
egress:
# API service (in-cluster, for server-side API calls)
- to:
- podSelector:
matchLabels:
app.kubernetes.io/name: api
ports:
- protocol: TCP
port: 8000

View File

@@ -0,0 +1,32 @@
# Pod Disruption Budgets — prevent node maintenance from killing all replicas
# API: at least 2 of 3 replicas must stay up during voluntary disruptions
# Worker: at least 1 of 2 replicas must stay up
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: api-pdb
namespace: honeydue
labels:
app.kubernetes.io/name: api
app.kubernetes.io/part-of: honeydue
spec:
minAvailable: 2
selector:
matchLabels:
app.kubernetes.io/name: api
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: worker-pdb
namespace: honeydue
labels:
app.kubernetes.io/name: worker
app.kubernetes.io/part-of: honeydue
spec:
minAvailable: 1
selector:
matchLabels:
app.kubernetes.io/name: worker

View File

@@ -0,0 +1,46 @@
# RBAC — Dedicated service accounts with no K8s API access
# Each pod gets its own SA with automountServiceAccountToken: false,
# so a compromised pod cannot query the Kubernetes API.
apiVersion: v1
kind: ServiceAccount
metadata:
name: api
namespace: honeydue
labels:
app.kubernetes.io/name: api
app.kubernetes.io/part-of: honeydue
automountServiceAccountToken: false
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: worker
namespace: honeydue
labels:
app.kubernetes.io/name: worker
app.kubernetes.io/part-of: honeydue
automountServiceAccountToken: false
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: admin
namespace: honeydue
labels:
app.kubernetes.io/name: admin
app.kubernetes.io/part-of: honeydue
automountServiceAccountToken: false
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: redis
namespace: honeydue
labels:
app.kubernetes.io/name: redis
app.kubernetes.io/part-of: honeydue
automountServiceAccountToken: false

View File

@@ -0,0 +1,106 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis
namespace: honeydue
labels:
app.kubernetes.io/name: redis
app.kubernetes.io/part-of: honeydue
spec:
replicas: 1
strategy:
type: Recreate # ReadWriteOnce PVC — can't attach to two pods
selector:
matchLabels:
app.kubernetes.io/name: redis
template:
metadata:
labels:
app.kubernetes.io/name: redis
app.kubernetes.io/part-of: honeydue
spec:
serviceAccountName: redis
nodeSelector:
honeydue/redis: "true"
securityContext:
runAsNonRoot: true
runAsUser: 999
runAsGroup: 999
fsGroup: 999
seccompProfile:
type: RuntimeDefault
containers:
- name: redis
image: redis:7-alpine
command:
- sh
- -c
- |
ARGS="--appendonly yes --appendfsync everysec --maxmemory 256mb --maxmemory-policy noeviction"
if [ -n "$REDIS_PASSWORD" ]; then
ARGS="$ARGS --requirepass $REDIS_PASSWORD"
fi
exec redis-server $ARGS
ports:
- containerPort: 6379
protocol: TCP
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
env:
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: honeydue-secrets
key: REDIS_PASSWORD
optional: true
volumeMounts:
- name: redis-data
mountPath: /data
- name: tmp
mountPath: /tmp
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
readinessProbe:
exec:
command:
- sh
- -c
- |
if [ -n "$REDIS_PASSWORD" ]; then
redis-cli -a "$REDIS_PASSWORD" ping 2>/dev/null | grep -q PONG
else
redis-cli ping | grep -q PONG
fi
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 5
livenessProbe:
exec:
command:
- sh
- -c
- |
if [ -n "$REDIS_PASSWORD" ]; then
redis-cli -a "$REDIS_PASSWORD" ping 2>/dev/null | grep -q PONG
else
redis-cli ping | grep -q PONG
fi
initialDelaySeconds: 15
periodSeconds: 20
timeoutSeconds: 5
volumes:
- name: redis-data
persistentVolumeClaim:
claimName: redis-data
- name: tmp
emptyDir:
medium: Memory
sizeLimit: 64Mi

View File

@@ -0,0 +1,15 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: redis-data
namespace: honeydue
labels:
app.kubernetes.io/name: redis
app.kubernetes.io/part-of: honeydue
spec:
accessModes:
- ReadWriteOnce
storageClassName: local-path
resources:
requests:
storage: 5Gi

View File

@@ -0,0 +1,16 @@
apiVersion: v1
kind: Service
metadata:
name: redis
namespace: honeydue
labels:
app.kubernetes.io/name: redis
app.kubernetes.io/part-of: honeydue
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: redis
ports:
- port: 6379
targetPort: 6379
protocol: TCP

View File

@@ -0,0 +1,47 @@
# EXAMPLE ONLY — never commit real values.
# Secrets are created by scripts/02-setup-secrets.sh.
# This file shows the expected structure for reference.
---
apiVersion: v1
kind: Secret
metadata:
name: honeydue-secrets
namespace: honeydue
type: Opaque
stringData:
POSTGRES_PASSWORD: "CHANGEME"
SECRET_KEY: "CHANGEME_MIN_32_CHARS"
EMAIL_HOST_PASSWORD: "CHANGEME"
FCM_SERVER_KEY: "CHANGEME"
---
apiVersion: v1
kind: Secret
metadata:
name: honeydue-apns-key
namespace: honeydue
type: Opaque
data:
apns_auth_key.p8: "" # base64-encoded .p8 file contents
---
apiVersion: v1
kind: Secret
metadata:
name: ghcr-credentials
namespace: honeydue
type: kubernetes.io/dockerconfigjson
data:
.dockerconfigjson: "" # base64-encoded Docker config
---
apiVersion: v1
kind: Secret
metadata:
name: cloudflare-origin-cert
namespace: honeydue
type: kubernetes.io/tls
data:
tls.crt: "" # base64-encoded origin certificate
tls.key: "" # base64-encoded origin private key