backend, web, admin, mobile, and landing repositories. The backend resolves each church from the subdomain in the request host, so a hosted installation needs a base domain and wildcard DNS for church portals.
Choose a setup
The backend Compose file is for local dependencies. It does not start the API, Client Portal, or Admin Console. The Flutter mobile app is built separately and is not deployed by the Helm chart.
Run locally
Prerequisites
- Docker Engine with Docker Compose v2
- Node.js 22
- pnpm 9 for the backend
- npm for the web and admin applications
- OpenSSL for creating local signing secrets
Start the backend
From the repository checkout, open a terminal inbackend:
.env.example as a variable reference. Before starting the application, set separate random values for JWT_ACCESS_SECRET and JWT_REFRESH_SECRET, and replace the example platform and tenant seed passwords. These values are for local development; never reuse them on a public server. The backend needs MASTER_DB_URL, TENANT_DB_URL, TENANT_DB_DIRECT_URL, and REDIS_URL. The example file points these at the local Compose services.
Generate each signing key separately:
seed:master creates the local platform administrator [email protected] and a demo organisation. Set SEED_SUPERADMIN_PASSWORD before running it. provision creates and migrates the demo tenant database; seed:tenant demo creates the passwordless local account [email protected]. Without SMTP configured, sign-in codes are logged by the backend instead of emailed.
The Compose database credentials and exposed ports are for local development only. Do not expose the Compose services to the public internet or use the example passwords on a server. Use the provision command for a single local tenant and migrate:tenants to migrate existing tenant databases. Avoid running migration:tenant:run with its default target, which points to tenant_demo.
The API listens on port 3000. In development, its health endpoint is http://localhost:3000/health and its Swagger UI is http://localhost:3000/docs.
Start the Client Portal
In another terminal, openweb:
demo tenant on localhost. Open http://demo.localhost:3002. The .localhost wildcard works in standard desktop browsers; for devices that cannot resolve it, use a local tunnel or a development domain that resolves to your computer.
Start the Admin Console
In another terminal, openadmin:
http://localhost:3000. Open http://localhost:3003 and sign in as [email protected] with the SEED_SUPERADMIN_PASSWORD you set before seeding.
The web and admin applications expose health checks at /api/health on their respective development ports.
Run the mobile app
The mobile app is a separate Flutter build. Set itsBASE_DOMAIN, API_SCHEME, API_PORT, WEB_SCHEME, and WEB_PORT Dart defines to match the API and portal addresses. The available settings are in mobile/lib/config/api_config.dart. Android emulators do not resolve subdomains of localhost; use a tunnel or a development domain that resolves from the device.
Deploy to a server
This guide takes you from a blank Ubuntu VPS to a working Faithloft install on your own domain. It uses k3s, Helm, Traefik, CloudNativePG, and, depending on how traffic reaches the server, cert-manager or a Cloudflare Tunnel. It is an operations deployment, not the local Compose file. Replaceexample.org with your domain throughout.
The chart deploys these workloads:
faithloft-backend: the API, autoscaled between 2 and 6 replicas with rolling updates.faithloft-worker: background jobs and schedulers, exactly 1 replica.- The Client Portal, the Admin Console and the landing site, 2 replicas each.
- PostgreSQL (CloudNativePG) with 2 instances (a primary and an automatic-failover standby) and a pooler, and Redis.
Requirements
- An Ubuntu 22.04 or newer VPS. This guide was tested on 8 vCPU and 11 GB RAM. 4 vCPU and 8 GB RAM is the suggested minimum; this is an estimate, not a tested floor.
- About 20 GB of disk for PostgreSQL, plus room for container images.
- A domain whose DNS is hosted on Cloudflare.
- Root or
sudoaccess on the server.
Choose how traffic reaches the server
To check which path you need, run this from another network, not from the server:
Install k3s with the Traefik config
Write the Traefik configuration before you install k3s, so Traefik starts with it:externalTrafficPolicy: Local preserves the real client IP, which the backend needs for throttling and lockout controls.
If another web server already holds ports 80 and 443, stop it first:
Install Helm, cert-manager, CloudNativePG, and the barman plugin
Install the Helm CLI using the official method, then confirm it works:NXDOMAIN looking up TXT for _acme-challenge:
certManager.enabled=false in your values file instead.
Install CloudNativePG and the barman-cloud plugin:
Get the container images
Choose one option. Option a: GitHub Container Registry. Each repository’s.github/workflows/image.yml builds an image on every push to main. Set the repository variables from the table below, then create a pull secret from a GitHub token with read:packages:
backend, web, admin, and landing repositories, clone it and build the image with the build arguments below:
repository and tag to what you built, set pullPolicy: Never, and set imagePullSecrets: [].
Build-time settings for the Next.js apps are baked into the image. Set them as --build-arg options for option b, or as repository variables with the same names for option a:
If you change your domain later, rebuild the web, admin, and landing images. Changing Helm values does not rewrite them.
Create the namespace and secrets
Create the namespace if you have not already:church PostgreSQL role. Keep the P variable in your shell; the next step uses it.
kubectl -n faithloft get secret faithloft-pg-church -o jsonpath='{.data.password}' | base64 -d.
Generate the backend env file in one step. The script writes every required key with fresh secrets and keeps the file private. The image sets NODE_ENV, so it is not listed.
FAITHLOFT_PROXY_SECRET in faithloft-web must equal the backend PORTAL_PROXY_SECRET, and the one in faithloft-admin must equal ADMIN_PROXY_SECRET:
Zone:DNS:Edit and Zone:Zone:Read permissions for your zone. Store it in the cert-manager namespace:
Configure DNS
Path A: public IP. In Cloudflare DNS, create these records and set both to DNS only (grey cloud):
Proxying (orange cloud) would hide client IPs and conflict with the in-cluster TLS. Remove any parking-page
A record and any www CNAME.
Path B: Cloudflare Tunnel. Remove any conflicting A records first. On your laptop, install cloudflared, then run:
tunnel create prints the tunnel ID and writes ~/.cloudflared/<ID>.json. The two route dns commands create proxied CNAME records that point to <ID>.cfargotunnel.com. Copy the JSON file to the server, then create the credentials secret:
A tunnel created in the Zero Trust dashboard is remotely managed, and its dashboard routes override the chart’s local routes. If you use the dashboard, add the same routes there, in this order:
example.orgto the landing site.apito the API.*with path^/(v1|socket\.io)(/|$)to the API.adminto the Admin Console.*to the Client Portal.
*.example.org. Deeper subdomains need Advanced Certificate Manager.
Add a dedicated edge node (optional)
Path B only. Run the tunnel connectors on a second, small node so that a connector survives the loss of either node. The cluster has two nodes:main, the k3s server that runs every workload, and edge, an agent that runs only tunnel connectors.
On the server, read the join token from /var/lib/rancher/k3s/server/node-token. On the edge machine, join it as an agent:
tunnel block of your values file:
spreadAcrossNodes stays true, which places one connector per node. Replicas above the node count stay Pending.
Cloudflare balances traffic across the connectors on both nodes. If the edge node dies, the connector on the main node keeps serving. The edge node holds no data. If the main node dies, the site is down.
Write the values file
Keep~/faithloft/values.yaml outside Git. Both paths need the base domain and the image tags. Set the image repository too if you use your own registry. For option b, also set pullPolicy: Never on each image and imagePullSecrets: []. Add postgres.backup now if you have a bucket (see Backups).
Path A, using the Let’s Encrypt staging server for the first run:
acmeServer line and delete the staging certificate secret so cert-manager issues a production certificate:
Install and initialize
On a first install, run the migrations before the worker and API start. Run these commands from thebackend checkout.
1
Install the chart
backend.2
Wait for PostgreSQL
3
Stop the worker and the API
4
Run the migrations
Use the backend image you installed. For images built on the server, add
--image-pull-policy=Never and "imagePullPolicy":"Never" in the overrides, and drop the imagePullSecrets.5
Clear Redis (first install only)
6
Start the worker and the API
backend.autoscaling.minReplicas). After this, the autoscaler takes over.tenant_demo. Confirm it in the worker logs:
migration:tenant:run. Its default target is tenant_demo.
Verify
get certificate check applies to Path A only. For Path A, run the checks below from outside your network.
Monitoring and alerts
The chart can run a small monitor that pushes alerts to your phone through ntfy. Create the secret before you install, or run the install again after creating it. Pick a long random topic name, because anyone who knows it can read your alerts.monitor.enabled: true in your values file, a “monitor started” message arrives after the install.
The monitor alerts when a container restarts or crash-loops, when a workload has fewer ready replicas than expected, when a node is NotReady, when Postgres is unhealthy or fails over, and when a health check fails twice in a row. It sends a recovery message when the problem clears.
A monitor inside the cluster cannot report that the server itself is down. To cover that, add a HEARTBEAT_URL key with a ping URL from a service such as healthchecks.io. The monitor pings it every minute, and the service alerts you through its ntfy, email or other integration when the pings stop.
High availability (optional)
One server cannot survive its own loss. For real high availability, run 3 k3s servers with embedded etcd. A single server, or a server plus agents, has no API server after a failure, so nothing is rescheduled and Postgres cannot fail over. To convert an existing single server, back up/var/lib/rancher/k3s/server/db, add cluster-init: true to /etc/rancher/k3s/config.yaml, and restart k3s. Remove any existing agent first with k3s-agent-uninstall.sh on that machine and kubectl delete node <name>. Then join each new server:
kubectl taint node <n> faithloft.org/edge- once all 3 servers should share the workloads. Recommended counts:
Volumes are tied to their node, so Redis only returns when its node does. Each Postgres instance has its own volume and fails over by promotion.
Sign in for the first time
- Admin Console:
https://admin.example.org. Sign in as[email protected]with theSEED_SUPERADMIN_PASSWORDfrom your env file. - Church portal:
https://demo.example.org.
Autoscaling the API
The API autoscales on CPU. By default it keeps at least 2 pods (backend.autoscaling.minReplicas) and grows to at most 6 (maxReplicas) when average CPU passes 70% of the 500m request. The worker is never autoscaled. A new pod takes 30 to 60 seconds to become Ready, so a sudden spike is absorbed by the always-on floor first.
Measured on 2026-10-10, one API pod handled about 1,500 concurrent active users (2 to 8 seconds of think time) at about 0.9 core. Plan about 1 core of node CPU per pod. Watch it with:
(maxReplicas + 1 surge + 1 worker) × 150 clients. The default max_client_conn of 2000 covers a maxReplicas up to 11; raise postgres.pooler.maxClientConn before going higher.
Deploy updates with zero downtime
- Put the new image tags in your values file.
-
Stop the worker and wait for it to go:
-
Run
pnpm migration:master:runand thenpnpm migrate:tenantsfrom the temporary migration pod, using the new image tag. The old API keeps serving meanwhile. -
Upgrade the chart. The API rolls with no downtime and the worker returns on the new image:
-
Check the result and confirm the API READY count is between
minReplicasandmaxReplicasand the worker is1/1:helm upgraderestores the worker but not an API you scaled to 0, because the autoscaled chart omitsreplicas. In that case runkubectl -n faithloft scale deploy/faithloft-backend --replicas=2(yourminReplicas) and the autoscaler resumes.
backend/deploy/k3s/README.md.
To roll back, run helm rollback faithloft. This does not roll back the database schema.
Automatic deploys (optional)
On a single k3s server without an image registry,backend/deploy/k3s/autodeploy/ provides a systemd timer that runs every 2 minutes. It fetches main of the backend, client, admin, and landing repositories. When something changed it builds the images on the host and imports them into k3s. For a backend change it then stops the worker, runs the migrations, runs helm upgrade, checks every rollout, and sends progress and failure notifications to ntfy.
Setup in short:
- Create one read-only deploy key per repository with
gh repo deploy-key add <key>.pub --repo <owner>/<repo>, add a~/.ssh/configalias for each (usessh.github.comon port 443 if outbound port 22 is blocked), and clone the four repositories through the aliases. - Install the script with
sudo install -m 755 faithloft-autodeploy.sh /usr/local/bin/faithloft-autodeploy, copyautodeploy.env.exampleto/etc/faithloft-autodeploy.envwith mode 600 and edit it, copy the two systemd units to/etc/systemd/system, and runsudo systemctl enable --now faithloft-autodeploy.timer. - Use
faithloft-autodeploy --status,--force all, and--dry-runto inspect or force a run, andjournalctl -u faithloft-autodeployfor logs.
[deploy:breaking] in a backend commit message to stop the API as well during that release. A failed deploy sends a high-priority ntfy message and does not roll back automatically: run helm rollback faithloft -n faithloft to recover. A failed deploy is attempted once: the same commits are not retried until any repository gets a new commit, or you run faithloft-autodeploy --force all after fixing the problem. If Coolify still runs, merging to main also deploys Coolify production. The full runbook is in backend/deploy/k3s/README.md.
Backups
Configure backups before you store real data.- Create an S3-compatible bucket.
-
Create the credentials secret:
-
Set the
postgres.backupvalues in~/faithloft/values.yaml: -
Run
helm upgrade, take an on-demand backup, and complete the restore drill. The exact manifests are in section 12 ofbackend/deploy/k3s/README.md.
~/faithloft/backend.env separately and securely. It holds the encryption keys that the database backups depend on.
Troubleshooting
Reference
backend/deploy/k3s/README.mdis the full runbook. It covers cutover from Coolify, Redis state, landmines, and the breaking-release path.backend/deploy/helm/faithloft/values.yamllists every chart value.backend/src/config/env.validation.tslists every backend environment key.
Optional integrations
The core app can start without every external provider, but related features remain limited until you configure one:
Use
backend/.env.example and backend/src/config/env.validation.ts as the authoritative environment variable reference. Client build-time settings are listed in each app’s .env.example or configuration source.