Compare commits

...

8 Commits

Author SHA1 Message Date
Paul Payne
e2aa16e679 Add external-dns annotations to openproject. 2026-01-08 01:17:20 +00:00
Paul Payne
37dafcd24d Remove stale apps from readme. 2026-01-04 23:56:53 +00:00
Paul Payne
b6d88e79ac Mastodon vapid init. 2026-01-04 23:56:37 +00:00
Paul Payne
963929475c Update CLAUDE.md. 2026-01-04 19:36:54 +00:00
Paul Payne
39095e76d2 Add Matrix. 2026-01-04 19:36:40 +00:00
Paul Payne
d756126a34 Add Mastodon. 2026-01-04 19:36:31 +00:00
Paul Payne
f17fea6910 Add Lemmy. 2026-01-04 19:36:23 +00:00
Paul Payne
0ba33a315d Add Decidim. 2026-01-04 19:36:15 +00:00
49 changed files with 2444 additions and 20 deletions

View File

@@ -1,15 +1,97 @@
- @README.md - @README.md
- @ADDING-APPS.md - @ADDING-APPS.md
## Finding good sources of documentation for adding a new app to the Wild Cloud Directory
- A good starting point is to:
- look for an app's official documentation on running in Kubernetes or a containerized environment
- look at the app's official docker compose
- look for official or common helm packages
- look at the source code repository for the app
These sources will oftentimes not use the latest version. Check to make sure you are adding the latest app version.
- Don't use helm for the final deployement, however it is a good idea to unpack a helm package to investigate best practices and to overcome tricky configurations
## App package development lifecycle
- when developing a new app, test on the `test-cloud` instance in the `/home/payne/repos/wild-cloud-dev/wild-cloud-redmond-data` wild data dir. Prefer the `wild` CLI for managing app lifecycle as it takes care of copying and compiling kustomize templates. Example commands:
- `wild instance use test-cloud`
- `wild app add <app>`
- `wild app deploy <app>`
- `wild app delete`
- But you can always use `kubectl` directly. Just make sure you use the `test-cloud` `kubeconfig` and, when applying resources, use the `-k` flag so kustomize templates get copied.
- kubectl --kubeconfig=/home/payne/repos/wild-cloud-redmond-data/instances/test-cloud/kubeconfig get pods -n <app>
- While we test in `/home/payne/repos/wild-cloud-dev/wild-cloud-redmond-data/instances/test-cloud`, the final product (our app package) must be in `/home/payne/repos/wild-cloud-dev/wild-directory`. Always make sure that, in the end, whatever is in `wild-directory` can be deployed into `test-cloud`.
- If you run into difficulties, revisit helm charts, docker compose files, and most importantly, the source code to determine how existing deployments are functioning correctly.
## App Icons
Icon Search Process
1. Primary Source: Dashboard Icons (homarr-labs)
- URL Pattern: https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/{app-name}.svg
- Why: Curated collection specifically for self-hosted apps, consistent style, reliable CDN
- Format: SVG (preferred for scalability)
- Check: Visit https://dashboardicons.com/icons/{app-name} to see if it exists
2. Fallback: Vector Logo Zone
- URL Pattern: https://www.vectorlogo.zone/logos/{app-name}/
- Why: Large collection of official logos in standardized formats
- Options:
- {app-name}-icon.svg (logo only, no text)
- {app-name}-ar21.svg (logo with text)
- Best for: Apps not in Dashboard Icons
3. Official Sources
- Check the app's official website for logo/brand pages
- Look for /brand, /logos, /assets paths
- Example: https://www.loomio.com/brand/logo_gold.svg
4. Community CDNs
- LobeHub Icons: For AI/LLM tools (vLLM, etc.)
- https://unpkg.com/@lobehub/icons-static-png@latest/dark/{app-name}.png
- Simple Icons: For popular brands
- https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/{app-name}.svg
Validation Process
For each candidate URL:
1. Test the URL using WebFetch to confirm it returns a valid image
2. Verify format: SVG preferred, PNG acceptable
3. Check content: Logo-only preferred over logo+text
4. Confirm it works: Actually loads in a browser/img tag
Icon Preferences
1. Format: SVG > PNG (for scalability)
2. Style: Logo only > Logo with text
3. Source: Official CDN > Community CDN > Direct hosting
4. Consistency: Similar visual style across all apps
Search Strategy
### For each app:
1. Try Dashboard Icons first: dashboardicons.com/icons/{app}
2. If not found, try Vector Logo Zone: vectorlogo.zone/logos/{app}
3. If still not found, search: "{app} official logo CDN SVG URL"
4. Validate the URL actually works
5. Prefer icon-only versions when multiple options exist
This systematic approach ensures consistent, reliable, and high-quality icons across all Wild Cloud apps.
## IMPORTANT ## IMPORTANT
- NEVER under any circumstances work on any Wild Cloud instance other than `test-cloud`
- `secrets.yaml` is NOT checked in and any values unrelated to your current task should be preserved
- When adding a new app to the directory, check to make sure you are adding the latest app version. - When adding a new app to the directory, check to make sure you are adding the latest app version.
- set `namespace` in default config and refer to it using `{{ .namespace }}` in the `namespace.yaml` definition file and the `kustomization.yaml` file.
- If the app requires a specific platform (amd64, arm64, etc.), make sure it is called out in the manifest and the k8s tags are set
- Use traefik for ingress. - Use traefik for ingress.
- Use postgres for database if supported. - Use postgres for database if supported.
- Keep config key naming (including nesting) consistent with other apps. - Keep config key naming (including nesting) consistent with other apps.
- Don't use helm
- If the app requires a specific platform (amd64, arm64, etc.), make sure it is called out in the manifest and the k8s tags are set
- when developing a new app:
- test with:
- reset to a fresh state between tests:
- secrets.yaml is not checked in and any values unrelated to your current task should be preserved

View File

@@ -97,20 +97,6 @@ Some apps require other apps to function. For example:
When you add an app, check its `requires` field in the manifest and ensure dependencies are added first. When you add an app, check its `requires` field in the manifest and ensure dependencies are added first.
## Available Apps
This repository includes apps for:
- Content management (Ghost, Discourse)
- Project management (OpenProject)
- Photo management (Immich)
- Code hosting (Gitea)
- Email marketing (Listmonk, Keila)
- AI interfaces (Open WebUI, vLLM)
- Databases (PostgreSQL, MySQL, Redis, Memcached)
- And more...
Browse the full catalog with descriptions through the web app, CLI, or via the API endpoint `/api/v1/apps/available`.
## Contributing ## Contributing
Want to add a new app or improve an existing one? See [ADDING-APPS.md](ADDING-APPS.md) for detailed guidance on creating Wild Cloud apps. Want to add a new app or improve an existing one? See [ADDING-APPS.md](ADDING-APPS.md) for detailed guidance on creating Wild Cloud apps.

25
decidim/Dockerfile Normal file
View File

@@ -0,0 +1,25 @@
# Build Decidim with Sidekiq support
FROM decidim/decidim:0.31.0
# Switch to root to install dependencies
USER root
# Add sidekiq to Gemfile
RUN cd /code && \
echo "" >> Gemfile && \
echo "# Background job processing" >> Gemfile && \
echo "gem 'sidekiq', '~> 6.5'" >> Gemfile && \
bundle install
# Configure Rails to use Sidekiq as ActiveJob backend
RUN cd /code && \
test -d config/initializers || mkdir -p config/initializers && \
echo "Rails.application.config.active_job.queue_adapter = :sidekiq" > config/initializers/active_job.rb && \
cat config/initializers/active_job.rb && \
ls -la config/initializers/
# Switch back to decidim user
USER decidim
# Default command (can be overridden)
CMD ["bundle", "exec", "rails", "s", "-b", "0.0.0.0"]

73
decidim/db-init-job.yaml Normal file
View File

