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:
2026-07-02 21:34:27 +00:00
parent 9f5057dff8
commit 4d983819c9
151 changed files with 3403 additions and 1303 deletions

View File

@@ -1,362 +0,0 @@
# Adding Apps - Notes & Documentation Improvements
## Documentation Improvement Candidates
Issues and gaps discovered while adding apps. These should be folded back into ADDING-APPS.md.
### 1. Node.js app memory requirements
Node.js apps (NocoDB, Outline, etc.) need more than 512Mi memory. NocoDB crashed with OOM at 512Mi.
- Recommended: 1Gi limit, 512Mi request for Node.js apps
- Set `NODE_OPTIONS=--max-old-space-size=768` to keep heap within the limit
- **Doc suggestion**: Add a section in ADDING-APPS.md about runtime-specific resource defaults:
- Node.js apps: 1Gi limit, 512Mi request; add NODE_OPTIONS env var
- JVM apps (Java/Kotlin): 1Gi+ limit
- Python/Ruby apps: 512Mi typically sufficient
- Go/Rust apps: 256-512Mi typically sufficient
### 2. db-init-job is repeated boilerplate
The db-init-job.yaml for PostgreSQL apps is nearly identical across all apps. Only the app name, namespace, and secret name change.
- **Doc suggestion**: Provide a copy-paste template for the standard PostgreSQL db-init-job with placeholders marked clearly.
### 4. Apps requiring hex-encoded secrets
Some apps (Outline) require secrets in specific formats (e.g., exactly 64 hex chars). The default Wild Cloud `GenerateSecret` produces alphanumeric (not hex) strings.
- **Solution**: Use `crypto.SHA256` gomplate function in the manifest `default` field to generate a valid 64-char hex string from another secret:
```yaml
defaultSecrets:
- key: utilsSecret
- key: secretKey
default: '{{ crypto.SHA256 .secrets.utilsSecret }}'
```
- **Doc suggestion**: Add this pattern to ADDING-APPS.md with examples of apps that need it. Note that the order of `defaultSecrets` matters — referenced secrets must come first.
### 6. Always use `?sslmode=disable` in PostgreSQL connection strings
The Wild Cloud internal PostgreSQL does not have SSL enabled. Apps that construct PostgreSQL connection strings must include `?sslmode=disable`, and deployments should set `PGSSLMODE=disable`. Without it, apps crash with "The server does not support SSL connections."
- Already done correctly in: listmonk, gitea, discourse
- Missing from: outline (fixed), any new app
- **Doc suggestion**: Add to the ADDING-APPS.md dbUrl template examples: always append `?sslmode=disable` to postgres connection strings.
### 5. Re-running `wild app add` is required after template changes
When you modify files in wild-directory (e.g., fix a template), you must re-run `wild app add <app>` before deploying. The compiled templates in the instance `apps/` directory are NOT automatically updated when wild-directory changes. Only then will `wild app deploy` use the updated templates.
- **Doc suggestion**: Add to ADDING-APPS.md: "After modifying templates in wild-directory, always re-run `wild app add <app>` to recompile before deploying."
### 7. WriteFreely requires root user (jrasanen/writefreely image)
The `jrasanen/writefreely` image startup script creates `config.ini` in `/writefreely/` which is a root-owned directory. The image's default user is non-root, causing "Permission denied" errors at startup.
- **Fix**: Set `runAsUser: 0` and `runAsNonRoot: false` in the pod securityContext
- Still safe with `allowPrivilegeEscalation: false` and `capabilities.drop: ALL`
- **Doc suggestion**: Note that some community images require root. When an image fails with permission denied on startup, check if it needs root and add the security context override with capabilities dropped.
### 8. Redis URL must include password for authenticated Redis
Wild Cloud's Redis requires authentication. Apps that take a Redis URL must include the password in the URL: `redis://:password@host:6379`.
- **Pattern**: Add `redis.password` to `requiredSecrets`, then use K8s env var expansion:
```yaml
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: myapp-secrets
key: redis.password
- name: REDIS_URL
value: redis://:$(REDIS_PASSWORD)@{{ .redis.host }}:6379
```
- The `$(VAR_NAME)` syntax is supported by Kubernetes in container env values (evaluated at pod start)
- **Doc suggestion**: Add this pattern to ADDING-APPS.md under "Redis Authentication" section.
### 3. Consistent health check paths across apps
Different apps use different health check paths (`/health`, `/healthz`, `/_health`, `/api/v1/health`). This should be documented per app type or noted that you must verify the actual path from the app's Docker docs.
- For apps where no valid HTTP health path exists (e.g., Typebot viewer which only serves chatbot URLs), use `tcpSocket` probe instead of `httpGet`.
### 9. Linuxserver.io and s6-overlay images require root + capabilities
Images using s6-overlay init system (most linuxserver.io images) must run as root and need full capabilities to switch to their internal "abc" user.
- Set `runAsUser: 0, runAsNonRoot: false` in pod securityContext
- Do NOT set `allowPrivilegeEscalation: false` or `capabilities.drop: ALL` in container securityContext
- Only keep `readOnlyRootFilesystem: false`
- Same applies to Nextcloud (uses rsync/chown during setup)
- **Affects**: BookStack (linuxserver.io), any linuxserver.io image
### 10. CryptPad needs emptyDir + initContainer for config directory
The CryptPad image's `/cryptpad/config/` directory is in the image overlay filesystem and not writable by the container process even when running as root.
- **Fix**: Mount an emptyDir at `/cryptpad/config`, and use an initContainer to pre-seed `config.example.js` from the image into the emptyDir.
- The initContainer mounts the emptyDir at a different path (e.g., `/config-dest`) and copies the file.
- **CPAD_CONF** env var must be set to `/cryptpad/config/config.js` (startup script copies config.example.js to config.js if config.js doesn't exist).
### 11. Pixelfed image requires authentication (ghcr.io access restriction)
~~`ghcr.io/pixelfed/pixelfed` returns 403 Forbidden for anonymous pulls.~~
- **Resolved**: Use `ghcr.io/mattlqx/docker-pixelfed:v0.12.7-nginx` (community image, publicly accessible)
- Note: the mattlqx image uses port 80 (nginx), not 8080. Update `containerPort` and service `targetPort` accordingly.
### 12. Bitnami images no longer on Docker Hub
Bitnami moved their images from Docker Hub (`bitnami/appname`) to their own OCI registry. Apps that were packaged using Bitnami images (e.g., Ghost) need to be updated to use the official upstream image or Bitnami's new registry.
- **Ghost fix**: Switched from `bitnami/ghost:5.x` to official `ghost:5.130.6-alpine`
- The official ghost image uses different env vars (`database__connection__host` instead of `GHOST_DATABASE_HOST`) and a different mount path (`/var/lib/ghost/content` instead of `/bitnami/ghost`)
- Check image availability before packaging: `docker manifest inspect docker.io/bitnami/appname:tag`
### 13. Required secrets belong in app-secrets, not dep-secrets
Apps that use `requiredSecrets` (e.g., `mysql.rootPassword`) receive those secrets copied into their own `<app>-secrets` K8s Secret, under the key `<dep>.<key>` (e.g., `mysql.rootPassword`). db-init jobs and deployments must reference `<app>-secrets`, NOT `mysql-secrets`.
```yaml
# WRONG - mysql-secrets doesn't exist in the app namespace
secretKeyRef:
name: mysql-secrets
key: rootPassword
# CORRECT - the required secret is copied into ghost-secrets
secretKeyRef:
name: ghost-secrets
key: mysql.rootPassword
```
### 15. mattlqx/docker-pixelfed requires APP_PORT env var
The `ghcr.io/mattlqx/docker-pixelfed` community image generates nginx config from env vars using `sed`. The `listen` directive uses `APP_PORT`, which must be set explicitly or nginx crashes with "invalid number of arguments in 'listen' directive".
- Add `APP_PORT: "80"` to the deployment env vars
- The image listens on port 80 (nginx), not 8080 as the original pixelfed image did
- Both the web and worker deployments must mount the shared storage PVC — use ReadWriteMany access mode, not ReadWriteOnce, since both pods need it simultaneously
### 14. Your Priorities and Polis have no public Docker images
- `ghcr.io/citizensfoundation/your-priorities-app` — 403 Forbidden, images require auth
- `compdemocracy/polis-server` — private, no public Docker Hub or ghcr.io images
- Both apps require building custom images from source before they can be packaged
- Attempting to add these to wild-directory results in ImagePullBackOff
### 16. PHP/Laravel and other heavy-framework apps need extended probe delays
Laravel's startup process runs package discovery, autoload optimization, and encryption key generation before the HTTP server is ready. This commonly takes 23 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
- Set `initialDelaySeconds: 120` and `failureThreshold: 6` for liveness probes on Laravel/Symfony apps
- Set `initialDelaySeconds: 60` for readiness probes
- The symptom is: pod shows `Running` for ~2 minutes then gets `Killing` due to liveness probe failure, followed by a new pod starting the same cycle
- The same applies to other frameworks with heavy startup initialization: Rails (`bundle exec`, asset precompilation), Spring Boot (JVM + Spring context loading), Django with migrations
- **Affects**: Ushahidi API (Laravel) — fixed with 120s initial delay + failureThreshold: 6
### 17. MySQL db-init `CREATE USER IF NOT EXISTS` doesn't update passwords
The common db-init pattern `CREATE USER IF NOT EXISTS 'user'@'%' IDENTIFIED BY '${PASSWORD}'` only sets the password at creation time. If the MySQL user already exists from a previous deployment (e.g., after `wild app delete` + `wild app add` without dropping the database), the password is silently left unchanged and the new deployment fails with "Access denied for user".
- **Symptom**: App starts, connects to MySQL, gets "Access denied" even though the db-init job completed successfully
- **Quick fix**: Exec into the MySQL pod and run: `ALTER USER 'user'@'%' IDENTIFIED BY 'current_password'; FLUSH PRIVILEGES;`
- **Better pattern** — make db-init jobs truly idempotent for both user creation and password:
```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;
```
- This `CREATE ... IF NOT EXISTS` + `ALTER USER` pattern is safe to run repeatedly regardless of whether the user existed before
- **Affects**: Ghost (MySQL backend) — hit this when redeploying after a previous partial test run
### 18. Gancio "Non empty db" crash on re-deploy
Gancio stores its setup state in `config.json` on the data PVC. If the PVC is lost or the app is deleted and redeployed with an existing database, gancio finds a non-empty DB but no `config.json` and refuses to start with "Non empty db! Please move your current db elsewhere than retry."
- **Fix**: Drop the database schema and let gancio recreate it:
```bash
kubectl exec -n postgres <postgres-pod> -- psql -U postgres gancio \
-c "DROP SCHEMA public CASCADE; CREATE SCHEMA public; GRANT ALL ON SCHEMA public TO gancio; GRANT ALL ON SCHEMA public TO public;"
kubectl scale deployment -n gancio gancio --replicas=0
# (wait for pod to terminate)
kubectl scale deployment -n gancio gancio --replicas=1
```
- Scale down first to release the DB connection before dropping the schema
- This only affects re-deployments; fresh installs work fine
### 19. Odoo requires explicit database name and `-i base` on first run
The official Odoo Docker image does not auto-initialize the database. Without specifying the database name and installing the base module, Odoo starts in multi-database manager mode and health checks fail with "Database not initialized".
- Add `args: ["-d", "DATABASE_NAME", "-i", "base"]` to the container spec
- The `-i base` flag installs Odoo's base module and creates all database tables on first run
- Subsequent runs with `-i base` are safe (idempotent) — it updates rather than reinstalls
- Use a `startupProbe` with `failureThreshold: 40, periodSeconds: 30` (20 minutes) since first-run initialization takes 1015 minutes
- Once startup probe passes, the regular liveness/readiness probes with `initialDelaySeconds: 0` take over
### 20. Headscale v0.29+ CLI: inconsistent user flag types
In headscale v0.29, different commands accept different user identifiers:
- `preauthkeys create --user <id>` — requires numeric ID
- `auth register --user <username>` — accepts username string
- `users destroy --identifier <id>` — requires numeric ID, not a positional arg
```bash
# Always list users first to get the ID
headscale users list
headscale preauthkeys create --user 1 # numeric ID
headscale auth register --auth-id <id> --user payne # username string OK
headscale users destroy --identifier 1 # numeric ID
```
Node registration flow: when a Tailscale client connects via browser (not pre-auth key), headscale
shows a command like `headscale auth register --auth-id hskey-authreq-XXXX --user USERNAME`.
Run it via `kubectl exec -n headscale deploy/headscale -- headscale auth register --auth-id <id> --user <username>`.
### 21. "No available server" from Traefik — kustomize label selector mismatch
If a deployment was originally created without kustomize labels in its selector (or with stale templates), re-deploying via `wild app deploy` cannot fix it because `spec.selector` on a Deployment is immutable once set. The Service selector will update (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**: `kubectl get endpoints <app> -n <namespace>` shows `<none>`; pod labels don't include `app: <name>` / `managedBy: kustomize` / `partOf: wild-cloud`
- **Fix**: Delete the affected deployments and re-apply kustomize — `kubectl delete deployment <name> -n <ns>` then `kubectl apply -k <instance>/apps/<app>/`
- **Root cause**: The Deployment selector is set correctly on first deploy, but if the resource already existed with only `component: web` (e.g., from a pre-kustomize deployment), the selector is stuck
### 22. Apps using same-origin iframes need SAMEORIGIN frame policy
The global Traefik `security-headers` middleware applies `X-Frame-Options: SAMEORIGIN` to all responses (changed from `DENY`). Apps that use iframes internally (like Etherpad's pad editor, which loads `../static/empty.html` in an iframe) were broken by the stricter `DENY` policy.
- **Root cause**: `DENY` prevents even same-origin iframes. `SAMEORIGIN` still blocks cross-origin embedding while allowing apps to embed their own sub-pages.
- **Symptoms**: JavaScript error `Blocked a frame with origin "https://..." from accessing a cross-origin frame` — occurs when an app creates a same-origin iframe and then the `X-Frame-Options: DENY` header causes the browser to block access to it.
- **Important**: Route-level Traefik middlewares run BEFORE entrypoint middlewares on the response path. A per-app middleware cannot override a global entrypoint middleware — the entrypoint middleware always runs last.
- **Fix applied globally**: Changed `frameDeny: true` to `X-Frame-Options: SAMEORIGIN` in `customResponseHeaders` in the crowdsec `security-headers` middleware.
- **Affects**: Etherpad (pad editor iframe), any app that loads sub-pages in iframes on the same domain.
### 24. nginx proxy containers: Docker DNS resolver (`127.0.0.11`) doesn't work in Kubernetes
Apps that use nginx as a frontend proxy (e.g., karrot-frontend) often ship with an nginx config template that uses Docker's embedded DNS resolver:
```nginx
resolver 127.0.0.11 valid=3s;
set $backend app-backend:8000;
proxy_pass http://$backend$request_uri;
```
This fails in Kubernetes with `send() failed (111: Connection refused) while resolving, resolver: 127.0.0.11`. 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.
### 25. ConfigMap changes don't restart pods — and `subPath` mounts never auto-update
When you update a ConfigMap and run `wild app deploy` (`kubectl apply`), the ConfigMap object is updated but **pods are not restarted**. You must explicitly restart them:
```bash
kubectl rollout restart deployment/<name> -n <namespace>
```
There's a further subtlety: ConfigMaps mounted with `subPath` are **never** live-updated in the pod filesystem, even if you wait. The pod must be restarted to pick up changes regardless of the `subPath` mount update behavior.
This means: after any ConfigMap change, always follow up with a rollout restart for affected deployments.
### 26. The `includeSelectors: true` mismatch affects every deployment in the app — check them all
When diagnosing "no available server" from Traefik (note #21), it's easy to focus only on the deployment that serves the HTTP route and miss other deployments in the same namespace. The `includeSelectors: true` label propagation affects every resource in the kustomization — if any deployment pre-existed with only `component: X` labels, its Service will have `<none>` endpoints and dependent services (backends, workers, Redis) will silently fail.
**Always run this after any selector-related redeploy**:
```bash
kubectl get endpoints --all-namespaces | grep "<none>"
```
Check every deployment in the affected namespace, not just the one Traefik is routing to. A Redis pod with `<none>` endpoints will cause the backend to crash with `ConnectionError` even though the Redis pod itself shows `1/1 Running`.
**Checklist when fixing a selector mismatch in an app**:
1. `kubectl get endpoints -n <app>` — identify all services with `<none>`
2. `kubectl get pods -n <app> --show-labels` — confirm which pods have old labels
3. Delete ALL affected deployments (not just the web one), then redeploy
4. Verify all endpoints are populated before declaring the app fixed
### 23. Post-deploy admin scripts for apps that require manual first-run steps
Some apps can't complete setup through the web UI until an initial admin account is created via a CLI command. Package these as shell scripts in `scripts/` alongside the kustomize files so users have a repeatable, documented way to run them.
**When to add a script**: If the app's README or upstream docs require running a management command after deploy (creating a superuser, registering a node, inviting the first user, etc.) — wrap it in a script.
**Always register scripts in `manifest.yaml`** — the web UI automatically shows a button with a parameter form for every script listed there. Without this, users have to drop to the CLI even though the UI fully supports it. Format:
```yaml
scripts:
- name: create-superuser
path: scripts/create-superuser.sh
description: "Short description shown in the UI."
params:
- name: EMAIL
description: Description shown in the param form
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):
- Require `KUBECONFIG`, `WILD_INSTANCE`, and `WILD_API_DATA_DIR` env vars; exit with a clear error if missing
- Read `namespace` from `config.yaml` via `yq` so the script doesn't hardcode values
- Auto-generate passwords with `openssl rand` if `PASSWORD` is not supplied
- Find the running pod by label (don't hardcode a pod name); fail clearly if none found
- Print credentials at the end with a "save this — it won't be shown again" warning
- Reference the script in the app README's "First-Time Setup" section in place of a raw `kubectl exec` command
**Non-interactive Django `createsuperuser`**:
```bash
kubectl exec -n <ns> <pod> -c <container> -- \
env DJANGO_SUPERUSER_PASSWORD="${PASSWORD}" \
python manage.py createsuperuser --email "${EMAIL}" --noinput
```
The `--noinput` flag reads the password from `DJANGO_SUPERUSER_PASSWORD`.
**Affected apps so far**: Eventyay (`scripts/create-superuser.sh`), Synapse (`scripts/create-user.sh`), Headscale (`scripts/register-node.sh`).
## App Status Tracking
| App | Status | Notes |
|-----|--------|-------|
| Firefly III | ✅ working | |
| Wiki.js | ✅ working | |
| Outline | ✅ working | Redis URL needs password via K8s env var expansion |
| WriteFreely | ✅ working | Needs runAsUser: 0 (community image quirk) |
| NocoDB | ✅ working | Node.js needs 2Gi memory |
| Mattermost | ✅ working | |
| Etherpad | ✅ working | |
| CryptPad | ✅ working | Needs emptyDir + initContainer for config seeding |
| MediaWiki | ✅ working | Post-deploy web installer required for LocalSettings.php |
| Nextcloud | ✅ working | |
| PeerTube | ✅ working | |
| BookStack | ✅ working | linuxserver.io needs root + no capabilities.drop |
| Typebot | ✅ working | Builder: /signin health path; Viewer: tcpSocket probe |
| Pixelfed | ✅ working | Use ghcr.io/mattlqx/docker-pixelfed:v0.12.7-nginx (community), port 80 not 8080 |
| Wekan | pending | needs MongoDB |
| Mautic | ✅ working | |
| Taiga | ✅ working | |
| Pol.is | ❌ blocked | Private AWS ECR images, no public alternative |
| Zulip | ✅ working | Bundles own postgres/rabbitmq/memcached; first-run migrations take ~10min |
| Rocket.Chat | pending | needs MongoDB |
| LimeSurvey | ✅ working | |
| Gancio | ✅ working | Re-deploy: must drop DB schema first if prior data exists (see note 18) |
| Mobilizon | ✅ working | |
| Formbricks | ✅ working | |
| Jitsi | ✅ working | |
| Mastodon | already done | |
| Chamilo | ✅ working | |
| Moodle | ✅ working | |
| Open edX | pending | complex |
| Pretix | ✅ working | |
| Indico | ✅ working | |
| Eventyay | ✅ working | No versioned Docker tags upstream — main tag is intentional |
| Leihs | pending | |
| LibreBooking | ✅ working | |
| uMap | ✅ working | Host header needed in health probes (Django ALLOWED_HOSTS) |
| MapComplete | pending | |
| Terrastories | pending | |
| Karrot | ✅ working | nginx frontend: override template via ConfigMap to remove Docker DNS resolver (see note 24) |
| LiquidFeedback | pending | |
| Your Priorities | ❌ blocked | No public Docker images (ghcr.io requires auth) |
| Helios Voting | pending | |
| Belenios | pending | |
| Decidim | already done | |
| Alaveteli | pending | |
| FixMyStreet | pending | |
| Ghost | ✅ working | Bitnami image gone; use official ghost:alpine; different env vars + mount path (see note 12) |
| Ushahidi | ✅ working | Client: runAsUser:0 + CHOWN/SETUID/SETGID; API: liveness initialDelay 120s (Laravel slow start) |
| CiviCRM | pending | |
| Open Collective | pending | |
| Open Food Network | pending | |
| Resonate | pending | |
| BookWyrm | ✅ working | |
| Lemmy | already done | |
| Akaunting | ✅ working | |
| GnuCash | skip | desktop app, not containerizable |
| OhMyForm | ✅ working | |
| Odoo | ✅ working | Needs `-d DATABASE -i base` args; uses startupProbe (20min window) for first-run DB init |
| Headscale | ✅ working | SQLite, ConfigMap-based config, no secrets; v0.29 CLI uses numeric user IDs (see note 20) |
| Baserow | ✅ working | |
| Keila | already done | |
| Listmonk | already done | |
| Discourse | already done | |
| Gitea | already done | |
| Immich | already done | |
| Loomio | already done | |
| Synapse | already done | App renamed from matrix to synapse |
| Open WebUI | already done | |
| OpenProject | already done | |
| vLLM | already done | |

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
- @README.md
- ADDING-APPS.md is the expert advice you need when adding new apps to the Wild Cloud Directory.
- docs/ contains deep-dive guides — runtime-specific (nodejs, python, php, ruby, jvm, nginx, linuxserver) and topic-specific (database, redis, traefik, scripts).
## Finding good sources of documentation for adding a new app to the Wild Cloud Directory

View File

@@ -69,6 +69,15 @@ spec:
volumeMounts:
- name: baserow-data
mountPath: /baserow/data
securityContext:
runAsNonRoot: false
runAsUser: 0
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
readOnlyRootFilesystem: false
seccompProfile:
type: RuntimeDefault
livenessProbe:
httpGet:
path: /api/_health/

View File

@@ -1,4 +1,4 @@
version: 2.2.2-1
version: 2.2.2-2
requires:
- name: postgres
- name: redis

38
bookwyrm/notes.md Normal file
View File

@@ -0,0 +1,38 @@
# BookWyrm — Notes
## SCSS themes must be compiled before collectstatic
BookWyrm ships SCSS source files instead of pre-compiled CSS. The static root is an `emptyDir`
(ephemeral), so it is empty on every pod restart. Running `collectstatic` alone fails:
```
ValueError: Missing staticfiles manifest entry for 'css/themes/...'
```
**Fix**: compile themes before collecting static files:
```yaml
command: ["/bin/sh", "-c"]
args:
- |
python manage.py compile_themes \
&& python manage.py collectstatic --noinput \
&& python manage.py migrate \
&& exec gunicorn bookwyrm.wsgi:application ...
```
Check the image's `Dockerfile` or `docker_start.sh` to confirm the current required build steps.
## PostgreSQL extensions require superuser
BookWyrm uses extensions (`bloom`, `pg_trgm`, etc.) that require superuser to install. Migrations
fail with `permission denied to create extension "bloom"` if the app DB user is not a superuser.
**Fix**: create the extensions in the `db-init-job`, which connects as the `postgres` superuser:
```bash
psql -d "$APP_DB_NAME" -c "CREATE EXTENSION IF NOT EXISTS bloom;"
psql -d "$APP_DB_NAME" -c "CREATE EXTENSION IF NOT EXISTS pg_trgm;"
```
This must run before migrations (i.e. in `db-init-job`, not in the app startup command).

View File

@@ -80,4 +80,7 @@ spec:
GRANT USAGE ON SCHEMA public TO \"$BOOKWYRM_DB_USER\";
"
# Create bloom extension (required by bookwyrm migrations, needs superuser)
psql -d "$BOOKWYRM_DB_NAME" -c "CREATE EXTENSION IF NOT EXISTS bloom;"
echo "Database initialization completed."

View File

@@ -23,7 +23,7 @@ spec:
containers:
- name: bookwyrm
image: ghcr.io/bookwyrm-social/bookwyrm:v0.8.6
command: ["gunicorn", "bookwyrm.wsgi:application"]
command: ["/bin/sh", "-c", "python manage.py compile_themes && python manage.py collectstatic --noinput && python manage.py migrate && exec gunicorn bookwyrm.wsgi:application"]
ports:
- name: http
containerPort: 8000

View File

@@ -2,4 +2,5 @@ name: cert-manager
is: cert-manager
description: X.509 certificate management for Kubernetes
category: services
icon: https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/cert-manager.svg
latest: "v1"

View File

@@ -3,3 +3,5 @@ is: community-search
description: Community Search is a federated, self-hosted search engine built on community-curated indexes rather than global web crawling.
category: community
latest: "0"
ignoreRules:
- WC-ICON # no established icon available

View File

@@ -2,4 +2,5 @@ name: coredns
is: coredns
description: DNS server for internal cluster DNS resolution
category: services
icon: https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/coredns.svg
latest: "v1"

View File

@@ -2,4 +2,5 @@ name: crowdsec
is: crowdsec
description: CrowdSec security engine with Traefik bouncer for threat detection and rate limiting
category: services
icon: https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/crowdsec.svg
latest: "v1"

25
cryptpad/notes.md Normal file
View File

@@ -0,0 +1,25 @@
# CryptPad — Notes
## Config directory requires emptyDir + initContainer
CryptPad's `/cryptpad/config/` directory is in the image overlay filesystem and is not writable
by the container process even when running as root.
**Fix**: mount an `emptyDir` at `/cryptpad/config` and use an initContainer to pre-seed
`config.example.js` from the image into the emptyDir:
```yaml
initContainers:
- name: seed-config
image: cryptpad/cryptpad:version-X.Y.Z
command: [sh, -c, "cp /cryptpad/config/config.example.js /config-dest/config.example.js"]
volumeMounts:
- name: cryptpad-config
mountPath: /config-dest
volumes:
- name: cryptpad-config
emptyDir: {}
```
Set `CPAD_CONF=/cryptpad/config/config.js` — the startup script copies `config.example.js` to
`config.js` on first run if `config.js` doesn't exist.

View File

@@ -22,7 +22,7 @@ spec:
type: RuntimeDefault
initContainers:
- name: seed-config
image: cryptpad/cryptpad:latest
image: cryptpad/cryptpad:version-2026.5.1
command:
- sh
- -c
@@ -33,9 +33,18 @@ spec:
volumeMounts:
- name: cryptpad-config
mountPath: /config-dest
securityContext:
runAsNonRoot: false
runAsUser: 0
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
readOnlyRootFilesystem: false
seccompProfile:
type: RuntimeDefault
containers:
- name: cryptpad
image: cryptpad/cryptpad:latest
image: cryptpad/cryptpad:version-2026.5.1
ports:
- name: http
containerPort: 3000
@@ -80,7 +89,14 @@ spec:
periodSeconds: 10
failureThreshold: 3
securityContext:
runAsNonRoot: false
runAsUser: 0
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
readOnlyRootFilesystem: false
seccompProfile:
type: RuntimeDefault
volumes:
- name: cryptpad-data
persistentVolumeClaim:

View File

@@ -1,4 +1,4 @@
version: 2024.x-1
version: 2026.5.1-2
defaultConfig:
namespace: cryptpad
externalDnsDomain: '{{ .cloud.domain }}'

View File

@@ -1,4 +1,4 @@
version: 0.31.0-2
version: 0.31.0-3
requires:
- name: postgres
- name: redis
@@ -32,7 +32,7 @@ defaultSecrets:
default: "{{ random.AlphaNum 128 }}"
- key: dbPassword
- key: dbUrl
default: "postgres://{{ .app.db.user }}:{{ .secrets.dbPassword }}@{{ .app.db.host }}:{{ .app.db.port }}/{{ .app.db.name }}"
default: "postgres://{{ .app.db.user }}:{{ .secrets.dbPassword }}@{{ .app.db.host }}:{{ .app.db.port }}/{{ .app.db.name }}?sslmode=disable"
requiredSecrets:
- postgres.password
- redis.password

View File

@@ -9,6 +9,7 @@ metadata:
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
external-dns.alpha.kubernetes.io/target: "{{ .externalDnsDomain }}"
spec:
ingressClassName: traefik
rules:
- host: "{{ .domain }}"
http:

View File

@@ -1,4 +1,4 @@
version: 3.5.3-3
version: 3.5.3-4
requires:
- name: postgres
- name: redis

View File

@@ -2,4 +2,8 @@ name: docker-registry
is: docker-registry
description: Private Docker image registry for cluster
category: services
icon: https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/docker.svg
latest: "3"
ignoreRules:
- WC-DNS # internal registry; external DNS not needed

View File

@@ -11,10 +11,7 @@ spec:
matchLabels:
app: docker-registry
strategy:
rollingUpdate:
maxSurge: 0
maxUnavailable: 1
type: RollingUpdate
type: Recreate
template:
metadata:
labels:

View File

@@ -3,6 +3,7 @@ kind: Ingress
metadata:
name: docker-registry
spec:
ingressClassName: traefik
rules:
- host: {{ .host }}
http:

View File

@@ -5,7 +5,8 @@ labels:
- includeSelectors: true
pairs:
app: docker-registry
managedBy: wild-cloud
managedBy: kustomize
partOf: wild-cloud
resources:
- deployment.yaml
- ingress.yaml

View File

@@ -1,4 +1,4 @@
version: "3.0.0"
version: "3.0.0-1"
requires:
- name: traefik
- name: cert-manager

78
docs/database.md Normal file
View 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
View 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 60120 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` 2533% below the container limit to leave room for off-heap memory (metaspace, direct buffers, GC overhead).
## Resources
512Mi1Gi request is typical. JVM startup can spike higher — use `requests` to reserve and `limits` to cap.

44
docs/linuxserver.md Normal file
View 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
View 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
View 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 2533% 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
View File

@@ -0,0 +1,31 @@
# PHP (Laravel / Symfony)
## Startup delay
Laravel's startup sequence (autoload optimization, key generation, migrations, package discovery) commonly takes 23 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
256512Mi request is typical for a PHP-FPM or Artisan-served app. Workers (queue:work, horizon) are separate processes — give them their own Deployment and 256512Mi each.

65
docs/python.md Normal file
View 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 256512Mi 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
View 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
View File

@@ -0,0 +1,32 @@
# Ruby (Rails)
## Startup delay
Rails startup (bundle exec, asset precompilation, initializer chain) typically takes 3090 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 256512Mi 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
View 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
View 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.

View File

@@ -3,6 +3,8 @@ is: e2e-test-app
description: End-to-end test application for automated integration testing. Includes PVC and PostgreSQL dependency to exercise all backup strategies.
category: services
latest: "2"
ignoreRules:
- WC-ICON # test/QA app; not user-facing
upgrade:
from:
- version: ">=1.0.0"

View File

@@ -4,3 +4,5 @@ description: Eventyay is an open-source event management platform covering ticke
category: community
icon: https://raw.githubusercontent.com/fossasia/eventyay/main/app/eventyay/static/common/img/logo.svg
latest: "1"
ignoreRules:
- WC-IMG # no versioned Docker tags upstream; main tag is intentional

View File

@@ -3,3 +3,6 @@ is: example
description: An example application that is deployed with internal-only access.
category: services
latest: "1"
ignoreRules:
- WC-ICON # example/demo app
- WC-IMG # example/demo app intentionally uses latest for simplicity

View File

@@ -15,6 +15,9 @@ spec:
labels:
app: example-admin
spec:
securityContext:
seccompProfile:
type: RuntimeDefault
containers:
- name: example-admin
image: nginx:latest
@@ -28,6 +31,15 @@ spec:
requests:
cpu: 50m
memory: 10Mi
securityContext:
runAsNonRoot: false
runAsUser: 0
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
readOnlyRootFilesystem: false
seccompProfile:
type: RuntimeDefault
livenessProbe:
httpGet:
path: /

View File

@@ -3,7 +3,10 @@ apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: example-admin
annotations:
external-dns.alpha.kubernetes.io/target: '{{ .externalDnsDomain }}'
spec:
ingressClassName: traefik
rules:
- host: "{{ .host }}"
http:

View File

@@ -1,4 +1,4 @@
version: 1.0.0-1
version: 1.0.0-2
defaultConfig:
namespace: example-admin
externalDnsDomain: '{{ .cloud.domain }}'

View File

@@ -3,3 +3,5 @@ is: example
description: An example application that is deployed with public access.
category: services
latest: "1"
ignoreRules:
- WC-ICON # example/demo app

View File

@@ -13,6 +13,9 @@ spec:
labels:
app: example-app
spec:
securityContext:
seccompProfile:
type: RuntimeDefault
containers:
- name: example-app
image: nginx:alpine
@@ -26,6 +29,15 @@ spec:
requests:
cpu: 50m
memory: 32Mi
securityContext:
runAsNonRoot: false
runAsUser: 0
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
readOnlyRootFilesystem: false
seccompProfile:
type: RuntimeDefault
livenessProbe:
httpGet:
path: /

View File

@@ -3,3 +3,7 @@ is: externaldns
description: Automatically configures DNS records for services
category: services
latest: "v0"
ignoreRules:
- WC-ICON # infrastructure service; not user-facing
- WC-SEL # infrastructure service; selectors are managed by the upstream manifest
- WC-NS # fixed namespace: externaldns

View File

@@ -16,6 +16,10 @@ spec:
app: external-dns
spec:
serviceAccountName: external-dns
securityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
containers:
- name: external-dns
image: registry.k8s.io/external-dns/external-dns:v0.13.4
@@ -30,6 +34,11 @@ spec:
- --publish-internal-services
- --no-cloudflare-proxied
- --log-level=debug
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
readOnlyRootFilesystem: true
env:
- name: CF_API_TOKEN
valueFrom:

View File

@@ -1,4 +1,4 @@
version: v0.13.4-1
version: v0.13.4-2
deploymentName: external-dns
requires:
- name: cert-manager

21
gancio/notes.md Normal file
View File

@@ -0,0 +1,21 @@
# Gancio — Notes
## "Non empty db" crash on re-deploy
Gancio stores its setup state in `config.json` on the data PVC. If the PVC is lost or the app is
deleted and redeployed against an existing database, Gancio finds a non-empty DB but no
`config.json` and refuses to start: `"Non empty db! Please move your current db elsewhere than retry."`
**Fix**: drop and recreate the public schema, then restart the deployment:
```bash
kubectl exec -n postgres <postgres-pod> -- psql -U postgres gancio \
-c "DROP SCHEMA public CASCADE; CREATE SCHEMA public; GRANT ALL ON SCHEMA public TO gancio; GRANT ALL ON SCHEMA public TO public;"
kubectl scale deployment -n gancio gancio --replicas=0
# wait for pod to terminate
kubectl scale deployment -n gancio gancio --replicas=1
```
Scale down before dropping the schema to release the database connection first.
This only affects re-deployments; fresh installs work fine.

View File

@@ -28,6 +28,6 @@ Key settings in `config.yaml`:
## Notes
- **Re-deploy warning**: If the app is deleted and redeployed with an existing database, Gancio will refuse to start with a "Non empty db" error. Fix by dropping and recreating the public schema in the `gancio` database before restarting. See ADDING-APPS-NOTES.md note 18 for the exact commands.
- **Re-deploy warning**: If the app is deleted and redeployed with an existing database, Gancio will refuse to start with a "Non empty db" error. See `gancio/notes.md` for the fix.
- Gancio stores its setup state in `config.json` on the data PVC — do not delete the PVC without also clearing the database
- Events are federated to Mastodon and other ActivityPub platforms

15
ghost/notes.md Normal file
View File

@@ -0,0 +1,15 @@
# Ghost — Notes
## Bitnami image migration
Ghost was originally packaged using `bitnami/ghost`. Bitnami moved their images off Docker Hub
in 2023, so the Bitnami image no longer pulls without authentication.
**Fix**: use the official `ghost:X.Y.Z-alpine` image. The official image has different conventions:
| | Bitnami | Official |
|---|---|---|
| DB host env | `GHOST_DATABASE_HOST` | `database__connection__host` |
| Content path | `/bitnami/ghost` | `/var/lib/ghost/content` |
Check `docker manifest inspect ghost:latest` for the current version tag before updating.

View File

@@ -19,6 +19,7 @@ spec:
mysql -h ${DB_HOSTNAME} -P ${DB_PORT} -u root -p${MYSQL_ROOT_PASSWORD} <<EOF
CREATE DATABASE IF NOT EXISTS ${DB_DATABASE_NAME};
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;
EOF

View File

@@ -19,7 +19,11 @@ spec:
partOf: wild-cloud
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
terminationGracePeriodSeconds: 60
containers:
- name: gitea
@@ -78,7 +82,10 @@ spec:
resources:
{}
securityContext:
{}
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
readOnlyRootFilesystem: false
volumeMounts:
- name: temp
mountPath: /tmp

View File

@@ -7,6 +7,7 @@ metadata:
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
external-dns.alpha.kubernetes.io/target: "{{ .externalDnsDomain }}"
spec:
ingressClassName: traefik
rules:
- host: "{{ .domain }}"
http:

View File

@@ -1,4 +1,4 @@
version: 1.24.3-2
version: 1.24.3-4
requires:
- name: postgres
- name: smtp

View File

@@ -2,4 +2,5 @@ name: headlamp
is: headlamp
description: Modern Kubernetes web UI (SIG UI) with in-cluster authentication
category: services
icon: https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/headlamp.svg
latest: "v0"

36
headscale/notes.md Normal file
View File

@@ -0,0 +1,36 @@
# Headscale — Notes
## v0.29+ CLI: inconsistent user identifier types
Different commands in headscale v0.29 accept different user identifiers:
| Command | Accepts |
|---|---|
| `preauthkeys create --user <id>` | numeric ID only |
| `auth register --user <username>` | username string |
| `users destroy --identifier <id>` | numeric ID only (not positional) |
Always list users first to get the ID:
```bash
headscale users list
headscale preauthkeys create --user 1 # numeric ID
headscale auth register --auth-id <id> --user payne # username OK
headscale users destroy --identifier 1 # numeric ID
```
## Node registration via browser
When a Tailscale client connects via browser (not pre-auth key), headscale shows a command like:
```
headscale auth register --auth-id hskey-authreq-XXXX --user USERNAME
```
Run it via:
```bash
kubectl exec -n headscale deploy/headscale -- \
headscale auth register --auth-id <id> --user <username>
```

View File

@@ -16,6 +16,11 @@ spec:
app: immich-machine-learning
component: machine-learning
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
seccompProfile:
type: RuntimeDefault
containers:
- image: "ghcr.io/immich-app/immich-machine-learning:v1.135.3"
name: immich-machine-learning
@@ -25,6 +30,11 @@ spec:
env:
- name: TZ
value: "UTC"
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
readOnlyRootFilesystem: false
volumeMounts:
- mountPath: /cache
name: immich-cache

View File

@@ -9,16 +9,18 @@ spec:
matchLabels:
app: immich-microservices
strategy:
rollingUpdate:
maxSurge: 0
maxUnavailable: 1
type: RollingUpdate
type: Recreate
template:
metadata:
labels:
app: immich-microservices
component: microservices
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
seccompProfile:
type: RuntimeDefault
containers:
- image: "ghcr.io/immich-app/immich-server:v1.135.3"
name: immich-microservices
@@ -43,6 +45,11 @@ spec:
value: "UTC"
- name: IMMICH_WORKERS_EXCLUDE
value: api
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
readOnlyRootFilesystem: false
volumeMounts:
- mountPath: /usr/src/app/upload
name: immich-storage

View File

@@ -9,16 +9,18 @@ spec:
matchLabels:
app: immich-server
strategy:
rollingUpdate:
maxSurge: 0
maxUnavailable: 1
type: RollingUpdate
type: Recreate
template:
metadata:
labels:
app: immich-server
component: server
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
seccompProfile:
type: RuntimeDefault
containers:
- image: "ghcr.io/immich-app/immich-server:v1.135.3"
name: immich-server
@@ -46,6 +48,11 @@ spec:
value: "UTC"
- name: IMMICH_WORKERS_EXCLUDE
value: microservices
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
readOnlyRootFilesystem: false
volumeMounts:
- mountPath: /usr/src/app/upload
name: immich-storage

View File

@@ -7,6 +7,7 @@ metadata:
external-dns.alpha.kubernetes.io/target: "{{ .externalDnsDomain }}"
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
spec:
ingressClassName: traefik
rules:
- host: "{{ .domain }}"
http:

View File

@@ -1,4 +1,4 @@
version: 1.135.3-2
version: 1.135.3-4
requires:
- name: redis
- name: postgres

View File

@@ -6,7 +6,6 @@ metadata:
spec:
accessModes:
- ReadWriteMany
storageClassName: nfs
resources:
requests:
storage: {{ .storage }}

View File

@@ -6,7 +6,6 @@ metadata:
spec:
accessModes:
- ReadWriteMany
storageClassName: nfs
resources:
requests:
storage: {{ .storage }}

View File

@@ -14,6 +14,9 @@ spec:
labels:
component: web
spec:
securityContext:
seccompProfile:
type: RuntimeDefault
containers:
- name: keila
image: "pentacent/keila:0.17.1"
@@ -69,6 +72,11 @@ spec:
volumeMounts:
- name: uploads
mountPath: /var/lib/keila/uploads
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
readOnlyRootFilesystem: false
livenessProbe:
httpGet:
path: /

View File

@@ -9,6 +9,7 @@ metadata:
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
traefik.ingress.kubernetes.io/router.middlewares: keila-cors@kubernetescrd
spec:
ingressClassName: traefik
rules:
- host: {{ .domain }}
http:

View File

@@ -1,4 +1,4 @@
version: 0.17.1-2
version: 0.17.1-4
requires:
- name: postgres
- name: smtp

View File

@@ -22,7 +22,7 @@ spec:
type: RuntimeDefault
initContainers:
- name: config-prep
image: busybox:stable
image: busybox:1.38.0
securityContext:
allowPrivilegeEscalation: false
capabilities:

View File

@@ -5,6 +5,8 @@ metadata:
namespace: {{ .namespace }}
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
component: pictrs

View File

@@ -2,4 +2,12 @@ name: longhorn
is: longhorn
description: Cloud-native distributed block storage for Kubernetes
category: services
icon: https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/longhorn.svg
latest: "v1"
ignoreRules:
- WC-HELM
- WC-SEL
- WC-NS # fixed namespace: longhorn-system
- WC-DNS # internal-only ingress (internalDomain), no external DNS
- WC-SC-POD # upstream manifest: longhorn.yaml has its own pod security policy
- WC-SC-CTR # upstream manifest: longhorn.yaml has its own container security policy

View File

@@ -4,6 +4,7 @@ metadata:
name: longhorn-ingress
namespace: longhorn-system
spec:
ingressClassName: traefik
rules:
- host: "longhorn.{{ .internalDomain }}"
http:

View File

@@ -1,4 +1,4 @@
version: v1.8.1-3
version: v1.8.1-4
deploymentName: longhorn-ui
requires:
- name: traefik

View File

@@ -4,3 +4,5 @@ description: Loomio is a collaborative decision-making tool that makes it easy f
category: community
icon: https://www.loomio.com/brand/logo_gold.svg
latest: "3"
ignoreRules:
- WC-IMG # loomio_channel_server has no versioned tags; stable is the best available pin

24
loomio/notes.md Normal file
View File

@@ -0,0 +1,24 @@
# Loomio — Notes
## Custom startup command must reproduce ENTRYPOINT setup steps
Loomio's Docker image uses `docker_start.sh` as its ENTRYPOINT, which copies
`client3-build/``public/client3/` before starting Rails. Overriding the entrypoint with a
direct Rails command skips this step, leaving `client3/` unpopulated and causing 500 errors on
all routes serving the SPA.
**Fix**: reproduce the copy step explicitly in the deployment `command:`:
```yaml
command: ["/bin/bash", "-c"]
args:
- |
set -e
mkdir -p /loomio/public/client3
cp -r /loomio/client3-build/* /loomio/public/client3/
bundle exec rake db:migrate db:seed
bundle exec thrust puma -C config/puma.rb
```
**General rule**: whenever you override `command:`, inspect the image's `ENTRYPOINT`/`CMD`
(via `docker inspect` or the Dockerfile) and reproduce any required setup steps.

View File

@@ -12,9 +12,14 @@ spec:
labels:
component: channels
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
seccompProfile:
type: RuntimeDefault
containers:
- name: channels
image: loomio/loomio_channel_server:latest
image: loomio/loomio_channel_server:stable
ports:
- containerPort: 3000
name: http

View File

@@ -12,9 +12,12 @@ spec:
labels:
component: worker
spec:
securityContext:
seccompProfile:
type: RuntimeDefault
containers:
- name: worker
image: loomio/loomio:latest
image: loomio/loomio:3.0.24
env:
- name: TASK
value: worker

View File

@@ -14,14 +14,19 @@ spec:
labels:
component: web
spec:
securityContext:
seccompProfile:
type: RuntimeDefault
containers:
- name: loomio
image: loomio/loomio:latest
image: loomio/loomio:3.0.24
command:
- /bin/bash
- -c
- |
set -e
mkdir -p /loomio/public/client3
cp -r /loomio/client3-build/* /loomio/public/client3/
bundle exec rake db:migrate db:seed
bundle exec thrust puma -C config/puma.rb
ports:

View File

@@ -1,4 +1,4 @@
version: 3.0.11-5
version: 3.0.24-2
requires:
- name: postgres
installed_as: postgres
@@ -34,7 +34,7 @@ defaultSecrets:
- key: dbPassword
default: "{{ random.AlphaNum 32 }}"
- key: dbUrl
default: "postgresql://{{ .app.db.user }}:{{ .secrets.dbPassword }}@{{ .app.db.host }}:{{ .app.db.port }}/{{ .app.db.name }}?pool=30"
default: "postgresql://{{ .app.db.user }}:{{ .secrets.dbPassword }}@{{ .app.db.host }}:{{ .app.db.port }}/{{ .app.db.name }}?pool=30&sslmode=disable"
- key: deviseSecret
default: "{{ random.AlphaNum 32 }}"
- key: secretCookieToken

View File

@@ -12,6 +12,11 @@ spec:
labels:
component: cache
spec:
securityContext:
runAsNonRoot: true
runAsUser: 11211
seccompProfile:
type: RuntimeDefault
containers:
- name: memcached
image: "memcached:1.6.32-alpine"

View File

@@ -2,4 +2,5 @@ name: metallb
is: metallb
description: Bare metal load-balancer for Kubernetes
category: services
icon: https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/metallb.svg
latest: "v0"

View File

@@ -4,3 +4,5 @@ description: Moodle is a free, open-source learning management system (LMS) used
category: education
icon: https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/moodle.svg
latest: "4"
ignoreRules:
- WC-BITNAMI # no official Moodle image exists; bitnamilegacy is the standard

27
moodle/notes.md Normal file
View File

@@ -0,0 +1,27 @@
# Moodle — Notes
## No official Docker image — Bitnami is the standard
The Moodle project does not publish an official Docker image. `bitnamilegacy/moodle` is the
widely-used standard and is the basis for the official Bitnami Helm chart. The `WC-BITNAMI`
warning is suppressed in `app.yaml` because there is no viable alternative.
Note: Bitnami images require Docker Hub authentication since 2023. Ensure the cluster has
Docker Hub credentials configured if pull rate limits or auth errors appear.
## Bitnami filesystem layout
Bitnami images store persistent data under `/bitnami`. The PVC is mounted at `/bitnami` to
cover both Moodle files and any Bitnami-managed config. Do not change the mount path without
also adjusting Bitnami's internal configuration.
## MySQL-only
Moodle's `MOODLE_DATABASE_TYPE=mysqli` requires MySQL/MariaDB. PostgreSQL is not supported.
The deployment uses Wild Cloud's MySQL dependency (`requires: [mysql]`).
## Long first-run initialization
Moodle runs database migrations and installs core plugins on first startup, which can take
515 minutes. The `startupProbe` is configured with a long timeout (`failureThreshold: 240`,
`periodSeconds: 15` = up to 60 minutes) to accommodate this.

View File

@@ -18,6 +18,7 @@ spec:
securityContext:
runAsNonRoot: true
runAsUser: 994
fsGroup: 994
seccompProfile:
type: RuntimeDefault
containers:

View File

@@ -3,3 +3,7 @@ is: nfs
description: NFS client provisioner for external NFS storage
category: services
latest: "v4"
ignoreRules:
- WC-ICON # config-only NFS settings; not a deployable app
- WC-CFG-NS # config-only app, no Kubernetes namespace

View File

@@ -3,3 +3,6 @@ is: node-feature-discovery
description: Detects hardware features available on each node
category: services
latest: "v0"
ignoreRules:
- WC-ICON # infrastructure/hardware plugin; not user-facing
- WC-SEL

View File

@@ -3,3 +3,7 @@ is: nvidia-device-plugin
description: NVIDIA device plugin for Kubernetes
category: services
latest: "v0"
ignoreRules:
- WC-ICON # hardware plugin; not user-facing
- WC-HELM
- WC-SEL

27
odoo/notes.md Normal file
View File

@@ -0,0 +1,27 @@
# Odoo — Notes
## Explicit database name and `-i base` required on first run
The official Odoo Docker image does not auto-initialize the database. Without specifying the
database name and installing the base module, Odoo starts in multi-database manager mode and
health checks fail with "Database not initialized".
```yaml
args: ["-d", "DATABASE_NAME", "-i", "base"]
```
The `-i base` flag installs Odoo's base module and creates all database tables on first run.
Subsequent runs with `-i base` are safe — it updates rather than reinstalls.
## First-run initialization takes 1015 minutes
Use a `startupProbe` with a long window to prevent premature restarts:
```yaml
startupProbe:
failureThreshold: 40
periodSeconds: 30 # up to 20 minutes total
```
Once the startup probe passes, regular liveness/readiness probes with `initialDelaySeconds: 0`
take over.

View File

@@ -38,7 +38,10 @@ spec:
- name: OPENAI_API_BASE_URL
value: "{{ .vllmApiUrl }}"
- name: OPENAI_API_KEY
value: "sk-placeholder" # Required but not used with vLLM
valueFrom:
secretKeyRef:
name: open-webui-secrets
key: vllmApiKey
- name: WEBUI_SECRET_KEY
valueFrom:
secretKeyRef:

View File

@@ -8,6 +8,7 @@ metadata:
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
traefik.ingress.kubernetes.io/router.middlewares: crowdsec-crowdsec-bouncer@kubernetescrd,crowdsec-rate-limit@kubernetescrd
spec:
ingressClassName: traefik
rules:
- host: {{ .domain }}
http:

View File

@@ -1,4 +1,4 @@
version: 0.9.5-2
version: 0.9.5-4
requires: []
defaultConfig:
namespace: open-webui
@@ -10,3 +10,4 @@ defaultConfig:
defaultSecrets:
- key: secretKey
- key: adminPassword
- key: vllmApiKey

View File

@@ -8,6 +8,7 @@ metadata:
external-dns.alpha.kubernetes.io/target: {{ .externalDnsDomain }}
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
spec:
ingressClassName: traefik
tls:
- hosts:
- "{{ .domain }}"

View File

@@ -1,4 +1,4 @@
version: 16.1.1-3
version: 16.1.1-4
requires:
- name: postgres
- name: memcached

21
pixelfed/notes.md Normal file
View File

@@ -0,0 +1,21 @@
# Pixelfed — Notes
## mattlqx/docker-pixelfed: APP_PORT required
The `ghcr.io/mattlqx/docker-pixelfed` community image generates its nginx config from env vars
using `sed`. The `listen` directive uses `${APP_PORT}`, which must be set explicitly — without it,
nginx crashes with `invalid number of arguments in 'listen' directive`.
```yaml
env:
- name: APP_PORT
value: "80"
```
The image listens on port 80 (nginx), not 8080 as the original pixelfed image did.
## Shared storage PVC requires ReadWriteMany
Both the web and worker deployments must mount the shared storage PVC simultaneously. Use
`ReadWriteMany` access mode — `ReadWriteOnce` with two pods causes multi-attach errors.
(`RollingUpdate` + RWO also triggers this; use `Recreate` strategy or RWX.)

View File

@@ -2,4 +2,7 @@ name: polis
is: polis
description: Pol.is is an open-source large-scale conversational survey tool that helps communities gather, understand, and act on the expressed opinions of large groups of people.
category: community
icon: https://avatars.githubusercontent.com/u/5333592?s=200&v=4
latest: dev
ignoreRules:
- WC-IMG # private AWS ECR images; no public versioned alternative available

9
polis/notes.md Normal file
View File

@@ -0,0 +1,9 @@
# Polis — Notes
## No public Docker images
`compdemocracy/polis-server` images are private — they are not publicly available on Docker Hub
or ghcr.io. Deploying without pre-built images results in `ImagePullBackOff`.
The current package uses private AWS ECR images. This is tracked in `app.yaml` via
`ignoreRules: [WC-IMG]`. Building images from source is required for a fully self-hostable package.

View File

@@ -15,7 +15,7 @@ defaultConfig:
defaultSecrets:
- key: dbPassword
- key: dbUrl
default: "postgresql://{{ .app.db.user }}:{{ .secrets.dbPassword }}@{{ .app.db.host }}:{{ .app.db.port }}/{{ .app.db.name }}"
default: "postgresql://{{ .app.db.user }}:{{ .secrets.dbPassword }}@{{ .app.db.host }}:{{ .app.db.port }}/{{ .app.db.name }}?sslmode=disable"
- key: loginCodePepper
- key: jwtPrivateKey
- key: jwtPublicKey

View File

@@ -15,6 +15,13 @@ spec:
labels:
app: postgres
spec:
securityContext:
runAsNonRoot: true
runAsUser: 999
runAsGroup: 999
fsGroup: 999
seccompProfile:
type: RuntimeDefault
containers:
- name: postgres
image: "pgvector/pgvector:pg15"
@@ -39,6 +46,10 @@ spec:
-c idle_in_transaction_session_timeout=600000
ports:
- containerPort: 5432
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
env:
- name: PGDATA
value: /var/lib/postgresql/data/pgdata

View File

@@ -13,6 +13,12 @@ spec:
labels:
app: redis
spec:
securityContext:
runAsNonRoot: true
runAsUser: 999
runAsGroup: 999
seccompProfile:
type: RuntimeDefault
containers:
- image: "redis:alpine"
name: redis
@@ -24,6 +30,10 @@ spec:
secretKeyRef:
name: redis-secrets
key: password
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
command:
- redis-server
- --requirepass

View File

@@ -3,3 +3,7 @@ is: smtp
description: SMTP relay service for cluster applications
category: services
latest: "1"
ignoreRules:
- WC-ICON # config-only SMTP settings; not a deployable app
- WC-CFG-NS # config-only app, no Kubernetes namespace

View File

@@ -3,3 +3,11 @@ is: snapshot-controller
description: Kubernetes CSI Snapshot Controller for managing VolumeSnapshots
category: services
latest: "v8"
ignoreRules:
- WC-ICON # K8s internal addon; not user-facing
- WC-SEL # upstream manifest
- WC-HELM # upstream manifest uses Helm-style labels
- WC-NS # fixed namespace: kube-system
- WC-NSFILE # deploys into kube-system (pre-existing)
- WC-SC-POD # upstream manifest
- WC-SC-CTR # upstream manifest

9
supabase/app.yaml Normal file
View File

@@ -0,0 +1,9 @@
name: supabase
is: supabase
description: Supabase is an open-source Firebase alternative providing a Postgres database, authentication, instant REST and realtime APIs, edge functions, and a web dashboard.
category: developer
icon: https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/supabase.svg
latest: "1"
ignoreRules:
- WC-SC-POD # Most Supabase images run as root; only postgres enforces non-root
- WC-SC-CTR # See above

126
supabase/notes.md Normal file
View File

@@ -0,0 +1,126 @@
# Supabase Notes
## Overview
Supabase is a multi-component application. All components run in the `supabase` namespace:
| Component | Image | Role |
|-----------|-------|------|
| `db` | supabase/postgres | PostgreSQL with Supabase extensions |
| `kong` | kong/kong | API gateway, entry point for all traffic |
| `auth` | supabase/gotrue | JWT-based authentication |
| `rest` | postgrest/postgrest | Auto-generated REST API from Postgres |
| `realtime` | supabase/realtime | WebSocket subscriptions |
| `storage` + `imgproxy` | supabase/storage-api + darthsim/imgproxy | File storage (imgproxy as sidecar) |
| `meta` | supabase/postgres-meta | DB introspection for Studio |
| `studio` | supabase/studio | Web dashboard (protected by Kong basic-auth) |
Traffic flows: browser → Traefik → Kong (port 8000) → individual services.
## First-Deploy Checklist
1. Add and deploy with: `wild app add supabase && wild app deploy supabase`
2. Wait for postgres to initialize (~23 min on first boot — it runs SQL init scripts)
3. Access the Studio at `https://supabase.<your-domain>` using the dashboard credentials
4. **Replace example API keys before going to production** (see below)
## JWT Keys — MUST Replace for Production
The default `jwtSecret`, `anonKey`, and `serviceRoleKey` in `secrets.yaml` are Supabase's well-known **example keys** (publicly known, expire Jan 2027). They are sufficient for testing but **must not be used in production**.
Generate new keys using the built-in script:
```bash
wild app run supabase generate-keys
```
Then copy the output values into `secrets.yaml` and redeploy:
```bash
wild app deploy supabase
```
The postgres database stores the JWT secret in its settings (`app.settings.jwt_secret`). After replacing keys, the postgres pod must restart to pick up the new secret — a redeploy handles this.
## Postgres — Internal Database
Supabase uses its own dedicated postgres instance (`supabase/postgres` image) rather than the shared Wild Cloud postgres app. This image includes:
- ~390 extensions pre-compiled (pgjwt, wal2json, vector, pg_net, etc.)
- Supabase service roles (supabase_admin, authenticator, supabase_auth_admin, supabase_storage_admin, etc.)
- Initialization scripts for realtime, webhooks, JWT settings
All Supabase service roles (auth, storage, realtime, etc.) use the same password as the postgres admin (`dbPassword` secret). This is the Supabase default behavior.
### Bootstrap User: supabase_admin
`POSTGRES_USER` is set to `supabase_admin` (not `postgres`). This is required because the image's `migrate.sh` script connects immediately as `supabase_admin` before running any init scripts. Using `POSTGRES_USER=postgres` would make postgres the bootstrap superuser, which prevents `migrate.sh` from demoting it (PostgreSQL prohibits removing SUPERUSER from the bootstrap user).
With `POSTGRES_USER=supabase_admin`:
1. `initdb` creates `supabase_admin` as the bootstrap superuser
2. `migrate.sh` connects as `supabase_admin` and creates the `postgres` role
3. After init-scripts run, `migrate.sh` demotes `postgres` to non-superuser (this is Supabase's intended model)
The `postgres` user remains available with database-level privileges. All Supabase service roles use specific functional accounts (supabase_auth_admin, authenticator, etc.), not the postgres superuser.
### Volume Mount: /var/lib/postgresql (parent dir)
The PVC is mounted at `/var/lib/postgresql` (the parent), NOT at `/var/lib/postgresql/data` (PGDATA). This is required because Longhorn's ext4 filesystem creates a `lost+found` directory, which blocks postgres `initdb` when the PVC is mounted directly at PGDATA. With the parent-dir mount, `initdb` creates `/var/lib/postgresql/data` as a subdirectory, leaving `lost+found` at the parent level where it doesn't interfere.
## Realtime Tenant ID
The Realtime service must identify itself with the tenant ID `realtime-dev`. The Deployment sets `spec.template.spec.hostname: realtime-dev` so the Elixir application seeds itself with this tenant ID. The Kubernetes service for Realtime is also named `realtime-dev` so Kong can route to it correctly.
## Storage
File storage uses the local filesystem backend by default. Objects are stored in the `supabase-storage` PVC. The `imgproxy` container is a sidecar in the same pod, sharing the storage PVC for image transformation.
For S3-backed storage: edit `deployment-storage.yaml` to change `STORAGE_BACKEND` to `s3` and add S3 credentials as secrets.
## SMTP
SMTP is required for auth email flows (email confirmation, password reset). Configure the `smtp` app dependency and it will be wired automatically. If you want to disable email confirmation during initial testing, you can temporarily set `GOTRUE_MAILER_AUTOCONFIRM=true` in `deployment-auth.yaml`.
## Studio Dashboard Credentials
The Studio dashboard is protected by HTTP Basic Auth via Kong. Credentials:
- Username: configured via `dashboardUsername` config key (default: `supabase`)
- Password: auto-generated `dashboardPassword` secret
To view the generated password:
```bash
kubectl -n supabase get secret supabase-secrets -o jsonpath='{.data.dashboardPassword}' | base64 -d
```
## Connecting Applications
Applications connect to Supabase via the Kong API gateway:
- **REST API**: `https://supabase.<domain>/rest/v1/`
- **Auth**: `https://supabase.<domain>/auth/v1/`
- **Realtime**: `wss://supabase.<domain>/realtime/v1/`
- **Storage**: `https://supabase.<domain>/storage/v1/`
Use the `anonKey` for client-side (browser) access and `serviceRoleKey` for server-side (admin) access.
## Supabase JavaScript Client
```js
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(
'https://supabase.<your-domain>',
'<anonKey>' // from secrets.yaml
)
```
## Troubleshooting
**Postgres won't start**: Check that the supabase/postgres image can be pulled and that the PVC is bound.
**Auth returns 500**: Postgres is not ready or the init scripts haven't completed. Check DB pod logs and wait for all init SQL to finish.
**Kong returns 401 for all requests**: The `anonKey` in secrets doesn't match the `jwtSecret`. Regenerate keys with `wild app run supabase generate-keys`.
**Realtime health check fails**: The pod hostname may not be `realtime-dev`. Check the Deployment `spec.template.spec.hostname` field.
**Studio shows "Project not found"**: Studio needs Kong to be healthy. Check Kong pod logs for declarative config errors.

View File

@@ -0,0 +1,159 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: supabase-db-init
data:
jwt.sql: |
\set jwt_secret `echo "$JWT_SECRET"`
\set jwt_exp `echo "$JWT_EXP"`
ALTER DATABASE postgres SET "app.settings.jwt_secret" TO :'jwt_secret';
ALTER DATABASE postgres SET "app.settings.jwt_exp" TO :'jwt_exp';
roles.sql: |
\set pgpass `echo "$POSTGRES_PASSWORD"`
ALTER USER authenticator WITH PASSWORD :'pgpass';
ALTER USER pgbouncer WITH PASSWORD :'pgpass';
ALTER USER supabase_auth_admin WITH PASSWORD :'pgpass';
ALTER USER supabase_functions_admin WITH PASSWORD :'pgpass';
ALTER USER supabase_storage_admin WITH PASSWORD :'pgpass';
realtime.sql: |
\set pguser `echo "$POSTGRES_USER"`
create schema if not exists _realtime;
alter schema _realtime owner to :pguser;
webhooks.sql: |
BEGIN;
CREATE EXTENSION IF NOT EXISTS pg_net SCHEMA extensions;
CREATE SCHEMA supabase_functions AUTHORIZATION supabase_admin;
GRANT USAGE ON SCHEMA supabase_functions TO postgres, anon, authenticated, service_role;
ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON TABLES TO postgres, anon, authenticated, service_role;
ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON FUNCTIONS TO postgres, anon, authenticated, service_role;
ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON SEQUENCES TO postgres, anon, authenticated, service_role;
CREATE TABLE supabase_functions.migrations (
version text PRIMARY KEY,
inserted_at timestamptz NOT NULL DEFAULT NOW()
);
INSERT INTO supabase_functions.migrations (version) VALUES ('initial');
CREATE TABLE supabase_functions.hooks (
id bigserial PRIMARY KEY,
hook_table_id integer NOT NULL,
hook_name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT NOW(),
request_id bigint
);
CREATE INDEX supabase_functions_hooks_request_id_idx ON supabase_functions.hooks USING btree (request_id);
CREATE INDEX supabase_functions_hooks_h_table_id_h_name_idx ON supabase_functions.hooks USING btree (hook_table_id, hook_name);
COMMENT ON TABLE supabase_functions.hooks IS 'Supabase Functions Hooks: Audit trail for triggered hooks.';
CREATE FUNCTION supabase_functions.http_request()
RETURNS trigger
LANGUAGE plpgsql
AS $function$
DECLARE
request_id bigint;
payload jsonb;
url text := TG_ARGV[0]::text;
method text := TG_ARGV[1]::text;
headers jsonb DEFAULT '{}'::jsonb;
params jsonb DEFAULT '{}'::jsonb;
timeout_ms integer DEFAULT 1000;
BEGIN
IF url IS NULL OR url = 'null' THEN
RAISE EXCEPTION 'url argument is missing';
END IF;
IF method IS NULL OR method = 'null' THEN
RAISE EXCEPTION 'method argument is missing';
END IF;
IF TG_ARGV[2] IS NULL OR TG_ARGV[2] = 'null' THEN
headers = '{"Content-Type": "application/json"}'::jsonb;
ELSE
headers = TG_ARGV[2]::jsonb;
END IF;
IF TG_ARGV[3] IS NULL OR TG_ARGV[3] = 'null' THEN
params = '{}'::jsonb;
ELSE
params = TG_ARGV[3]::jsonb;
END IF;
IF TG_ARGV[4] IS NULL OR TG_ARGV[4] = 'null' THEN
timeout_ms = 1000;
ELSE
timeout_ms = TG_ARGV[4]::integer;
END IF;
CASE
WHEN method = 'GET' THEN
SELECT http_get INTO request_id FROM net.http_get(url, params, headers, timeout_ms);
WHEN method = 'POST' THEN
payload = jsonb_build_object('old_record', OLD, 'record', NEW, 'type', TG_OP, 'table', TG_TABLE_NAME, 'schema', TG_TABLE_SCHEMA);
SELECT http_post INTO request_id FROM net.http_post(url, payload, params, headers, timeout_ms);
ELSE
RAISE EXCEPTION 'method argument % is invalid', method;
END CASE;
INSERT INTO supabase_functions.hooks (hook_table_id, hook_name, request_id) VALUES (TG_RELID, TG_NAME, request_id);
RETURN NEW;
END
$function$;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'supabase_functions_admin') THEN
CREATE USER supabase_functions_admin NOINHERIT CREATEROLE LOGIN NOREPLICATION;
END IF;
END $$;
GRANT ALL PRIVILEGES ON SCHEMA supabase_functions TO supabase_functions_admin;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA supabase_functions TO supabase_functions_admin;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA supabase_functions TO supabase_functions_admin;
ALTER USER supabase_functions_admin SET search_path = "supabase_functions";
ALTER table "supabase_functions".migrations OWNER TO supabase_functions_admin;
ALTER table "supabase_functions".hooks OWNER TO supabase_functions_admin;
ALTER function "supabase_functions".http_request() OWNER TO supabase_functions_admin;
GRANT supabase_functions_admin TO postgres;
DO $$ BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'supabase_pg_net_admin') THEN
REASSIGN OWNED BY supabase_pg_net_admin TO supabase_admin;
DROP OWNED BY supabase_pg_net_admin;
DROP ROLE supabase_pg_net_admin;
END IF;
END $$;
DO $$ BEGIN
IF EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_net') THEN
GRANT USAGE ON SCHEMA net TO supabase_functions_admin, postgres, anon, authenticated, service_role;
ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER;
ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER;
ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net;
ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net;
REVOKE ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC;
REVOKE ALL ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role;
GRANT EXECUTE ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role;
END IF;
END $$;
CREATE OR REPLACE FUNCTION extensions.grant_pg_net_access()
RETURNS event_trigger LANGUAGE plpgsql AS $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_event_trigger_ddl_commands() AS ev JOIN pg_extension AS ext ON ev.objid = ext.oid WHERE ext.extname = 'pg_net') THEN
GRANT USAGE ON SCHEMA net TO supabase_functions_admin, postgres, anon, authenticated, service_role;
ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER;
ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER;
ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net;
ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net;
REVOKE ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC;
REVOKE ALL ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role;
GRANT EXECUTE ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role;
END IF;
END;
$$;
COMMENT ON FUNCTION extensions.grant_pg_net_access IS 'Grants access to pg_net';
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_event_trigger WHERE evtname = 'issue_pg_net_access') THEN
CREATE EVENT TRIGGER issue_pg_net_access ON ddl_command_end WHEN TAG IN ('CREATE EXTENSION')
EXECUTE PROCEDURE extensions.grant_pg_net_access();
END IF;
END $$;
INSERT INTO supabase_functions.migrations (version) VALUES ('20210809183423_update_grants');
ALTER function supabase_functions.http_request() SECURITY DEFINER;
ALTER function supabase_functions.http_request() SET search_path = supabase_functions;
REVOKE ALL ON FUNCTION supabase_functions.http_request() FROM PUBLIC;
GRANT EXECUTE ON FUNCTION supabase_functions.http_request() TO postgres, anon, authenticated, service_role;
COMMIT;

Some files were not shown because too many files have changed in this diff Show More