Deployment Guide
This guide covers how Colony's components are deployed to various environments.
Documentation Deployment
Colony's documentation is automatically deployed to GitHub Pages using GitHub Actions.
GitHub Pages Setup
The documentation is built with MkDocs Material and deployed to GitHub
Pages at: https://your-username.github.io/colony/
Automatic Deployment
Every push to the main branch automatically triggers a documentation
build and deployment via GitHub Actions.
GitHub Actions Workflow
The deployment is handled by .github/workflows/docs.yml:
name: Deploy Documentation
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configure Git Credentials
run: |
git config user.name github-actions[bot]
git config user.email 41898282+github-actions[bot]@users.noreply.github.com
- uses: actions/setup-python@v4
with:
python-version: 3.x
- name: Cache dependencies
uses: actions/cache@v3
with:
key: ${{ github.ref }}
path: .cache
- name: Install dependencies
run: pip install mkdocs-material
- name: Deploy to GitHub Pages
run: mkdocs gh-deploy --force
Manual Deployment
To deploy documentation manually:
Repository Settings
Ensure your GitHub repository has the following settings:
- Go to Settings > Pages
- Source: Deploy from a branch
- Branch:
gh-pages - Folder:
/ (root)
The gh-deploy command automatically creates and manages the gh-pages
branch.
Local Preview
To preview documentation locally before deployment:
# Install dependencies
pip install mkdocs-material
# Serve locally
mkdocs serve
# Access at http://localhost:8000
Troubleshooting
Common Issues:
- Permission denied: Ensure GitHub Actions has write permissions
- Build fails: Check that all referenced files exist in the docs folder
- Pages not updating: GitHub Pages can take a few minutes to reflect changes
Debug Steps:
- Check GitHub Actions logs in the Actions tab
- Verify all documentation files are committed
- Ensure
mkdocs.ymlconfiguration is valid - Test build locally with
mkdocs build
Application Deployment — Kubernetes (k3s)
Colony ships with a Helm chart located at helm/colony/ for deploying
to a Kubernetes cluster. This guide targets a k3s homelab setup with a
private Harbor registry.
Cluster assumptions used in this guide:
| Item | Value |
|---|---|
| Node IPs | 192.168.1.208 (control-plane), 192.168.1.206 (worker) |
| Harbor URL | http://192.168.1.206:30002 |
| Target namespace | colony-app |
| Frontend URL | http://192.168.1.206:30080 |
| Backend URL | http://192.168.1.206:30800 |
Substitute your own values where needed.
Prerequisites
Install the following tools on the machine you will deploy from:
- kubectl — configured with access to the cluster
(
kubectl get nodesshould return your nodes) - helm v3 —
helm versionto verify - docker — for building and pushing images
Step 1 — Harbor Setup (one-time)
1a. Allow insecure HTTP registry on each k3s node
Harbor runs on plain HTTP, so every k3s node must be told to trust it.
SSH into each node and create or edit
/etc/rancher/k3s/registries.yaml:
Then restart k3s:
Verify k3s restarted cleanly:
1b. Allow insecure push from your local Docker
The steps differ depending on how Docker is installed on your machine.
Option A — Docker Engine installed natively inside WSL2 (Linux)
This is the case when you run sudo apt install docker.io (or equivalent)
inside your WSL distro and the daemon runs there. All configuration happens
from inside the WSL terminal.
Edit /etc/docker/daemon.json inside WSL (create if missing):
Restart the Docker daemon. WSL2 distros often run without systemd, so try
service first:
If your distro has systemd enabled (WSL2 + systemd is supported in recent Windows builds and Ubuntu 22.04+):
Verify it picked up the change:
Option B — Docker Desktop for Windows (WSL integration)
This is the case when Docker Desktop is installed on Windows and your WSL
distro uses it via the "Use the WSL 2 based engine" integration.
The daemon runs inside Docker Desktop's own VM, so editing
/etc/docker/daemon.json inside WSL has no effect — the change must be
made on the Windows side.
Via the Docker Desktop UI (recommended):
- Open Docker Desktop on Windows
- Go to Settings → Docker Engine
- Add the
insecure-registrieskey to the JSON config:
- Click Apply & restart
Via the Windows config file (alternative):
Open %USERPROFILE%\.docker\daemon.json
(i.e. C:\Users\<your-username>\.docker\daemon.json) in any text editor
and add the same JSON block above. Then restart Docker Desktop.
After either method, verify from your WSL terminal:
You should see 192.168.1.206:30002 listed.
1c. Create a project in Harbor
- Open
http://192.168.1.206:30002in a browser - Log in (default credentials:
admin/Harbor12345— change these) - Go to Projects → New Project
- Create a project named
colony(or use the existinglibraryproject) - Set the project to Public so k3s can pull without authentication, or keep it private and create a pull secret (see Step 4 — Pull Secret)
1d. Log in to Harbor from Docker
Step 2 — Build and Push Images
Frontend API URL — runtime injection
The frontend image is built with a placeholder string instead of a hardcoded URL. At container startup,
entrypoint.shreplaces that placeholder in the compiled JavaScript with the value ofNEXT_PUBLIC_API_URLsupplied at runtime. This means one image can be deployed anywhere — no rebuild needed when the backend URL changes. The URL is set invalues.yamlunderfrontend.env.apiUrland injected by Helm as a container environment variable.
Build the backend
docker build \
-t 192.168.1.206:30002/colony/colony-backend:1.0.0 \
./backend
docker push 192.168.1.206:30002/colony/colony-backend:1.0.0
Build the frontend
docker build \
-t 192.168.1.206:30002/colony/colony-frontend:1.0.0 \
./frontend
docker push 192.168.1.206:30002/colony/colony-frontend:1.0.0
Step 3 — Install with Helm
Configuration goes in a custom my-values.yaml file. Two starting
templates follow — Option 1 with Ingress for hostname-based access,
Option 2 with NodePort-only access. Both are valid; you can migrate
from one to the other later via helm upgrade.
Why a values file rather than
--setflags? Helm's--setparser treats commas as key/value delimiters, so settings that contain commas (notably the JSONallowedHostsarray) fail to parse withError: failed parsing --set data: key "..." has no value. A values file is immune to this and to most shell-quoting traps. Same on Helm 3.x and 4.x.
Option 1 — NodePort + Ingress (recommended)
Reach the app at clean hostnames like http://colony.dev.lan and
http://api.colony.dev.lan, served by Traefik (which ships with k3s by
default). Requires DNS resolution for those names — see
Step 7 — Switching Between NodePort and Ingress
for /etc/hosts setup or the homelab's
Pi-hole guide.
image:
registry: "192.168.1.206:30002"
backend:
image:
repository: colony/colony-backend
tag: "1.0.0"
port: 8000
service:
type: NodePort
nodePort: 30800
env:
secretKey: "your-long-random-secret-here"
# CORS: include both Ingress hostname and NodePort URL during transition.
allowedHosts: '["http://colony.dev.lan","http://192.168.1.206:30080"]'
defaultAdminPassword: "your-admin-password"
frontend:
image:
repository: colony/colony-frontend
tag: "1.0.0"
port: 3000
service:
type: NodePort
nodePort: 30080
env:
# Browser-side URL the JS calls. Use the Ingress hostname.
apiUrl: "http://api.colony.dev.lan/api/v1"
ingress:
enabled: true
ingressClassName: traefik
frontend:
host: colony.dev.lan
backend:
host: api.colony.dev.lan
postgresql:
password: "your-strong-db-password"
Option 2 — NodePort only (no Ingress)
Reach the app at http://<node-ip>:<nodePort>. Simpler — no DNS or
ingress controller assumptions. This is also the default if you omit the
ingress block entirely.
image:
registry: "192.168.1.206:30002"
backend:
image:
repository: colony/colony-backend
tag: "1.0.0"
port: 8000
service:
type: NodePort
nodePort: 30800
env:
secretKey: "your-long-random-secret-here"
allowedHosts: '["http://192.168.1.206:30080"]'
defaultAdminPassword: "your-admin-password"
frontend:
image:
repository: colony/colony-frontend
tag: "1.0.0"
port: 3000
service:
type: NodePort
nodePort: 30080
env:
apiUrl: "http://192.168.1.206:30800/api/v1"
postgresql:
password: "your-strong-db-password"
Install
Whichever file you used:
Tip — separate values files per environment. Keep distinct files for dev and prod (e.g.
~/homelab/colony-dev/my-values.yamland~/homelab/colony-prod/my-values.yaml) with their own NodePort numbers, hostnames, and image tags. They install with the same chart but different--namespaceand-f. To make dev/prod resource names distinct in the cluster, use distinct release names too —helm install colony-dev …vshelm install colony-prod …— which propagates through every templated resource via{{ include "colony.fullname" . }}.
Step 4 — Optional: Harbor Pull Secret
If your Harbor project is private, create a pull secret so k3s can authenticate:
kubectl create secret docker-registry harbor-secret \
--docker-server=192.168.1.206:30002 \
--docker-username=<harbor-user> \
--docker-password=<harbor-password> \
--namespace colony-app
Then set imagePullSecrets in your values file:
Step 5 — Verify the Deployment
# Watch pods come up
kubectl get pods -n colony-app -w
# All three pods should reach Running/Ready state:
# colony-backend-* 1/1 Running
# colony-frontend-* 1/1 Running
# colony-postgresql-* 1/1 Running
# Check services and NodePorts
kubectl get svc -n colony-app
Step 6 — Access the Application
| Service | URL |
|---|---|
| Frontend (app) | http://192.168.1.206:30080 |
| Backend API docs | http://192.168.1.206:30800/docs |
| Backend health | http://192.168.1.206:30800/health |
The default admin credentials are whatever you set in
backend.env.defaultAdminUsername / backend.env.defaultAdminPassword
(defaults: admin / colony-admin).
Step 7 — Optional: Switching Between NodePort and Ingress
If you installed with Option 1 (Ingress already enabled in
my-values.yaml), the chart has already created the Ingress resources;
skip ahead to 7b for DNS setup.
If you installed with Option 2 (NodePort-only) and later want
hostname access, edit my-values.yaml to enable the Ingress block and
update the URLs the frontend and backend use:
ingress:
enabled: true
ingressClassName: traefik
frontend:
host: colony.dev.lan
backend:
host: api.colony.dev.lan
backend:
env:
# Both Ingress hostname and NodePort URL — coexist during the switch.
allowedHosts: '["http://colony.dev.lan","http://192.168.1.206:30080"]'
frontend:
env:
apiUrl: "http://api.colony.dev.lan/api/v1"
Apply:
7a. Verify Traefik and the Ingress resources
k3s ships Traefik preinstalled — no extra install is needed. Sanity check the controller and the Ingresses created by the chart:
kubectl get ingressclass
# NAME CONTROLLER AGE
# traefik traefik.io/ingress-controller ...
kubectl get svc -n kube-system traefik
# TYPE EXTERNAL-IP PORT(S)
# LoadBalancer 192.168.1.206,192.168.1.208 80:32103/TCP,443:32192/TCP
kubectl get ingress -n colony-app
# NAME CLASS HOSTS ADDRESS PORTS
# colony-frontend traefik colony.dev.lan 192.168.1.206,192.168.1.208 80
# colony-backend traefik api.colony.dev.lan 192.168.1.206,192.168.1.208 80
The Ingress resource names follow {{ release-name }}-frontend /
-backend, so a release named colony-dev would produce
colony-dev-frontend / colony-dev-backend.
7b. Resolve the hostnames
Until a LAN-wide DNS server is in place, add entries on each client
machine that needs access. /etc/hosts does not support wildcards —
list each hostname explicitly, one per line (or space-separated on a
single line):
For a permanent solution that covers every device on the network with a
single *.dev.lan wildcard rule, see the homelab guide
Pi-hole Local DNS Setup.
7c. Verify end-to-end
getent hosts colony.dev.lan # → 192.168.1.206
getent hosts api.colony.dev.lan # → 192.168.1.206
curl -I http://colony.dev.lan # → HTTP/1.1 200 OK
curl http://api.colony.dev.lan/health
Open http://colony.dev.lan in a browser and log in. The frontend's
JavaScript will call the backend at http://api.colony.dev.lan/api/v1.
NodePort still works — but only if the browser has DNS for the hostname. Once
apiUrlpoints atapi.colony.dev.lan, accessing the frontend viahttp://192.168.1.206:30080still serves the page, but its API calls will fail unless that browser can also resolveapi.colony.dev.lan(via/etc/hostsor Pi-hole). For machines without DNS for the new names, keep using the NodePort URLs end-to-end.HTTPS and
cookieSecure. This Ingress serves plain HTTP, so leavefrontend.env.cookieSecure: "false"(the chart default) —Securecookies are dropped over HTTP and break login. When you later add TLS to Traefik (cert-manager + Let's Encrypt, or self-signed via mkcert), flipcookieSecureto"true". See Login succeeds (backend returns 200) but the app redirects back to login in the troubleshooting section below for context.
Updating the Application
Update the backend
# 1. Build and push the new image
docker build -t 192.168.1.206:30002/colony/colony-backend:1.1.0 ./backend
docker push 192.168.1.206:30002/colony/colony-backend:1.1.0
# 2. Upgrade the release
helm upgrade colony ./helm/colony \
--namespace colony-app \
--reuse-values \
--set backend.image.tag=1.1.0
Update the frontend
# 1. Build and push the new image (no build args needed)
docker build \
-t 192.168.1.206:30002/colony/colony-frontend:1.1.0 \
./frontend
docker push 192.168.1.206:30002/colony/colony-frontend:1.1.0
# 2. Upgrade the release
helm upgrade colony ./helm/colony \
--namespace colony-app \
--reuse-values \
--set frontend.image.tag=1.1.0
To change the backend URL the frontend points to (e.g. after moving to a different node or port), no image rebuild is required — just update the value and upgrade:
helm upgrade colony ./helm/colony \
--namespace colony-app \
--reuse-values \
--set frontend.env.apiUrl="http://192.168.1.206:30800/api/v1"
Update both at once
helm upgrade colony ./helm/colony \
--namespace colony-app \
--reuse-values \
--set backend.image.tag=1.1.0 \
--set frontend.image.tag=1.1.0
Note on
--reuse-values: This flag carries forward all values from the previoushelm installorhelm upgrade. You only need to specify the values you want to change.
Rolling Back
If an upgrade introduces a problem, roll back to the previous release:
# See the release history
helm history colony -n colony-app
# Roll back to the previous revision
helm rollback colony -n colony-app
# Roll back to a specific revision number
helm rollback colony 2 -n colony-app
Seeding Sample Data (optional)
The backend automatically creates the admin user on first startup via the
DEFAULT_ADMIN_* environment variables. Seeding is only needed if you want
to pre-populate sample payment methods, cycles, and expenses.
helm upgrade colony ./helm/colony \
--namespace colony-app \
--reuse-values \
--set seeder.enabled=true \
--set-file seeder.seedDataContent=./seed_data.yaml
The seeder runs once as a Kubernetes Job (Helm post-install hook) and self-deletes after it succeeds.
Uninstalling
helm uninstall colony -n colony-app
# Optionally delete the namespace (removes all remaining resources)
kubectl delete namespace colony-app
The PostgreSQL PersistentVolumeClaim is not deleted by
helm uninstallbecause Helm does not delete PVCs by default. Delete it manually if you want to wipe the data:
Helm Chart Reference
The chart lives at helm/colony/. Key files:
| File | Purpose |
|---|---|
Chart.yaml |
Chart metadata and version |
values.yaml |
All configurable defaults |
templates/secrets.yaml |
K8s Secret: DB password, JWT key |
templates/configmap-backend.yaml |
Non-sensitive backend env vars |
templates/postgres-*.yaml |
PostgreSQL Deployment, Service, PVC |
templates/backend-*.yaml |
FastAPI Deployment + NodePort Service |
templates/frontend-*.yaml |
Next.js Deployment + NodePort Service |
templates/ingress.yaml |
Frontend + backend Ingresses (gated by ingress.enabled) |
templates/seeder-job.yaml |
Optional seed data Job (post-install) |
Key values
| Value | Default | Description |
|---|---|---|
image.registry |
192.168.1.206:30002 |
Harbor registry address |
backend.image.tag |
latest |
Backend image tag |
frontend.image.tag |
latest |
Frontend image tag |
frontend.env.apiUrl |
http://192.168.1.206:30800/api/v1 |
Backend URL injected at runtime |
backend.service.nodePort |
30800 |
Backend external port |
frontend.service.nodePort |
30080 |
Frontend external port |
backend.env.secretKey |
(must set) | JWT signing secret |
backend.env.allowedHosts |
["http://192.168.1.206:30080"] |
CORS origins |
postgresql.password |
(must set) | Database password |
postgresql.persistence.size |
5Gi |
PVC size for Postgres |
ingress.enabled |
false |
Create Ingress resources for frontend + backend |
ingress.frontend.host |
"" |
Frontend hostname (e.g. colony.dev.lan) |
ingress.backend.host |
"" |
Backend hostname (e.g. api.colony.dev.lan) |
seeder.enabled |
false |
Enable sample data seeder |
Troubleshooting & FAQ
Backend pod crashes on startup with psycopg2.OperationalError
Symptom: The backend pod enters a crash loop immediately after the init container exits. The logs show a connection error like:
psycopg2.OperationalError: connection to server on socket
"@##@colony-postgresql/.s.PGSQL.5432" failed: Connection refused
Cause: The postgresql.password (or postgresql.username) in your
values.yaml contains URL-special characters — most commonly @ or #.
These break the DATABASE_URL connection string:
@is the delimiter between credentials and hostname in a URL (user:pass@host), so a@in the password confuses the parser.#marks the start of a URL fragment, causing everything after it to be ignored.
The Helm chart URL-encodes both fields with urlquery to prevent this, but
if you are using an older revision of the chart (before this fix) you will
hit this issue.
Fix: Upgrade to the latest chart revision and restart the backend:
helm upgrade colony ./helm/colony --namespace colony-app -f my-values.yaml
kubectl rollout restart deployment colony-backend -n colony-app
Prevention: Any password that works in a URL is fine. Characters to
avoid unless you are on the fixed chart version: @, #, %, ?, &.
Login returns 401 "Incorrect username or password" on first deploy
Symptom: The app loads, login page appears, but every login attempt with the admin credentials returns 401.
Cause: The admin user was never created. In the original Helm
deployment the seeder was disabled by default, and the backend's startup
only created tables — it did not bootstrap the admin user. A fresh
database therefore has all tables but no rows in users.
Fix (chart v0.1.0 and later): As of the fix shipped in this chart,
_bootstrap_admin() runs inside the FastAPI lifespan on every startup.
It creates the default household and admin user automatically if they do
not already exist. Simply deploying the updated image is enough.
Fix (manual, for existing deployments): If you are on an older image
and the tables are already created but empty, exec into the running backend
pod and run the seeder in auth_only mode — this creates only the admin
user and household without touching any sample data:
kubectl exec -n colony-app deployment/colony-backend -- \
sh -c "echo '{}' > /tmp/seed.yaml && \
SEED_FILE=/tmp/seed.yaml \
SEED_MODE=auth_only \
uv run python scripts/seed_db.py"
Prevention: Build and deploy the updated backend image which includes
the _bootstrap_admin() lifespan call in app/main.py.
Login succeeds (backend returns 200) but the app redirects back to login
Symptom: You can see a POST /api/v1/auth/login 200 OK in the backend
logs, but the browser immediately returns to the login page.
Cause: The auth cookie is set with Secure: true when
COOKIE_SECURE=true, which tells browsers to only transmit the cookie over
HTTPS. When the app is served over plain HTTP (as in a homelab without TLS),
the browser silently discards the cookie — so every page load sees no token
and redirects back to /login.
Fix: Ensure COOKIE_SECURE=false is set in the frontend container (the
default in values.yaml). Then rebuild and deploy the frontend image with
the updated actions/auth.action.ts that reads COOKIE_SECURE instead of
NODE_ENV:
docker build -t 192.168.1.206:30002/colony/colony-frontend:1.0.1 ./frontend
docker push 192.168.1.206:30002/colony/colony-frontend:1.0.1
helm upgrade colony ./helm/colony \
--namespace colony-app \
-f my-values.yaml \
--set frontend.image.tag=1.0.1
Note: If you ever add HTTPS to the cluster (e.g. via a TLS ingress),
set frontend.env.cookieSecure: "true" in your values file so session
cookies are properly protected.