@@ -0,0 +1,73 @@
---
apiVersion: batch/v1
kind: Job
metadata:
name: decidim-db-init
namespace: decidim
spec:
ttlSecondsAfterFinished: 300
template:
metadata:
labels:
component: db-init
spec:
restartPolicy: OnFailure
securityContext:
runAsNonRoot: true
runAsUser: 999
runAsGroup: 999
fsGroup: 999
seccompProfile:
type: RuntimeDefault
containers:
- name: db-init
image: postgres:17
imagePullPolicy: IfNotPresent
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
readOnlyRootFilesystem: false
command:
- /bin/bash
- -c
- |
set -e
export PGPASSWORD="${POSTGRES_ADMIN_PASSWORD}"
# Create database if it doesn't exist
psql -h "${POSTGRES_HOST}" -U "${POSTGRES_ADMIN_USER}" -d postgres -tc "SELECT 1 FROM pg_database WHERE datname = '${DB_NAME}'" | grep -q 1 || \
psql -h "${POSTGRES_HOST}" -U "${POSTGRES_ADMIN_USER}" -d postgres -c "CREATE DATABASE ${DB_NAME};"
# Create user if it doesn't exist, or update password if it does
psql -h "${POSTGRES_HOST}" -U "${POSTGRES_ADMIN_USER}" -d postgres -tc "SELECT 1 FROM pg_roles WHERE rolname = '${DB_USER}'" | grep -q 1 && \
psql -h "${POSTGRES_HOST}" -U "${POSTGRES_ADMIN_USER}" -d postgres -c "ALTER USER ${DB_USER} WITH PASSWORD '${DB_PASSWORD}';" || \
psql -h "${POSTGRES_HOST}" -U "${POSTGRES_ADMIN_USER}" -d postgres -c "CREATE USER ${DB_USER} WITH PASSWORD '${DB_PASSWORD}';"
# Grant privileges
psql -h "${POSTGRES_HOST}" -U "${POSTGRES_ADMIN_USER}" -d postgres -c "GRANT ALL PRIVILEGES ON DATABASE ${DB_NAME} TO ${DB_USER};"
# Grant schema privileges (needed for Rails migrations)
psql -h "${POSTGRES_HOST}" -U "${POSTGRES_ADMIN_USER}" -d "${DB_NAME}" -c "GRANT ALL ON SCHEMA public TO ${DB_USER};"
echo "Database initialization completed successfully"
env:
- name: POSTGRES_HOST
value: {{ .dbHostname }}
- name: POSTGRES_ADMIN_USER
value: postgres
- name: POSTGRES_ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: decidim-secrets
key: postgres.password
- name: DB_NAME
value: {{ .dbName }}
- name: DB_USER
value: {{ .dbUsername }}
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: decidim-secrets
key: dbPassword

214
decidim/deployment.yaml Normal file
View File

@@ -0,0 +1,214 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: decidim
namespace: decidim
spec:
replicas: 1
selector:
matchLabels:
component: web
strategy:
type: Recreate
template:
metadata:
labels:
component: web
spec:
automountServiceAccountToken: false
serviceAccountName: decidim
securityContext:
fsGroup: 1000
fsGroupChangePolicy: Always
containers:
- name: decidim
image: payneio/decidim-sidekiq:0.31.0
imagePullPolicy: Always
command:
- /bin/bash
- -c
- |
set -e
cd /code
bundle exec rake db:migrate
bundle exec rails runner "Decidim::System::Admin.find_or_create_by!(email: ENV['SYSTEM_ADMIN_EMAIL']) { |admin| admin.password = ENV['SYSTEM_ADMIN_PASSWORD']; admin.password_confirmation = ENV['SYSTEM_ADMIN_PASSWORD'] }"
bundle exec rails s -b 0.0.0.0
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
add:
- CHOWN
- FOWNER
- SETGID
- SETUID
- DAC_OVERRIDE
privileged: false
readOnlyRootFilesystem: false
runAsNonRoot: false
runAsUser: 0
seccompProfile:
type: RuntimeDefault
env:
- name: RAILS_ENV
value: "production"
- name: PORT
value: "{{ .port }}"
- name: RAILS_LOG_TO_STDOUT
value: "true"
# Database configuration
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: decidim-secrets
key: dbUrl
# Redis configuration
- name: REDIS_HOSTNAME
value: {{ .redisHostname }}
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: decidim-secrets
key: redis.password
- name: REDIS_URL
value: "redis://:$(REDIS_PASSWORD)@$(REDIS_HOSTNAME):6379/0"
# Application configuration
- name: DECIDIM_HOST
value: {{ .domain }}
- name: DECIDIM_ORGANIZATION_NAME
value: {{ .siteName }}
- name: SECRET_KEY_BASE
valueFrom:
secretKeyRef:
name: decidim-secrets
key: secretKeyBase
# SMTP configuration
- name: SMTP_ADDRESS
value: {{ .smtp.host }}
- name: SMTP_PORT
value: "{{ .smtp.port }}"
- name: SMTP_USERNAME
value: {{ .smtp.user }}
- name: SMTP_PASSWORD
valueFrom:
secretKeyRef:
name: decidim-secrets
key: smtpPassword
- name: SMTP_DOMAIN
value: {{ .domain }}
- name: SMTP_FROM
value: {{ .smtp.from }}
- name: SMTP_STARTTLS_AUTO
value: "{{ .smtp.startTls }}"
# System admin credentials
- name: SYSTEM_ADMIN_EMAIL
value: {{ .systemAdminEmail }}
- name: SYSTEM_ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: decidim-secrets
key: systemAdminPassword
ports:
- name: http
containerPort: {{ .port }}
protocol: TCP
livenessProbe:
tcpSocket:
port: {{ .port }}
initialDelaySeconds: 300
periodSeconds: 30
timeoutSeconds: 10
successThreshold: 1
failureThreshold: 6
readinessProbe:
tcpSocket:
port: {{ .port }}
initialDelaySeconds: 180
periodSeconds: 30
timeoutSeconds: 10
successThreshold: 1
failureThreshold: 6
resources:
limits:
cpu: 2000m
ephemeral-storage: 10Gi
memory: 4Gi
requests:
cpu: 500m
ephemeral-storage: 50Mi
memory: 1Gi
volumeMounts:
- name: decidim-data
mountPath: /code/public/uploads
- name: sidekiq
image: payneio/decidim-sidekiq:0.31.0
imagePullPolicy: Always
command:
- /bin/bash
- -c
- |
set -e
cd /code
bundle exec sidekiq
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
add:
- CHOWN
- FOWNER
- SETGID
- SETUID
- DAC_OVERRIDE
privileged: false
readOnlyRootFilesystem: false
runAsNonRoot: false
runAsUser: 0
seccompProfile:
type: RuntimeDefault
env:
- name: RAILS_ENV
value: "production"
- name: RAILS_LOG_TO_STDOUT
value: "true"
# Database configuration
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: decidim-secrets
key: dbUrl
# Redis configuration
- name: REDIS_HOSTNAME
value: {{ .redisHostname }}
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: decidim-secrets
key: redis.password
- name: REDIS_URL
value: "redis://:$(REDIS_PASSWORD)@$(REDIS_HOSTNAME):6379/0"
# Application configuration
- name: DECIDIM_HOST
value: {{ .domain }}
- name: SECRET_KEY_BASE
valueFrom:
secretKeyRef:
name: decidim-secrets
key: secretKeyBase
resources:
limits:
cpu: 1000m
memory: 2Gi
requests:
cpu: 250m
memory: 512Mi
volumeMounts:
- name: decidim-data
mountPath: /code/public/uploads
volumes:
- name: decidim-data
persistentVolumeClaim:
claimName: decidim-data

26
decidim/ingress.yaml Normal file
View File

