feat(supabase): add services and statefulset for database management
feat(synapse): update ingress to use traefik ingress class and bump version feat(syncthing-discovery): introduce syncthing discovery service with deployment and ingress feat(syncthing-relay): add syncthing relay server with deployment and ingress configuration fix(taiga): update liveness and readiness probes to use tcpSocket for health checks fix(taiga): change PVC access mode to ReadWriteMany for media and static storage feat(traefik): add icon and ignore rules for traefik service docs(ushahidi): add notes for Redis configuration and Laravel startup probe adjustments feat(ushahidi): implement dedicated Redis deployment for Ushahidi fix(vllm): update deployment strategy and readiness/liveness probes for improved stability fix(writefreely): pin writefreely image version to v0.15.1 for consistency docs(zulip): add notes for TLS-terminating reverse proxy configuration and expected behavior
This commit is contained in:
78
docs/database.md
Normal file
78
docs/database.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# Database Patterns
|
||||
|
||||
## PostgreSQL
|
||||
|
||||
### Always use `?sslmode=disable` `[WC-SSL]`
|
||||
|
||||
Wild Cloud's internal PostgreSQL has no SSL configured. Without `?sslmode=disable`, connections fail with "The server does not support SSL connections."
|
||||
|
||||
```yaml
|
||||
defaultSecrets:
|
||||
- key: dbUrl
|
||||
default: "postgresql://{{ .app.db.user }}:{{ .secrets.dbPassword }}@{{ .app.db.host }}:{{ .app.db.port }}/{{ .app.db.name }}?sslmode=disable"
|
||||
```
|
||||
|
||||
Also set `PGSSLMODE=disable` for apps that use libpq directly.
|
||||
|
||||
### db-init-job
|
||||
|
||||
Include a `db-init-job.yaml` for every app that uses PostgreSQL. See `immich`, `gitea`, or `openproject` for reference implementations. The job must:
|
||||
|
||||
- Create the database if it doesn't exist
|
||||
- Create/update the user with correct credentials
|
||||
- Grant permissions
|
||||
- Install required extensions (`vector`, `pg_trgm`, etc.)
|
||||
- Use `restartPolicy: OnFailure` and `runAsUser: 999`
|
||||
- Be idempotent — safe to re-run after redeploy
|
||||
|
||||
### Database URL secrets
|
||||
|
||||
When an app needs a connection URL with embedded credentials, use a `dbUrl` secret — do not construct URLs inline:
|
||||
|
||||
```yaml
|
||||
# Wrong: Kustomize cannot do runtime env var substitution
|
||||
- name: DB_URL
|
||||
value: "postgresql://user:$(DB_PASSWORD)@host/db"
|
||||
|
||||
# Correct: use a secret with the full URL
|
||||
- name: DB_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: myapp-secrets
|
||||
key: dbUrl
|
||||
```
|
||||
|
||||
## MySQL
|
||||
|
||||
### db-init user password idempotency `[WC-DBIN]`
|
||||
|
||||
`CREATE USER IF NOT EXISTS` only sets the password on first creation. On redeploy against an existing database the password stays stale, causing "Access denied".
|
||||
|
||||
Always follow `CREATE USER` with `ALTER USER`:
|
||||
|
||||
```sql
|
||||
CREATE USER IF NOT EXISTS '${DB_USERNAME}'@'%' IDENTIFIED BY '${DB_PASSWORD}';
|
||||
ALTER USER '${DB_USERNAME}'@'%' IDENTIFIED BY '${DB_PASSWORD}';
|
||||
GRANT ALL PRIVILEGES ON ${DB_DATABASE_NAME}.* TO '${DB_USERNAME}'@'%';
|
||||
FLUSH PRIVILEGES;
|
||||
```
|
||||
|
||||
`ALTER USER` is a no-op when the user was just created — it is safe to always include it.
|
||||
|
||||
### Required secrets reference
|
||||
|
||||
MySQL secrets are copied into `<app>-secrets`, not `mysql-secrets`. Reference them as:
|
||||
|
||||
```yaml
|
||||
secretKeyRef:
|
||||
name: myapp-secrets
|
||||
key: mysql.rootPassword # not mysql-secrets / rootPassword
|
||||
```
|
||||
|
||||
## Database env var naming
|
||||
|
||||
Name database-related env vars so the backup system can identify them:
|
||||
|
||||
- **Database name**: include `DATABASE`, `DB_NAME`, `DBNAME`, or `__DATABASE`
|
||||
- **Database URLs**: value must contain `://`
|
||||
- **Usernames**: include `USER` — these are not patched on restore
|
||||
44
docs/jvm.md
Normal file
44
docs/jvm.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# JVM (Spring Boot / Scala / Kotlin)
|
||||
|
||||
## Startup delay
|
||||
|
||||
Spring Boot loads the full application context before accepting HTTP. Cold starts typically take 60–120 seconds depending on the number of beans and auto-configuration classes.
|
||||
|
||||
```yaml
|
||||
livenessProbe:
|
||||
tcpSocket:
|
||||
port: 8080
|
||||
initialDelaySeconds: 120
|
||||
periodSeconds: 30
|
||||
failureThreshold: 6
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /actuator/health
|
||||
port: 8080
|
||||
initialDelaySeconds: 90
|
||||
periodSeconds: 15
|
||||
failureThreshold: 3
|
||||
```
|
||||
|
||||
If the app exposes a Spring Actuator health endpoint, prefer it for readiness over the root path.
|
||||
|
||||
## Heap limit
|
||||
|
||||
Set `-Xmx` to keep the heap within the container memory limit. The JVM does not automatically respect cgroup limits on older JDK versions.
|
||||
|
||||
```yaml
|
||||
env:
|
||||
- name: JAVA_OPTS
|
||||
value: "-Xmx512m -Xms256m"
|
||||
resources:
|
||||
limits:
|
||||
memory: 768Mi
|
||||
requests:
|
||||
memory: 512Mi
|
||||
```
|
||||
|
||||
Keep `-Xmx` 25–33% below the container limit to leave room for off-heap memory (metaspace, direct buffers, GC overhead).
|
||||
|
||||
## Resources
|
||||
|
||||
512Mi–1Gi request is typical. JVM startup can spike higher — use `requests` to reserve and `limits` to cap.
|
||||
44
docs/linuxserver.md
Normal file
44
docs/linuxserver.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# linuxserver.io / s6-overlay images
|
||||
|
||||
## Security context
|
||||
|
||||
Images using the s6-overlay init system (all linuxserver.io images) must run as root and need full capabilities to switch to their internal `abc` user. **Do not drop capabilities** — s6-overlay's user-switching will fail.
|
||||
|
||||
```yaml
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
securityContext: # pod level
|
||||
runAsNonRoot: false
|
||||
runAsUser: 0
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: app
|
||||
securityContext: # container level — only this field
|
||||
readOnlyRootFilesystem: false
|
||||
```
|
||||
|
||||
Do NOT set `allowPrivilegeEscalation: false` or `capabilities.drop: ALL` for these images.
|
||||
|
||||
Suppress `WC-SC-POD` and `WC-SC-CTR` in `app.yaml`:
|
||||
|
||||
```yaml
|
||||
ignoreRules:
|
||||
- WC-SC-POD # s6-overlay requires root
|
||||
- WC-SC-CTR # s6-overlay requires full capabilities
|
||||
```
|
||||
|
||||
## PUID / PGID
|
||||
|
||||
linuxserver.io images accept `PUID` and `PGID` env vars to set the internal `abc` user's UID/GID. Set them explicitly for consistent file ownership on PVCs:
|
||||
|
||||
```yaml
|
||||
env:
|
||||
- name: PUID
|
||||
value: "1000"
|
||||
- name: PGID
|
||||
value: "1000"
|
||||
```
|
||||
|
||||
**Affected apps**: BookStack, and any app using a linuxserver.io image.
|
||||
54
docs/nginx.md
Normal file
54
docs/nginx.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# nginx-based images
|
||||
|
||||
## Security context
|
||||
|
||||
nginx binds to port 80 (or 443) and needs to `chown` files before dropping to `www-data`. It must run as root but can drop most capabilities after startup.
|
||||
|
||||
```yaml
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
securityContext: # pod level
|
||||
runAsNonRoot: false
|
||||
runAsUser: 0
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: app
|
||||
securityContext: # container level
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: [ALL]
|
||||
add: [CHOWN, SETUID, SETGID, NET_BIND_SERVICE]
|
||||
readOnlyRootFilesystem: false
|
||||
```
|
||||
|
||||
Add `NET_BIND_SERVICE` only if the container binds to a port below 1024. Drop it if the app uses a high port (8080, 3000, etc.).
|
||||
|
||||
## Docker DNS resolver (`WC-DKRDNS`)
|
||||
|
||||
nginx proxy configs shipped for Docker often contain:
|
||||
|
||||
```nginx
|
||||
resolver 127.0.0.11 valid=3s;
|
||||
set $backend app-backend:8000;
|
||||
proxy_pass http://$backend$request_uri;
|
||||
```
|
||||
|
||||
`127.0.0.11` is Docker's embedded DNS resolver — it does not exist in Kubernetes. This fails with `send() failed (111: Connection refused) while resolving`.
|
||||
|
||||
There's a second trap: **any nginx variable anywhere in the `proxy_pass` URL** (even `$request_uri` in the path) forces runtime DNS resolution and requires a `resolver` directive. Removing the resolver line but keeping `proxy_pass http://backend:8000$request_uri;` still fails with `no resolver defined to resolve backend`.
|
||||
|
||||
**Fix**: override the nginx config template via a ConfigMap mounted with `subPath`, and remove the variable from `proxy_pass` entirely:
|
||||
|
||||
```nginx
|
||||
# Instead of:
|
||||
resolver 127.0.0.11 valid=3s;
|
||||
set $backend ${BACKEND};
|
||||
proxy_pass http://$backend$request_uri;
|
||||
|
||||
# Use:
|
||||
proxy_pass http://${BACKEND};
|
||||
```
|
||||
|
||||
With no nginx variable in `proxy_pass`, nginx resolves the hostname at startup using the pod's `/etc/resolv.conf` (which points to CoreDNS). The full request URI is still forwarded automatically in a regex `location ~` block. Mount the ConfigMap with `subPath` to override just the template file without replacing the whole directory.
|
||||
20
docs/nodejs.md
Normal file
20
docs/nodejs.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Node.js
|
||||
|
||||
## Memory limits
|
||||
|
||||
Node.js apps frequently OOM-crash at the default 512Mi limit. Set a higher limit and cap the V8 heap so it stays under it:
|
||||
|
||||
```yaml
|
||||
resources:
|
||||
limits:
|
||||
memory: 1Gi
|
||||
requests:
|
||||
memory: 512Mi
|
||||
env:
|
||||
- name: NODE_OPTIONS
|
||||
value: "--max-old-space-size=768"
|
||||
```
|
||||
|
||||
`--max-old-space-size` is in MiB. Keep it 25–33% below the container limit so the process has headroom for non-heap memory (buffers, native modules, etc.).
|
||||
|
||||
**Applies to**: NocoDB, Outline, Gitea (web), and any Electron/Express/Next.js app.
|
||||
31
docs/php.md
Normal file
31
docs/php.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# PHP (Laravel / Symfony)
|
||||
|
||||
## Startup delay
|
||||
|
||||
Laravel's startup sequence (autoload optimization, key generation, migrations, package discovery) commonly takes 2–3 minutes on cluster restart or first boot. The default `initialDelaySeconds: 60` is too short — the liveness probe fires before the app is ready, kills the container, and a restart loop begins.
|
||||
|
||||
**Symptom**: pod shows `Running` for ~2 minutes, then gets `Killing` due to liveness probe failure, then a new pod starts the same cycle.
|
||||
|
||||
Use a `tcpSocket` liveness probe (avoids the Host header issue) with an extended initial delay:
|
||||
|
||||
```yaml
|
||||
livenessProbe:
|
||||
tcpSocket:
|
||||
port: 8080
|
||||
initialDelaySeconds: 120
|
||||
periodSeconds: 30
|
||||
failureThreshold: 6
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 8080
|
||||
initialDelaySeconds: 60
|
||||
periodSeconds: 15
|
||||
failureThreshold: 3
|
||||
```
|
||||
|
||||
Symfony has the same behavior. Adjust `initialDelaySeconds` up if migrations are slow.
|
||||
|
||||
## Resources
|
||||
|
||||
256–512Mi request is typical for a PHP-FPM or Artisan-served app. Workers (queue:work, horizon) are separate processes — give them their own Deployment and 256–512Mi each.
|
||||
65
docs/python.md
Normal file
65
docs/python.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# Python (Django / FastAPI)
|
||||
|
||||
## Health probe host header
|
||||
|
||||
Kubernetes sends probe requests directly to the pod IP. Django's `ALLOWED_HOSTS` rejects requests without a matching `Host` header, causing every probe to return 400 and the pod to restart. Add a `Host` header to all HTTP probes:
|
||||
|
||||
```yaml
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 8000
|
||||
httpHeaders:
|
||||
- name: Host
|
||||
value: "{{ .domain }}"
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 8000
|
||||
httpHeaders:
|
||||
- name: Host
|
||||
value: "{{ .domain }}"
|
||||
```
|
||||
|
||||
FastAPI and Flask apps with explicit host validation have the same issue.
|
||||
|
||||
## Startup delay (apps that run migrations)
|
||||
|
||||
Django apps that run `migrate` on startup need extra time before the liveness probe fires. The default 60-second delay is too short for large migration sets.
|
||||
|
||||
```yaml
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 8000
|
||||
httpHeaders:
|
||||
- name: Host
|
||||
value: "{{ .domain }}"
|
||||
initialDelaySeconds: 90
|
||||
periodSeconds: 30
|
||||
failureThreshold: 6
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 8000
|
||||
httpHeaders:
|
||||
- name: Host
|
||||
value: "{{ .domain }}"
|
||||
initialDelaySeconds: 60
|
||||
periodSeconds: 15
|
||||
failureThreshold: 3
|
||||
```
|
||||
|
||||
## Celery workers
|
||||
|
||||
Celery workers are full Python processes. Size them at 256–512Mi and deploy them as a separate Deployment from the web process. They do not need an HTTP probe — use a `tcpSocket` or `exec` check if a liveness probe is needed at all.
|
||||
|
||||
## Non-interactive superuser creation
|
||||
|
||||
```bash
|
||||
kubectl exec -n <ns> <pod> -- \
|
||||
env DJANGO_SUPERUSER_PASSWORD="${PASSWORD}" \
|
||||
python manage.py createsuperuser --email "${EMAIL}" --noinput
|
||||
```
|
||||
|
||||
`--noinput` reads the password from `DJANGO_SUPERUSER_PASSWORD`.
|
||||
33
docs/redis.md
Normal file
33
docs/redis.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Redis
|
||||
|
||||
## Authenticated Redis URL
|
||||
|
||||
Wild Cloud's Redis requires a password. Apps that take a Redis URL must embed the password. Use Kubernetes env var expansion so the password isn't hardcoded:
|
||||
|
||||
```yaml
|
||||
requiredSecrets:
|
||||
- redis.password
|
||||
|
||||
# In deployment env:
|
||||
- name: REDIS_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: myapp-secrets
|
||||
key: redis.password
|
||||
- name: REDIS_URL
|
||||
value: "redis://:$(REDIS_PASSWORD)@{{ .redis.host }}:6379"
|
||||
```
|
||||
|
||||
`$(REDIS_PASSWORD)` is evaluated by Kubernetes at pod start from other env vars in the same container spec.
|
||||
|
||||
## Apps that don't support Redis passwords
|
||||
|
||||
Some apps only accept `REDIS_HOST` and `REDIS_PORT` with no password option (e.g. Ushahidi). Deploy a dedicated unauthenticated Redis sidecar for those apps and remove `redis` from `requires` in `manifest.yaml`:
|
||||
|
||||
```yaml
|
||||
- name: redis
|
||||
image: redis:7-alpine
|
||||
args: ["--save", ""] # disable persistence
|
||||
```
|
||||
|
||||
Point the app's `REDIS_HOST` at the sidecar's service name.
|
||||
32
docs/ruby.md
Normal file
32
docs/ruby.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# Ruby (Rails)
|
||||
|
||||
## Startup delay
|
||||
|
||||
Rails startup (bundle exec, asset precompilation, initializer chain) typically takes 30–90 seconds. Extend the liveness probe delay to avoid restart loops:
|
||||
|
||||
```yaml
|
||||
livenessProbe:
|
||||
tcpSocket:
|
||||
port: 3000
|
||||
initialDelaySeconds: 90
|
||||
periodSeconds: 30
|
||||
failureThreshold: 6
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 3000
|
||||
initialDelaySeconds: 60
|
||||
periodSeconds: 15
|
||||
failureThreshold: 3
|
||||
```
|
||||
|
||||
## Sidekiq workers
|
||||
|
||||
Sidekiq is a full Ruby process, not a thread pool. Deploy it as a separate Deployment from the web process with 256–512Mi memory. It does not need an HTTP liveness probe — omit it or use a simple `exec` check.
|
||||
|
||||
## Non-interactive rails console / rake tasks
|
||||
|
||||
```bash
|
||||
kubectl exec -n <ns> <pod> -- bundle exec rake db:migrate
|
||||
kubectl exec -n <ns> <pod> -- bundle exec rails runner "User.create!(...)"
|
||||
```
|
||||
45
docs/scripts.md
Normal file
45
docs/scripts.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# Post-Deploy Scripts
|
||||
|
||||
Some apps require a management command after first deploy to create an admin account, register a node, or invite the first user. Package these as shell scripts in `scripts/` alongside the kustomize files.
|
||||
|
||||
## Registering scripts
|
||||
|
||||
Register every script in `manifest.yaml` — the web UI shows a button with a parameter form for each one:
|
||||
|
||||
```yaml
|
||||
scripts:
|
||||
- name: create-superuser
|
||||
path: scripts/create-superuser.sh
|
||||
description: "Create the initial admin account."
|
||||
params:
|
||||
- name: EMAIL
|
||||
required: true
|
||||
- name: PASSWORD
|
||||
description: Leave blank to generate a random one
|
||||
```
|
||||
|
||||
## Script conventions
|
||||
|
||||
Follow `synapse/versions/v1/scripts/create-user.sh` as the reference implementation:
|
||||
|
||||
- Require `KUBECONFIG`, `WILD_INSTANCE`, and `WILD_API_DATA_DIR`; exit with a clear error if missing
|
||||
- Read `namespace` from `config.yaml` via `yq` — never hardcode it
|
||||
- Auto-generate passwords with `openssl rand` if `PASSWORD` is not supplied
|
||||
- Find the running pod by label — never hardcode a pod name
|
||||
- Print credentials at the end with a "save this — it won't be shown again" warning
|
||||
|
||||
## Common commands
|
||||
|
||||
**Django non-interactive superuser**:
|
||||
```bash
|
||||
kubectl exec -n <ns> <pod> -- \
|
||||
env DJANGO_SUPERUSER_PASSWORD="${PASSWORD}" \
|
||||
python manage.py createsuperuser --email "${EMAIL}" --noinput
|
||||
```
|
||||
|
||||
**Rails**:
|
||||
```bash
|
||||
kubectl exec -n <ns> <pod> -- bundle exec rake db:migrate
|
||||
```
|
||||
|
||||
**Affected apps**: Eventyay, Synapse, Headscale.
|
||||
47
docs/traefik.md
Normal file
47
docs/traefik.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# Traefik
|
||||
|
||||
## "No available server" after redeploy `[WC-SEL]`
|
||||
|
||||
If a Deployment was originally created without kustomize labels in its selector, re-deploying cannot fix it — `spec.selector` is immutable once set. The Service selector updates (it's mutable) but pods won't match it, leaving the Service with no endpoints.
|
||||
|
||||
**Symptom**: Traefik returns "No available server" even though pods are Running.
|
||||
|
||||
**Diagnosis**:
|
||||
```bash
|
||||
kubectl get endpoints <app> -n <namespace>
|
||||
# shows <none> — pods aren't matching the service
|
||||
kubectl get pods -n <namespace> --show-labels
|
||||
# pod labels don't include app: <name> / managedBy: kustomize / partOf: wild-cloud
|
||||
```
|
||||
|
||||
**Fix**: delete the Deployment and re-deploy:
|
||||
```bash
|
||||
kubectl delete deployment <name> -n <ns>
|
||||
wild app deploy <app>
|
||||
```
|
||||
|
||||
## Same-origin iframes
|
||||
|
||||
The global Traefik `security-headers` middleware sets `X-Frame-Options: SAMEORIGIN`. Apps that embed their own sub-pages in iframes (e.g. Etherpad's pad editor) work correctly with this setting.
|
||||
|
||||
`DENY` was the previous setting and broke same-origin iframes. If you see:
|
||||
```
|
||||
Blocked a frame with origin "https://..." from accessing a cross-origin frame
|
||||
```
|
||||
the app is likely creating a same-origin iframe that a stale `DENY` header is blocking. Verify the cluster's Traefik middleware is using `SAMEORIGIN`.
|
||||
|
||||
**Note**: route-level Traefik middlewares run before entrypoint middlewares on the response path. A per-app middleware cannot override the global one.
|
||||
|
||||
## TLS-terminating reverse proxy (DISABLE_HTTPS)
|
||||
|
||||
Some apps (e.g. Zulip) redirect port 80 → HTTPS internally. When Traefik terminates TLS and forwards plain HTTP, this causes an infinite redirect loop.
|
||||
|
||||
Set these env vars to disable the internal redirect and trust the forwarded proto:
|
||||
```yaml
|
||||
- name: DISABLE_HTTPS
|
||||
value: "true"
|
||||
- name: LOADBALANCER_IPS
|
||||
value: "10.244.0.0/16" # Kubernetes pod CIDR (Flannel default)
|
||||
```
|
||||
|
||||
Also update liveness/readiness probes from HTTPS port 443 to HTTP port 80.
|
||||
Reference in New Issue
Block a user