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:
@@ -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 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
|
||||
- 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 10–15 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 | |
|
||||
1167
ADDING-APPS.md
1167
ADDING-APPS.md
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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
38
bookwyrm/notes.md
Normal 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).
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
25
cryptpad/notes.md
Normal 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.
|
||||
@@ -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:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
version: 2024.x-1
|
||||
version: 2026.5.1-2
|
||||
defaultConfig:
|
||||
namespace: cryptpad
|
||||
externalDnsDomain: '{{ .cloud.domain }}'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
version: 3.5.3-3
|
||||
version: 3.5.3-4
|
||||
requires:
|
||||
- name: postgres
|
||||
- name: redis
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,10 +11,7 @@ spec:
|
||||
matchLabels:
|
||||
app: docker-registry
|
||||
strategy:
|
||||
rollingUpdate:
|
||||
maxSurge: 0
|
||||
maxUnavailable: 1
|
||||
type: RollingUpdate
|
||||
type: Recreate
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
|
||||
@@ -3,6 +3,7 @@ kind: Ingress
|
||||
metadata:
|
||||
name: docker-registry
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
- host: {{ .host }}
|
||||
http:
|
||||
|
||||
@@ -5,7 +5,8 @@ labels:
|
||||
- includeSelectors: true
|
||||
pairs:
|
||||
app: docker-registry
|
||||
managedBy: wild-cloud
|
||||
managedBy: kustomize
|
||||
partOf: wild-cloud
|
||||
resources:
|
||||
- deployment.yaml
|
||||
- ingress.yaml
|
||||
|
||||
@@ -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
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.
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: /
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
version: 1.0.0-1
|
||||
version: 1.0.0-2
|
||||
defaultConfig:
|
||||
namespace: example-admin
|
||||
externalDnsDomain: '{{ .cloud.domain }}'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: /
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
21
gancio/notes.md
Normal 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.
|
||||
@@ -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
15
ghost/notes.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
version: 1.24.3-2
|
||||
version: 1.24.3-4
|
||||
requires:
|
||||
- name: postgres
|
||||
- name: smtp
|
||||
|
||||
@@ -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
36
headscale/notes.md
Normal 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>
|
||||
```
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
version: 1.135.3-2
|
||||
version: 1.135.3-4
|
||||
requires:
|
||||
- name: redis
|
||||
- name: postgres
|
||||
|
||||
@@ -6,7 +6,6 @@ metadata:
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteMany
|
||||
storageClassName: nfs
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .storage }}
|
||||
|
||||
@@ -6,7 +6,6 @@ metadata:
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteMany
|
||||
storageClassName: nfs
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .storage }}
|
||||
|
||||
@@ -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: /
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
version: 0.17.1-2
|
||||
version: 0.17.1-4
|
||||
requires:
|
||||
- name: postgres
|
||||
- name: smtp
|
||||
|
||||
@@ -22,7 +22,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
initContainers:
|
||||
- name: config-prep
|
||||
image: busybox:stable
|
||||
image: busybox:1.38.0
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
|
||||
@@ -5,6 +5,8 @@ metadata:
|
||||
namespace: {{ .namespace }}
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
component: pictrs
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,6 +4,7 @@ metadata:
|
||||
name: longhorn-ingress
|
||||
namespace: longhorn-system
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
- host: "longhorn.{{ .internalDomain }}"
|
||||
http:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
version: v1.8.1-3
|
||||
version: v1.8.1-4
|
||||
deploymentName: longhorn-ui
|
||||
requires:
|
||||
- name: traefik
|
||||
|
||||
@@ -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
24
loomio/notes.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
27
moodle/notes.md
Normal 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
|
||||
5–15 minutes. The `startupProbe` is configured with a long timeout (`failureThreshold: 240`,
|
||||
`periodSeconds: 15` = up to 60 minutes) to accommodate this.
|
||||
@@ -18,6 +18,7 @@ spec:
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 994
|
||||
fsGroup: 994
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
27
odoo/notes.md
Normal 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 10–15 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.
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}"
|
||||
|
||||
@@ -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
21
pixelfed/notes.md
Normal 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.)
|
||||
@@ -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
9
polis/notes.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
9
supabase/app.yaml
Normal 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
126
supabase/notes.md
Normal 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 (~2–3 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.
|
||||
159
supabase/versions/1/configmap-db-init.yaml
Normal file
159
supabase/versions/1/configmap-db-init.yaml
Normal 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
Reference in New Issue
Block a user