@@ -0,0 +1,26 @@
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: decidim
namespace: decidim
annotations:
external-dns.alpha.kubernetes.io/target: {{ .externalDnsDomain }}
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
spec:
ingressClassName: traefik
tls:
- hosts:
- {{ .domain }}
secretName: {{ .tlsSecretName }}
rules:
- host: {{ .domain }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: decidim
port:
number: {{ .port }}

View File

@@ -0,0 +1,17 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: decidim
labels:
- includeSelectors: true
pairs:
app: decidim
managedBy: kustomize
partOf: wild-cloud
resources:
- namespace.yaml
- serviceaccount.yaml
- pvc.yaml
- db-init-job.yaml
- deployment.yaml
- service.yaml
- ingress.yaml

44
decidim/manifest.yaml Normal file
View File

@@ -0,0 +1,44 @@
name: decidim
is: decidim
description: Decidim is a participatory democracy framework for cities and organizations. Built in Ruby on Rails, it enables citizen participation through proposals, debates, and voting. Includes Sidekiq for background job processing.
version: 0.31.0
icon: https://raw.githubusercontent.com/decidim/decidim/develop/logo.svg
requires:
- name: postgres
installed_as: postgres
- name: redis
installed_as: redis
defaultConfig:
namespace: decidim
externalDnsDomain: "{{ .cloud.domain }}"
timezone: UTC
port: 3000
storage: 20Gi
systemAdminEmail: "{{ .operator.email }}"
siteName: "Decidim"
domain: decidim.{{ .cloud.domain }}
dbHostname: "{{ .apps.postgres.host }}"
dbPort: "{{ .apps.postgres.port }}"
dbUsername: decidim
dbName: decidim
redisHostname: "{{ .apps.redis.host }}"
tlsSecretName: wildcard-wild-cloud-tls
smtp:
enabled: true
host: "{{ .cloud.smtp.host }}"
port: "{{ .cloud.smtp.port }}"
user: "{{ .cloud.smtp.user }}"
from: "{{ .cloud.smtp.from }}"
tls: "{{ .cloud.smtp.tls }}"
startTls: "{{ .cloud.smtp.startTls }}"
defaultSecrets:
- key: systemAdminPassword
- key: secretKeyBase
default: "{{ random.AlphaNum 128 }}"
- key: smtpPassword
- key: dbPassword
- key: dbUrl
default: "postgres://{{ .app.dbUsername }}:{{ .secrets.dbPassword }}@{{ .app.dbHostname }}:{{ .app.dbPort }}/{{ .app.dbName }}"
requiredSecrets:
- postgres.password
- redis.password

4
decidim/namespace.yaml Normal file
View File

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

12
decidim/pvc.yaml Normal file
View File

@@ -0,0 +1,12 @@
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: decidim-data
namespace: decidim
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: {{ .storage }}

15
decidim/service.yaml Normal file
View File

@@ -0,0 +1,15 @@
---
apiVersion: v1
kind: Service
metadata:
name: decidim
namespace: decidim
spec:
selector:
component: web
ports:
- name: http
port: {{ .port }}
targetPort: http
protocol: TCP
type: ClusterIP

View File

@@ -0,0 +1,7 @@
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: decidim
namespace: decidim
automountServiceAccountToken: false

36
lemmy/configmap.yaml Normal file
View File

@@ -0,0 +1,36 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: lemmy-config
namespace: {{ .namespace }}
data:
lemmy.hjson: |
{
hostname: "{{ .domain }}"
bind: "0.0.0.0"
port: {{ .backendPort }}
tls_enabled: false
database: {
uri: "postgresql://{{ .dbUser }}:DBPASSWORD@{{ .dbHost }}:{{ .dbPort }}/{{ .dbName }}"
}
pictrs: {
url: "http://lemmy-pictrs:{{ .pictrsPort }}/"
api_key: "PICTRS_API_KEY"
}
email: {
smtp_server: "{{ .smtp.host }}:{{ .smtp.port }}"
smtp_login: "{{ .smtp.user }}"
smtp_password: "SMTP_PASSWORD"
smtp_from_address: "{{ .smtp.from }}"
tls_type: "{{ if eq .smtp.tls "true" }}tls{{ else }}none{{ end }}"
}
setup: {
admin_username: "admin"
admin_password: "ADMIN_PASSWORD"
site_name: "Lemmy"
}
}

76
lemmy/db-init-job.yaml Normal file
View File

@@ -0,0 +1,76 @@
apiVersion: batch/v1
kind: Job
metadata:
name: lemmy-db-init
namespace: {{ .namespace }}
spec:
template:
metadata:
labels:
component: db-init
spec:
restartPolicy: OnFailure
securityContext:
runAsNonRoot: true
runAsUser: 999
runAsGroup: 999
seccompProfile:
type: RuntimeDefault
containers:
- name: db-init
image: postgres:16-alpine
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
readOnlyRootFilesystem: false
env:
- name: PGHOST
value: "{{ .dbHost }}"
- name: PGPORT
value: "{{ .dbPort }}"
- name: PGUSER
value: postgres
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: lemmy-secrets
key: postgres.password
- name: DB_NAME
value: "{{ .dbName }}"
- name: DB_USER
value: "{{ .dbUser }}"
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: lemmy-secrets
key: dbPassword
command:
- sh
- -c
- |
set -e
echo "Waiting for PostgreSQL to be ready..."
until pg_isready -h $PGHOST -p $PGPORT -U $PGUSER; do
echo "Waiting for database connection..."
sleep 2
done
echo "Creating database and user..."
psql -v ON_ERROR_STOP=1 <<-EOSQL
SELECT 'CREATE DATABASE ${DB_NAME}' WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '${DB_NAME}')\gexec
DO \$\$
BEGIN
IF NOT EXISTS (SELECT FROM pg_user WHERE usename = '${DB_USER}') THEN
CREATE USER ${DB_USER} WITH PASSWORD '${DB_PASSWORD}';
ELSE
ALTER USER ${DB_USER} WITH PASSWORD '${DB_PASSWORD}';
END IF;
END
\$\$;
GRANT ALL PRIVILEGES ON DATABASE ${DB_NAME} TO ${DB_USER};
\c ${DB_NAME}
GRANT ALL ON SCHEMA public TO ${DB_USER};
EOSQL
echo "Database initialization completed successfully"

View File

@@ -0,0 +1,102 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: lemmy-backend
namespace: {{ .namespace }}
spec:
replicas: {{ .backendReplicas }}
selector:
matchLabels:
component: backend
template:
metadata:
labels:
component: backend
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
initContainers:
- name: config-prep
image: busybox:stable
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
readOnlyRootFilesystem: true
command:
- sh
- -c
- |
cp /config-template/lemmy.hjson /config/lemmy.hjson
sed -i "s|DBPASSWORD|${DB_PASSWORD}|g" /config/lemmy.hjson
sed -i "s|PICTRS_API_KEY|${PICTRS_API_KEY}|g" /config/lemmy.hjson
sed -i "s|SMTP_PASSWORD|${SMTP_PASSWORD}|g" /config/lemmy.hjson
sed -i "s|ADMIN_PASSWORD|${ADMIN_PASSWORD}|g" /config/lemmy.hjson
env:
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: lemmy-secrets
key: dbPassword
- name: PICTRS_API_KEY
valueFrom:
secretKeyRef:
name: lemmy-secrets
key: jwtSecret
- name: SMTP_PASSWORD
valueFrom:
secretKeyRef:
name: lemmy-secrets
key: smtpPassword
- name: ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: lemmy-secrets
key: adminPassword
volumeMounts:
- name: config-template
mountPath: /config-template
- name: config
mountPath: /config
containers:
- name: backend
image: {{ .backendImage }}
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
readOnlyRootFilesystem: false
env:
- name: LEMMY_CONFIG_LOCATION
value: /config/lemmy.hjson
- name: TZ
value: "{{ .timezone }}"
ports:
- containerPort: {{ .backendPort }}
name: http
volumeMounts:
- name: config
mountPath: /config
livenessProbe:
httpGet:
path: /api/v3/site
port: {{ .backendPort }}
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /api/v3/site
port: {{ .backendPort }}
initialDelaySeconds: 10
periodSeconds: 5
volumes:
- name: config-template
configMap:
name: lemmy-config
- name: config
emptyDir: {}

View File

@@ -0,0 +1,77 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: lemmy-pictrs
namespace: {{ .namespace }}
spec:
replicas: {{ .pictrsReplicas }}
selector:
matchLabels:
component: pictrs
template:
metadata:
labels:
component: pictrs
spec:
securityContext:
runAsNonRoot: true
runAsUser: 991
runAsGroup: 991
fsGroup: 991
seccompProfile:
type: RuntimeDefault
containers:
- name: pictrs
image: {{ .pictrsImage }}
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
readOnlyRootFilesystem: false
env:
- name: PICTRS__SERVER__BIND
value: "0.0.0.0:{{ .pictrsPort }}"
- name: PICTRS__MEDIA__VIDEO_CODEC
value: vp9
- name: PICTRS__MEDIA__GIF__MAX_WIDTH
value: "256"
- name: PICTRS__MEDIA__GIF__MAX_HEIGHT
value: "256"
- name: PICTRS__MEDIA__GIF__MAX_AREA
value: "65536"
- name: PICTRS__MEDIA__GIF__MAX_FRAME_COUNT
value: "400"
- name: RUST_LOG
value: debug
- name: RUST_BACKTRACE
value: full
- name: PICTRS__REPO__TYPE
value: sled
- name: PICTRS__REPO__PATH
value: /mnt/sled-repo
- name: PICTRS__STORE__TYPE
value: filesystem
- name: PICTRS__STORE__PATH
value: /mnt/files
ports:
- containerPort: {{ .pictrsPort }}
name: http
volumeMounts:
- name: storage
mountPath: /mnt
livenessProbe:
httpGet:
path: /healthz
port: {{ .pictrsPort }}
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /healthz
port: {{ .pictrsPort }}
initialDelaySeconds: 10
periodSeconds: 5
volumes:
- name: storage
persistentVolumeClaim:
claimName: lemmy-pictrs-storage

51
lemmy/deployment-ui.yaml Normal file
View File

@@ -0,0 +1,51 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: lemmy-ui
namespace: {{ .namespace }}
spec:
replicas: {{ .uiReplicas }}
selector:
matchLabels:
component: ui
template:
metadata:
labels:
component: ui
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
seccompProfile:
type: RuntimeDefault
containers:
- name: ui
image: {{ .uiImage }}
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
readOnlyRootFilesystem: false
env:
- name: LEMMY_UI_LEMMY_INTERNAL_HOST
value: "lemmy-backend:{{ .backendPort }}"
- name: LEMMY_UI_LEMMY_EXTERNAL_HOST
value: "{{ .domain }}"
- name: LEMMY_UI_HTTPS
value: "true"
ports:
- containerPort: {{ .uiPort }}
name: http
livenessProbe:
httpGet:
path: /
port: {{ .uiPort }}
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /
port: {{ .uiPort }}
initialDelaySeconds: 10
periodSeconds: 5

42
lemmy/ingress.yaml Normal file
View File

@@ -0,0 +1,42 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: lemmy-ingress
namespace: {{ .namespace }}
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.tls: "true"
external-dns.alpha.kubernetes.io/target: {{ .externalDnsDomain }}
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
spec:
ingressClassName: traefik
tls:
- hosts:
- {{ .domain }}
secretName: {{ .tlsSecretName }}
rules:
- host: {{ .domain }}
http:
paths:
- path: /api
pathType: Prefix
backend:
service:
name: lemmy-backend
port:
number: {{ .backendPort }}
- path: /pictrs
pathType: Prefix
backend:
service:
name: lemmy-pictrs
port:
number: {{ .pictrsPort }}
- path: /
pathType: Prefix
backend:
service:
name: lemmy-ui
port:
number: {{ .uiPort }}

21
lemmy/kustomization.yaml Normal file
View File

@@ -0,0 +1,21 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: lemmy
labels:
- includeSelectors: true
pairs:
app: lemmy
managedBy: kustomize
partOf: wild-cloud
resources:
- namespace.yaml
- configmap.yaml
- pvc-pictrs.yaml
- db-init-job.yaml
- deployment-pictrs.yaml
- service-pictrs.yaml
- deployment-backend.yaml
- service-backend.yaml
- deployment-ui.yaml
- service-ui.yaml
- ingress.yaml

41
lemmy/manifest.yaml Normal file
View File

@@ -0,0 +1,41 @@
name: lemmy
is: lemmy
description: Lemmy is a selfhosted social link aggregation and discussion platform. It is an open source alternative to Reddit, designed for the fediverse.
version: 0.19.15
icon: https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/lemmy.svg
requires:
- name: postgres
defaultConfig:
namespace: lemmy
backendImage: dessalines/lemmy:0.19.15
uiImage: dessalines/lemmy-ui:0.19.15
pictrsImage: asonix/pictrs:0.5.5
backendPort: 8536
uiPort: 1234
pictrsPort: 8080
backendReplicas: 1
uiReplicas: 1
pictrsReplicas: 1
storage: 10Gi
pictrsStorage: 50Gi
timezone: UTC
domain: lemmy.{{ .cloud.domain }}
externalDnsDomain: lemmy.{{ .cloud.baseDomain }}
tlsSecretName: lemmy-tls
dbName: lemmy
dbUser: lemmy
dbHost: postgres.postgres.svc.cluster.local
dbPort: 5432
smtp:
host: "{{ .cloud.smtp.host }}"
port: "{{ .cloud.smtp.port }}"
user: "{{ .cloud.smtp.user }}"
from: "noreply@{{ .cloud.baseDomain }}"
tls: "{{ .cloud.smtp.tls }}"
defaultSecrets:
- key: dbPassword
- key: adminPassword
- key: jwtSecret
- key: smtpPassword
requiredSecrets:
- postgres.password

4
lemmy/namespace.yaml Normal file
View File

@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: {{ .namespace }}

11
lemmy/pvc-pictrs.yaml Normal file
View File

@@ -0,0 +1,11 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: lemmy-pictrs-storage
namespace: {{ .namespace }}
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: {{ .pictrsStorage }}

View File

@@ -0,0 +1,13 @@
apiVersion: v1
kind: Service
metadata:
name: lemmy-backend
namespace: {{ .namespace }}
spec:
type: ClusterIP
selector:
component: backend
ports:
- name: http
port: {{ .backendPort }}
targetPort: {{ .backendPort }}

13
lemmy/service-pictrs.yaml Normal file
View File

@@ -0,0 +1,13 @@
apiVersion: v1
kind: Service
metadata:
name: lemmy-pictrs
namespace: {{ .namespace }}
spec:
type: ClusterIP
selector:
component: pictrs
ports:
- name: http
port: {{ .pictrsPort }}
targetPort: {{ .pictrsPort }}

13
lemmy/service-ui.yaml Normal file
View File

@@ -0,0 +1,13 @@
apiVersion: v1
kind: Service
metadata:
name: lemmy-ui
namespace: {{ .namespace }}
spec:
type: ClusterIP
selector:
component: ui
ports:
- name: http
port: {{ .uiPort }}
targetPort: {{ .uiPort }}

81
mastodon/README.md Normal file
View File

@@ -0,0 +1,81 @@
# Mastodon
Mastodon is a free, open-source social network server based on ActivityPub. It allows you to run your own instance of a decentralized social media platform.
## Version
This package deploys Mastodon v4.5.3 (released July 8, 2025).
## Dependencies
- **PostgreSQL**: Database for storing application data
- **Redis**: Used for caching and background job queuing
## Configuration
### VAPID Keys
Mastodon requires VAPID (Voluntary Application Server Identification) keys for Web Push notifications. These keys use Elliptic Curve P-256 cryptography.
**The Wild Cloud API automatically generates proper VAPID keys when you add the Mastodon app.** No manual configuration is required!
### Database
The database is automatically initialized with:
- Database: `mastodon_production`
- User: `mastodon` with auto-generated password
- All necessary privileges granted
The db-init job handles creating the database and user, and automatically updates the user password if it changes.
### Storage
Mastodon uses two persistent volumes:
- **Assets** (10Gi): Stores compiled assets and static files
- **System** (100Gi): Stores user uploads, media files, and other system data
Both volumes use ReadWriteMany access mode to allow multiple pods to access them simultaneously.
## Components
Mastodon runs three separate services:
- **Web (Puma)**: Main web server for the Mastodon web interface
- **Streaming (Node.js)**: Real-time streaming API for live updates
- **Sidekiq**: Background job processor for async tasks
## Access
After deployment, Mastodon will be available at:
- https://mastodon.{your-cloud-domain}
The ingress automatically routes:
- `/api/v1/streaming` → Streaming service
- All other paths → Web service
## First-Time Setup
1. Add and deploy the app:
```bash
wild app add mastodon
wild app deploy mastodon
```
2. Generate and configure VAPID keys (see above)
3. Access your instance in a browser and create the first admin user account
4. Configure additional settings through the Mastodon admin interface
## Security
All containers run as non-root user (UID 991) with:
- No privilege escalation
- All capabilities dropped
- Compliant with Pod Security Standards
## Notes
- SMTP configuration is inherited from your Wild Cloud instance settings
- Database credentials are auto-generated and stored in your instance's `secrets.yaml`
- The Active Record Encryption keys are auto-generated for Rails 8.0.3 compatibility

185
mastodon/db-init-job.yaml Normal file
View File

@@ -0,0 +1,185 @@
apiVersion: batch/v1
kind: Job
metadata:
name: mastodon-db-init
namespace: {{ .namespace }}
spec:
ttlSecondsAfterFinished: 300
template:
metadata:
labels:
component: db-init
spec:
restartPolicy: OnFailure
securityContext:
runAsUser: 999
runAsGroup: 999
fsGroup: 999
seccompProfile:
type: RuntimeDefault
containers:
- name: db-init
image: postgres:16-alpine
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
readOnlyRootFilesystem: false
env:
- name: PGHOST
value: "{{ .dbHostname }}"
- name: PGPORT
value: "{{ .dbPort }}"
- name: PGUSER
value: postgres
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: mastodon-secrets
key: postgres.password
- name: MASTODON_DB
value: "{{ .dbName }}"
- name: MASTODON_USER
value: "{{ .dbUsername }}"
- name: MASTODON_PASSWORD
valueFrom:
secretKeyRef:
name: mastodon-secrets
key: dbPassword
command:
- sh
- -c
- |
set -e
echo "Waiting for PostgreSQL to be ready..."
until pg_isready -h $PGHOST -p $PGPORT -U $PGUSER; do
echo "PostgreSQL is unavailable - sleeping"
sleep 2
done
echo "PostgreSQL is ready"
echo "Creating database if it doesn't exist..."
psql -v ON_ERROR_STOP=1 <<-EOSQL
SELECT 'CREATE DATABASE $MASTODON_DB'
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '$MASTODON_DB')\gexec
EOSQL
echo "Creating/updating user..."
psql -v ON_ERROR_STOP=1 <<-EOSQL
DO \$\$
BEGIN
IF NOT EXISTS (SELECT FROM pg_user WHERE usename = '$MASTODON_USER') THEN
CREATE USER $MASTODON_USER WITH PASSWORD '$MASTODON_PASSWORD';
ELSE
ALTER USER $MASTODON_USER WITH PASSWORD '$MASTODON_PASSWORD';
END IF;
END
\$\$;
EOSQL
echo "Granting privileges..."
psql -v ON_ERROR_STOP=1 <<-EOSQL
GRANT ALL PRIVILEGES ON DATABASE $MASTODON_DB TO $MASTODON_USER;
\c $MASTODON_DB
GRANT ALL ON SCHEMA public TO $MASTODON_USER;
EOSQL
echo "Database initialization complete"
---
apiVersion: batch/v1
kind: Job
metadata:
name: mastodon-db-migrate
namespace: {{ .namespace }}
spec:
ttlSecondsAfterFinished: 300
template:
metadata:
labels:
component: db-migrate
spec:
restartPolicy: OnFailure
securityContext:
runAsNonRoot: true
runAsUser: 991
runAsGroup: 991
fsGroup: 991
seccompProfile:
type: RuntimeDefault
containers:
- name: db-migrate
image: {{ .image }}
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
readOnlyRootFilesystem: false
command:
- bundle
- exec
- rails
- db:migrate
env:
- name: LOCAL_DOMAIN
value: "{{ .domain }}"
- name: RAILS_ENV
value: production
- name: SECRET_KEY_BASE
valueFrom:
secretKeyRef:
name: mastodon-secrets
key: secretKeyBase
- name: OTP_SECRET
valueFrom:
secretKeyRef:
name: mastodon-secrets
key: otpSecret
- name: ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY
valueFrom:
secretKeyRef:
name: mastodon-secrets
key: activeRecordPrimaryKey
- name: ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY
valueFrom:
secretKeyRef:
name: mastodon-secrets
key: activeRecordDeterministicKey
- name: ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT
valueFrom:
secretKeyRef:
name: mastodon-secrets
key: activeRecordKeyDerivationSalt
- name: DB_HOST
value: "{{ .dbHostname }}"
- name: DB_PORT
value: "{{ .dbPort }}"
- name: DB_NAME
value: "{{ .dbName }}"
- name: DB_USER
value: "{{ .dbUsername }}"
- name: DB_PASS
valueFrom:
secretKeyRef:
name: mastodon-secrets
key: dbPassword
- name: REDIS_HOST
value: "{{ .redisHostname }}"
- name: REDIS_PORT
value: "{{ .redisPort }}"
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: mastodon-secrets
key: redis.password
volumeMounts:
- name: assets
mountPath: /opt/mastodon/public/assets
- name: system
mountPath: /opt/mastodon/public/system
volumes:
- name: assets
persistentVolumeClaim:
claimName: mastodon-assets
- name: system
persistentVolumeClaim:
claimName: mastodon-system

View File

@@ -0,0 +1,156 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: mastodon-sidekiq
namespace: {{ .namespace }}
spec:
replicas: {{ .sidekiq.replicas }}
selector:
matchLabels:
component: sidekiq
template:
metadata:
labels:
component: sidekiq
spec:
securityContext:
runAsNonRoot: true
runAsUser: 991
runAsGroup: 991
fsGroup: 991
seccompProfile:
type: RuntimeDefault
containers:
- name: sidekiq
image: {{ .image }}
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
readOnlyRootFilesystem: false
command:
- bundle
- exec
- sidekiq
- -c
- "{{ .sidekiq.concurrency }}"
- -q
- default,8
- -q
- push,6
- -q
- ingress,4
- -q
- mailers,2
- -q
- pull
- -q
- scheduler
env:
- name: LOCAL_DOMAIN
value: "{{ .domain }}"
- name: RAILS_ENV
value: production
- name: RAILS_LOG_LEVEL
value: info
- name: DEFAULT_LOCALE
value: "{{ .locale }}"
- name: SECRET_KEY_BASE
valueFrom:
secretKeyRef:
name: mastodon-secrets
key: secretKeyBase
- name: OTP_SECRET
valueFrom:
secretKeyRef:
name: mastodon-secrets
key: otpSecret
- name: VAPID_PRIVATE_KEY
valueFrom:
secretKeyRef:
name: mastodon-secrets
key: vapidPrivateKey
- name: VAPID_PUBLIC_KEY
valueFrom:
secretKeyRef:
name: mastodon-secrets
key: vapidPublicKey
- name: ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY
valueFrom:
secretKeyRef:
name: mastodon-secrets
key: activeRecordPrimaryKey
- name: ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY
valueFrom:
secretKeyRef:
name: mastodon-secrets
key: activeRecordDeterministicKey
- name: ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT
valueFrom:
secretKeyRef:
name: mastodon-secrets
key: activeRecordKeyDerivationSalt
- name: DB_HOST
value: "{{ .dbHostname }}"
- name: DB_PORT
value: "{{ .dbPort }}"
- name: DB_NAME
value: "{{ .dbName }}"
- name: DB_USER
value: "{{ .dbUsername }}"
- name: DB_PASS
valueFrom:
secretKeyRef:
name: mastodon-secrets
key: dbPassword
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: mastodon-secrets
key: postgres.password
- name: REDIS_HOST
value: "{{ .redisHostname }}"
- name: REDIS_PORT
value: "{{ .redisPort }}"
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: mastodon-secrets
key: redis.password
- name: SMTP_SERVER
value: "{{ .smtp.server }}"
- name: SMTP_PORT
value: "{{ .smtp.port }}"
- name: SMTP_LOGIN
value: "{{ .smtp.user }}"
- name: SMTP_PASSWORD
valueFrom:
secretKeyRef:
name: mastodon-secrets
key: smtpPassword
- name: SMTP_FROM_ADDRESS
value: "{{ .smtp.from }}"
- name: SMTP_AUTH_METHOD
value: "{{ .smtp.authMethod }}"
- name: SMTP_ENABLE_STARTTLS
value: "{{ .smtp.enableStarttls }}"
- name: SMTP_TLS
value: "{{ .smtp.tls }}"
volumeMounts:
- name: assets
mountPath: /opt/mastodon/public/assets
- name: system
mountPath: /opt/mastodon/public/system
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
memory: 768Mi
volumes:
- name: assets
persistentVolumeClaim:
claimName: mastodon-assets
- name: system
persistentVolumeClaim:
claimName: mastodon-system

View File

@@ -0,0 +1,83 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: mastodon-streaming
namespace: {{ .namespace }}
spec:
replicas: 1
selector:
matchLabels:
component: streaming
template:
metadata:
labels:
component: streaming
spec:
securityContext:
runAsNonRoot: true
runAsUser: 991
runAsGroup: 991
fsGroup: 991
seccompProfile:
type: RuntimeDefault
containers:
- name: streaming
image: {{ .streamingImage }}
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
readOnlyRootFilesystem: false
ports:
- name: streaming
containerPort: {{ .streamingPort }}
protocol: TCP
env:
- name: NODE_ENV
value: production
- name: PORT
value: "{{ .streamingPort }}"
- name: STREAMING_CLUSTER_NUM
value: "1"
- name: DB_HOST
value: "{{ .dbHostname }}"
- name: DB_PORT
value: "{{ .dbPort }}"
- name: DB_NAME
value: "{{ .dbName }}"
- name: DB_USER
value: "{{ .dbUsername }}"
- name: DB_PASS
valueFrom:
secretKeyRef:
name: mastodon-secrets
key: dbPassword
- name: REDIS_HOST
value: "{{ .redisHostname }}"
- name: REDIS_PORT
value: "{{ .redisPort }}"
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: mastodon-secrets
key: redis.password
resources:
requests:
cpu: 250m
memory: 128Mi
limits:
memory: 512Mi
livenessProbe:
httpGet:
path: /api/v1/streaming/health
port: streaming
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
readinessProbe:
httpGet:
path: /api/v1/streaming/health
port: streaming
initialDelaySeconds: 20
periodSeconds: 5
timeoutSeconds: 3

View File

@@ -0,0 +1,170 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: mastodon-web
namespace: {{ .namespace }}
spec:
replicas: 1
selector:
matchLabels:
component: web
template:
metadata:
labels:
component: web
spec:
securityContext:
runAsNonRoot: true
runAsUser: 991
runAsGroup: 991
fsGroup: 991
seccompProfile:
type: RuntimeDefault
containers:
- name: web
image: {{ .image }}
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
readOnlyRootFilesystem: false
command:
- bundle
- exec
- puma
- -C
- config/puma.rb
ports:
- name: http
containerPort: {{ .webPort }}
protocol: TCP
env:
- name: LOCAL_DOMAIN
value: "{{ .domain }}"
- name: RAILS_ENV
value: production
- name: RAILS_LOG_LEVEL
value: info
- name: DEFAULT_LOCALE
value: "{{ .locale }}"
- name: SINGLE_USER_MODE
value: "{{ .singleUserMode }}"
- name: SECRET_KEY_BASE
valueFrom:
secretKeyRef:
name: mastodon-secrets
key: secretKeyBase
- name: OTP_SECRET
valueFrom:
secretKeyRef:
name: mastodon-secrets
key: otpSecret
- name: VAPID_PRIVATE_KEY
valueFrom:
secretKeyRef:
name: mastodon-secrets
key: vapidPrivateKey
- name: VAPID_PUBLIC_KEY
valueFrom:
secretKeyRef:
name: mastodon-secrets
key: vapidPublicKey
- name: ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY
valueFrom:
secretKeyRef:
name: mastodon-secrets
key: activeRecordPrimaryKey
- name: ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY
valueFrom:
secretKeyRef:
name: mastodon-secrets
key: activeRecordDeterministicKey
- name: ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT
valueFrom:
secretKeyRef:
name: mastodon-secrets
key: activeRecordKeyDerivationSalt
- name: DB_HOST
value: "{{ .dbHostname }}"
- name: DB_PORT
value: "{{ .dbPort }}"
- name: DB_NAME
value: "{{ .dbName }}"
- name: DB_USER
value: "{{ .dbUsername }}"
- name: DB_PASS
valueFrom:
secretKeyRef:
name: mastodon-secrets
key: dbPassword
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: mastodon-secrets
key: postgres.password
- name: REDIS_HOST
value: "{{ .redisHostname }}"
- name: REDIS_PORT
value: "{{ .redisPort }}"
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: mastodon-secrets
key: redis.password
- name: SMTP_SERVER
value: "{{ .smtp.server }}"
- name: SMTP_PORT
value: "{{ .smtp.port }}"
- name: SMTP_LOGIN
value: "{{ .smtp.user }}"
- name: SMTP_PASSWORD
valueFrom:
secretKeyRef:
name: mastodon-secrets
key: smtpPassword
- name: SMTP_FROM_ADDRESS
value: "{{ .smtp.from }}"
- name: SMTP_AUTH_METHOD
value: "{{ .smtp.authMethod }}"
- name: SMTP_ENABLE_STARTTLS
value: "{{ .smtp.enableStarttls }}"
- name: SMTP_TLS
value: "{{ .smtp.tls }}"
- name: STREAMING_API_BASE_URL
value: "wss://{{ .domain }}"
- name: WEB_CONCURRENCY
value: "2"
- name: MAX_THREADS
value: "5"
volumeMounts:
- name: assets
mountPath: /opt/mastodon/public/assets
- name: system
mountPath: /opt/mastodon/public/system
resources:
requests:
cpu: 250m
memory: 768Mi
limits:
memory: 1280Mi
livenessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
readinessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 20
periodSeconds: 5
timeoutSeconds: 3
volumes:
- name: assets
persistentVolumeClaim:
claimName: mastodon-assets
- name: system
persistentVolumeClaim:
claimName: mastodon-system

33
mastodon/ingress.yaml Normal file
View File

@@ -0,0 +1,33 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: mastodon
namespace: {{ .namespace }}
annotations:
external-dns.alpha.kubernetes.io/target: {{ .externalDnsDomain }}
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
traefik.ingress.kubernetes.io/router.entrypoints: websecure
spec:
ingressClassName: traefik
tls:
- hosts:
- {{ .domain }}
secretName: {{ .tlsSecretName }}
rules:
- host: {{ .domain }}
http:
paths:
- path: /api/v1/streaming
pathType: Prefix
backend:
service:
name: mastodon-streaming
port:
number: {{ .streamingPort }}
- path: /
pathType: Prefix
backend:
service:
name: mastodon-web
port:
number: {{ .webPort }}

View File

@@ -0,0 +1,21 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: {{ .namespace }}
labels:
- includeSelectors: true
pairs:
app: mastodon
managedBy: kustomize
partOf: wild-cloud
resources:
- namespace.yaml
- pvc-assets.yaml
- pvc-system.yaml
- db-init-job.yaml
- vapid-init-job.yaml
- deployment-web.yaml
- deployment-sidekiq.yaml
- deployment-streaming.yaml
- service-web.yaml
- service-streaming.yaml
- ingress.yaml

67
mastodon/manifest.yaml Normal file
View File

@@ -0,0 +1,67 @@
name: mastodon
is: mastodon
description: Mastodon is a free, open-source social network server based on ActivityPub.
version: 4.5.3
icon: https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/mastodon.svg
requires:
- name: postgres
- name: redis
defaultConfig:
namespace: mastodon
externalDnsDomain: "{{ .cloud.domain }}"
timezone: UTC
image: ghcr.io/mastodon/mastodon:v4.5.3
streamingImage: ghcr.io/mastodon/mastodon-streaming:v4.5.3
domain: mastodon.{{ .cloud.domain }}
locale: en
singleUserMode: false
# Database configuration
dbHostname: "{{ .apps.postgres.host }}"
dbPort: "{{ .apps.postgres.port }}"
dbName: mastodon_production
dbUsername: mastodon
# Redis configuration
redisHostname: "{{ .apps.redis.host }}"
redisPort: "{{ .apps.redis.port }}"
# Ports
webPort: 3000
streamingPort: 4000
# Storage
assetsStorage: 10Gi
systemStorage: 100Gi
# SMTP configuration
smtp:
enabled: "{{ .cloud.smtp.host | ternary true false }}"
server: "{{ .cloud.smtp.host }}"
port: "{{ .cloud.smtp.port }}"
from: notifications@{{ .cloud.domain }}
user: "{{ .cloud.smtp.user }}"
authMethod: plain
enableStarttls: auto
tls: "{{ .cloud.smtp.tls }}"
# TLS
tlsSecretName: wildcard-wild-cloud-tls
# Sidekiq configuration
sidekiq:
replicas: 1
concurrency: 25
defaultSecrets:
- key: secretKeyBase
default: "{{ random.AlphaNum 128 }}"
- key: otpSecret
default: "{{ random.AlphaNum 128 }}"
- key: vapidPrivateKey
# Generated by vapid-init-job.yaml on first deploy
- key: vapidPublicKey
# Generated by vapid-init-job.yaml on first deploy
- key: activeRecordPrimaryKey
default: "{{ random.AlphaNum 32 }}"
- key: activeRecordDeterministicKey
default: "{{ random.AlphaNum 32 }}"
- key: activeRecordKeyDerivationSalt
default: "{{ random.AlphaNum 32 }}"
- key: dbPassword
- key: smtpPassword
requiredSecrets:
- postgres.password
- redis.password

4
mastodon/namespace.yaml Normal file
View File

@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: {{ .namespace }}

11
mastodon/pvc-assets.yaml Normal file
View File

@@ -0,0 +1,11 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mastodon-assets
namespace: {{ .namespace }}
spec:
accessModes:
- ReadWriteMany
resources:
requests:
storage: {{ .assetsStorage }}

11
mastodon/pvc-system.yaml Normal file
View File

@@ -0,0 +1,11 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mastodon-system
namespace: {{ .namespace }}
spec:
accessModes:
- ReadWriteMany
resources:
requests:
storage: {{ .systemStorage }}

View File

@@ -0,0 +1,14 @@
apiVersion: v1
kind: Service
metadata:
name: mastodon-streaming
namespace: {{ .namespace }}
spec:
type: ClusterIP
ports:
- port: {{ .streamingPort }}
targetPort: streaming
protocol: TCP
name: streaming
selector:
component: streaming

14
mastodon/service-web.yaml Normal file
View File

@@ -0,0 +1,14 @@
apiVersion: v1
kind: Service
metadata:
name: mastodon-web
namespace: {{ .namespace }}
spec:
type: ClusterIP
ports:
- port: {{ .webPort }}
targetPort: http
protocol: TCP
name: http
selector:
component: web

View File

@@ -0,0 +1,68 @@
apiVersion: batch/v1
kind: Job
metadata:
name: mastodon-vapid-init
namespace: {{ .namespace }}
spec:
ttlSecondsAfterFinished: 300
template:
metadata:
labels:
component: vapid-init
spec:
restartPolicy: OnFailure
securityContext:
runAsNonRoot: true
runAsUser: 991
runAsGroup: 991
fsGroup: 991
seccompProfile:
type: RuntimeDefault
containers:
- name: vapid-init
image: {{ .image }}
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
readOnlyRootFilesystem: false
command:
- sh
- -c
- |
set -e
# Check if VAPID keys already exist in the secret
if [ -n "$VAPID_PRIVATE_KEY" ] && [ "$VAPID_PRIVATE_KEY" != "null" ] && \
[ -n "$VAPID_PUBLIC_KEY" ] && [ "$VAPID_PUBLIC_KEY" != "null" ]; then
echo "VAPID keys already exist in secret, skipping generation"
exit 0
fi
echo "Generating VAPID keys..."
bundle exec rake mastodon:webpush:generate_vapid_key > /tmp/vapid_output.txt
echo "VAPID keys generated:"
cat /tmp/vapid_output.txt
echo ""
echo "NOTE: These keys must be manually added to secrets.yaml:"
echo " apps.mastodon.vapidPrivateKey: <VAPID_PRIVATE_KEY from above>"
echo " apps.mastodon.vapidPublicKey: <VAPID_PUBLIC_KEY from above>"
env:
- name: VAPID_PRIVATE_KEY
valueFrom:
secretKeyRef:
name: mastodon-secrets
key: vapidPrivateKey
optional: true
- name: VAPID_PUBLIC_KEY
valueFrom:
secretKeyRef:
name: mastodon-secrets
key: vapidPublicKey
optional: true
- name: RAILS_ENV
value: production
- name: LOCAL_DOMAIN
value: "{{ .domain }}"

66
matrix/configmap.yaml Normal file
View File

@@ -0,0 +1,66 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: matrix-config
data:
homeserver.yaml: |
server_name: "{{ .serverName }}"
public_baseurl: https://{{ .domain }}
listeners:
- port: {{ .port }}
tls: false
type: http
x_forwarded: true
bind_addresses: ['::']
resources:
- names: [client, federation]
compress: false
database:
name: psycopg2
args:
user: {{ .dbUsername }}
password: ${DB_PASSWORD}
database: {{ .dbName }}
host: {{ .dbHostname }}
port: 5432
cp_min: 5
cp_max: 10
redis:
enabled: true
host: {{ .redisHostname }}
port: 6379
password: ${REDIS_PASSWORD}
media_store_path: /data/media_store
uploads_path: /data/uploads
max_upload_size: 100M
enable_registration: {{ .enableRegistration }}
registration_shared_secret: "${REGISTRATION_SHARED_SECRET}"
macaroon_secret_key: "${MACAROON_SECRET_KEY}"
form_secret: "${FORM_SECRET}"
signing_key_path: /data/keys/signing.key
trusted_key_servers:
- server_name: "matrix.org"
email:
smtp_host: "{{ .smtp.host }}"
smtp_port: {{ .smtp.port }}
smtp_user: "{{ .smtp.user }}"
smtp_pass: "${SMTP_PASSWORD}"
require_transport_security: {{ .smtp.requireTls }}
notif_from: "{{ .smtp.from }}"
app_name: Matrix
report_stats: false
enable_metrics: true
suppress_key_server_warning: true

57
matrix/db-init-job.yaml Normal file
View File

@@ -0,0 +1,57 @@
apiVersion: batch/v1
kind: Job
metadata:
name: matrix-db-init
spec:
template:
spec:
containers:
- name: db-init
image: postgres:17
command: ["/bin/bash", "-c"]
args:
- |
PGPASSWORD=${POSTGRES_ADMIN_PASSWORD} psql -h ${DB_HOSTNAME} -U postgres <<EOF
DO \$\$
BEGIN
IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = '${DB_USERNAME}') THEN
CREATE USER ${DB_USERNAME} WITH ENCRYPTED PASSWORD '${DB_PASSWORD}';
ELSE
ALTER USER ${DB_USERNAME} WITH ENCRYPTED PASSWORD '${DB_PASSWORD}';
END IF;
END
\$\$;
SELECT 'CREATE DATABASE ${DB_DATABASE_NAME} ENCODING ''UTF8'' LC_COLLATE ''C'' LC_CTYPE ''C'' TEMPLATE template0' WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '${DB_DATABASE_NAME}')\gexec
ALTER DATABASE ${DB_DATABASE_NAME} OWNER TO ${DB_USERNAME};
GRANT ALL PRIVILEGES ON DATABASE ${DB_DATABASE_NAME} TO ${DB_USERNAME};
EOF
env:
- name: POSTGRES_ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: matrix-secrets
key: postgres.password
- name: DB_HOSTNAME
value: "{{ .dbHostname }}"
- name: DB_DATABASE_NAME
value: "{{ .dbName }}"
- name: DB_USERNAME
value: "{{ .dbUsername }}"
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: matrix-secrets
key: dbPassword
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
readOnlyRootFilesystem: false
securityContext:
runAsNonRoot: true
runAsUser: 999
runAsGroup: 999
seccompProfile:
type: RuntimeDefault
restartPolicy: OnFailure

221
matrix/deployment.yaml Normal file
View File

@@ -0,0 +1,221 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: matrix-synapse
spec:
replicas: 1
selector:
matchLabels:
app: matrix-synapse
strategy:
type: Recreate
template:
metadata:
labels:
app: matrix-synapse
component: synapse
spec:
initContainers:
- name: generate-signing-key
image: "{{ .image }}"
command: ["/bin/sh", "-c"]
args:
- |
if [ ! -f /data/keys/signing.key ]; then
echo "Generating signing key..."
mkdir -p /data/keys
# Use Synapse's generate-keys command
python3 -m synapse.app.homeserver \
--generate-keys \
--config-path=/config/homeserver.yaml
echo "Signing key generated successfully"
ls -la /data/keys/
else
echo "Signing key already exists"
fi
env:
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: matrix-secrets
key: dbPassword
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: matrix-secrets
key: redis.password
- name: REGISTRATION_SHARED_SECRET
valueFrom:
secretKeyRef:
name: matrix-secrets
key: registrationSharedSecret
- name: MACAROON_SECRET_KEY
valueFrom:
secretKeyRef:
name: matrix-secrets
key: macaroonSecretKey
- name: FORM_SECRET
valueFrom:
secretKeyRef:
name: matrix-secrets
key: formSecret
- name: SMTP_PASSWORD
valueFrom:
secretKeyRef:
name: matrix-secrets
key: smtp.password
volumeMounts:
- name: matrix-data
mountPath: /data
- name: matrix-config
mountPath: /config
readOnly: true
securityContext:
runAsUser: 991
runAsGroup: 991
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
readOnlyRootFilesystem: false
containers:
- name: synapse
image: "{{ .image }}"
command: ["/bin/sh", "-c"]
args:
- |
set -e
echo "Starting config substitution..."
# Substitute environment variables in the config using Python
python3 -c "
import os
import re
import sys
print('Reading config from /config/homeserver.yaml', file=sys.stderr)
try:
with open('/config/homeserver.yaml', 'r') as f:
content = f.read()
print(f'Config file read: {len(content)} bytes', file=sys.stderr)
except Exception as e:
print(f'Error reading config: {e}', file=sys.stderr)
sys.exit(1)
# Replace \${VAR} with environment variable values
def replace_var(match):
var_name = match.group(1)
value = os.environ.get(var_name, match.group(0))
print(f'Replacing {var_name}: {\"***\" if \"PASSWORD\" in var_name or \"SECRET\" in var_name else value}', file=sys.stderr)
return value
content = re.sub(r'\\\$\{([A-Z_]+)\}', replace_var, content)
print('Writing processed config to /data/homeserver.yaml', file=sys.stderr)
try:
with open('/data/homeserver.yaml', 'w') as f:
f.write(content)
print('Config file written successfully', file=sys.stderr)
except Exception as e:
print(f'Error writing config: {e}', file=sys.stderr)
sys.exit(1)
" || { echo "Python script failed with exit code $?"; exit 1; }
echo "Config substitution complete"
ls -la /data/homeserver.yaml
# Start Synapse with the processed config
exec /start.py
ports:
- containerPort: {{ .port }}
protocol: TCP
name: http
- containerPort: {{ .federationPort }}
protocol: TCP
name: federation
env:
- name: SYNAPSE_CONFIG_PATH
value: /data/homeserver.yaml
- name: TZ
value: "{{ .timezone }}"
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: matrix-secrets
key: dbPassword
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: matrix-secrets
key: redis.password
- name: REGISTRATION_SHARED_SECRET
valueFrom:
secretKeyRef:
name: matrix-secrets
key: registrationSharedSecret
- name: MACAROON_SECRET_KEY
valueFrom:
secretKeyRef:
name: matrix-secrets
key: macaroonSecretKey
- name: FORM_SECRET
valueFrom:
secretKeyRef:
name: matrix-secrets
key: formSecret
- name: SMTP_PASSWORD
valueFrom:
secretKeyRef:
name: matrix-secrets
key: smtp.password
volumeMounts:
- name: matrix-config
mountPath: /config
readOnly: true
- name: matrix-data
mountPath: /data
- name: matrix-media
mountPath: /data/media_store
livenessProbe:
httpGet:
path: /health
port: {{ .port }}
initialDelaySeconds: 60
periodSeconds: 30
timeoutSeconds: 5
readinessProbe:
httpGet:
path: /health
port: {{ .port }}
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
resources:
requests:
memory: "512Mi"
cpu: "500m"
limits:
memory: "2Gi"
cpu: "2000m"
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
readOnlyRootFilesystem: false
securityContext:
runAsNonRoot: true
runAsUser: 991
runAsGroup: 991
fsGroup: 991
seccompProfile:
type: RuntimeDefault
volumes:
- name: matrix-config
configMap:
name: matrix-config
- name: matrix-data
persistentVolumeClaim:
claimName: matrix-data-pvc
- name: matrix-media
persistentVolumeClaim:
claimName: matrix-media-pvc

52
matrix/ingress.yaml Normal file
View File

@@ -0,0 +1,52 @@
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: matrix-client-ingress
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.tls: "true"
external-dns.alpha.kubernetes.io/target: {{ .externalDnsDomain }}
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
spec:
tls:
- hosts:
- {{ .domain }}
secretName: {{ .tlsSecretName }}
rules:
- host: {{ .domain }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: matrix-synapse
port:
number: {{ .port }}
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: matrix-federation-ingress
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.tls: "true"
external-dns.alpha.kubernetes.io/target: {{ .externalDnsDomain }}
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
spec:
tls:
- hosts:
- {{ .serverName }}
secretName: {{ .tlsSecretName }}
rules:
- host: {{ .serverName }}
http:
paths:
- path: /.well-known/matrix
pathType: Prefix
backend:
service:
name: matrix-synapse
port:
number: {{ .federationPort }}

17
matrix/kustomization.yaml Normal file
View File

@@ -0,0 +1,17 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: {{ .namespace }}
labels:
- includeSelectors: true
pairs:
app: matrix
managedBy: kustomize
partOf: wild-cloud
resources:
- namespace.yaml
- configmap.yaml
- pvc.yaml
- db-init-job.yaml
- deployment.yaml
- service.yaml
- ingress.yaml

41
matrix/manifest.yaml Normal file
View File

@@ -0,0 +1,41 @@
name: matrix
is: matrix
install: true
description: Matrix is an open standard for secure, decentralized, real-time communication. This deploys the Synapse homeserver for self-hosted Matrix federation and messaging.
version: v1.144.0
icon: https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/matrix.svg
requires:
- name: postgres
- name: redis
defaultConfig:
namespace: matrix
externalDnsDomain: '{{ .cloud.domain }}'
image: matrixdotorg/synapse:v1.144.0
timezone: UTC
port: 8008
federationPort: 8448
storage: 50Gi
mediaStorage: 100Gi
serverName: '{{ .cloud.domain }}'
dbHostname: postgres.postgres.svc.cluster.local
dbUsername: matrix
dbName: matrix
redisHostname: redis.redis.svc.cluster.local
domain: matrix.{{ .cloud.domain }}
tlsSecretName: wildcard-wild-cloud-tls
enableRegistration: false
smtp:
host: '{{ .cloud.smtp.host }}'
port: '{{ .cloud.smtp.port }}'
from: matrix@{{ .cloud.domain }}
user: '{{ .cloud.smtp.user }}'
requireTls: '{{ .cloud.smtp.tls }}'
defaultSecrets:
- key: dbPassword
- key: registrationSharedSecret
- key: macaroonSecretKey
- key: formSecret
requiredSecrets:
- postgres.password
- redis.password
- smtp.password

4
matrix/namespace.yaml Normal file
View File

@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: {{ .namespace }}

22
matrix/pvc.yaml Normal file
View File

@@ -0,0 +1,22 @@
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: matrix-data-pvc
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: {{ .storage }}
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: matrix-media-pvc
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: {{ .mediaStorage }}

18
matrix/service.yaml Normal file
View File

@@ -0,0 +1,18 @@
---
apiVersion: v1
kind: Service
metadata:
name: matrix-synapse
spec:
type: ClusterIP
ports:
- name: http
port: {{ .port }}
targetPort: {{ .port }}
protocol: TCP
- name: federation
port: {{ .federationPort }}
targetPort: {{ .federationPort }}
protocol: TCP
selector:
app: matrix-synapse

View File

@@ -4,6 +4,9 @@ apiVersion: networking.k8s.io/v1
kind: Ingress kind: Ingress
metadata: metadata:
name: openproject name: openproject
annotations:
external-dns.alpha.kubernetes.io/target: {{ .externalDnsDomain }}
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
spec: spec:
tls: tls:
- hosts: - hosts: