Proposal: GitOps Configuration Management for Canasta
Problem
Canasta installations are configured through a set of files (PHP settings, wikis.yaml, Caddyfile customizations, .env, extensions, skins) that live on the server with no version control. This creates several pain points:
- No change history — it's hard to know what changed, when, and why
- No rollback — recovering from a bad config change means restoring from a full backup
- Config drift — servers diverge over time
- Manual deployment — config changes are applied by SSH-ing into each server and editing files
- No review process — changes go live without peer review
Approach
This proposal introduces a GitOps workflow for Canasta: the configuration files that define a wiki installation are stored in a private Git repository and managed through new canasta gitops CLI commands.
In the simplest case, a single server uses gitops purely for version-controlled configuration backup — every change is committed and pushed to a remote repository, providing change history, encrypted secret storage, and easy rollback. No pull requests, no multi-server coordination — just canasta gitops push after making changes.
The same architecture extends to multi-server deployments. Changes are made on a source server (staging or dev), tested, pushed to the repo with an optional pull request for peer review, and then pulled onto production servers. A pull_requests setting in hosts.yaml controls whether push commits directly to main or creates a branch with a pull request.
Although secrets are encrypted, a private repository is recommended as defense in depth — configuration details such as enabled extensions, server hostnames, and directory structure should not be publicly exposed. Secrets such as database passwords are stored in the same repo but encrypted transparently using git-crypt, so they are never visible in plain text on GitHub. User-installed extensions and skins are tracked as Git submodules, pinning each to a specific version for reproducibility and easy rollback.
Scope
Each Git repository manages one logical Canasta wiki farm. The farm is treated as a whole — individual wiki configurations (per-wiki settings files, wiki entries in wikis.yaml) are managed alongside the farm-level configuration. As such, the canasta gitops commands operate on a Canasta instance and do not take wiki-specific arguments.
A single-server deployment is fully supported — the repository simply has one host entry. This provides version-controlled configuration with encrypted secrets and change history, even without multi-server coordination.
For multi-server deployments, multiple servers in various roles — staging, dev, production — are different deployments of the same wiki farm, sharing identical configuration except for host-specific values like domain names and passwords.
If you operate several independent wiki farms, each gets its own config repository.
Goals
- Version-control all configuration files for a Canasta installation
- Manage secrets (database passwords,
$wgSecretKey, admin passwords) safely in the repo - Work for a single server (version-controlled backup) and scale to multi-server deployments
- Optionally allow peer review of config changes via pull requests
- Keep the workflow simple enough for small teams
Architecture
Git repository structure
A single private Git repository holds the configuration for all servers. A shared env.template defines the .env structure, with placeholders for values that differ per host. Each host has a vars.yaml containing its specific values (domain, passwords, etc.). The .env used by Canasta is generated from the template and vars at deploy time.
canasta-config/
├── .gitattributes # git-crypt filter rules
├── .gitignore
├── custom-keys.yaml # user-defined host-specific .env keys
├── env.template # shared .env template with {{placeholders}}
├── config/
│ ├── wikis.yaml
│ ├── Caddyfile.site
│ ├── Caddyfile.global
│ └── settings/
│ ├── global/
│ │ └── *.php
│ └── wikis/
│ └── {wiki-id}/
│ └── *.php
├── custom/ # user files (Dockerfiles, extra configs, scripts)
│ └── elasticsearch/
│ └── Dockerfile
├── extensions/ # git submodules
│ ├── MyExtension/
│ └── AnotherExtension/
├── skins/ # git submodules
│ └── MyCustomSkin/
├── public_assets/
│ └── {wiki-id}/
│ └── logo.png
├── docker-compose.override.yml # Compose only, if used
├── hosts/ # per-host variables, encrypted by git-crypt
│ └── myserver/ # one directory per host
│ └── vars.yaml
└── hosts.yaml # host inventory and settings
The custom/ directory is a general-purpose location for any additional files needed by the deployment — custom Dockerfiles, orchestrator configs, helper scripts, etc. For example, a docker-compose.override.yml that builds a custom Elasticsearch image can reference build: ./custom/elasticsearch/.
In a multi-server deployment, the hosts/ directory contains one subdirectory per server (e.g., hosts/staging/, hosts/production/).
This structure is orchestrator-agnostic — it works with both Docker Compose and Kubernetes. The docker-compose.override.yml file is Compose-specific and would not be present in a Kubernetes deployment.
env.template and per-host vars
The env.template is the single source of truth for what configuration exists. On a single server it is the template from which .env is generated. In multi-server deployments it is shared across all servers — any config change goes here and is tested before reaching production.
Example env.template:
MW_SITE_SERVER=https://{{domain}}
MW_SITE_FQDN={{domain}}
MYSQL_PASSWORD={{mysql_password}}
WIKI_DB_PASSWORD={{wiki_db_password}}
MW_SECRET_KEY={{wg_secret_key}}
HTTPS_PORT={{https_port}}
Each host's vars.yaml supplies the host-specific values:
Single-server example:
# hosts/myserver/vars.yaml domain: wiki.example.com https_port: 443 mysql_password: "my-db-pass" wiki_db_password: "my-wiki-pass" wg_secret_key: "abc123..." admin_password_main: "my-admin-pass"
In multi-server deployments, each server has its own vars.yaml with different values:
# hosts/staging/vars.yaml domain: staging.example.com https_port: 443 mysql_password: "staging-db-pass" wiki_db_password: "staging-wiki-pass" wg_secret_key: "abc123..." admin_password_main: "staging-admin-pass"
# hosts/production/vars.yaml domain: wiki.example.com https_port: 443 mysql_password: "prod-db-pass" wiki_db_password: "prod-wiki-pass" wg_secret_key: "def456..." admin_password_main: "prod-admin-pass"
Wiki admins may reference their own secrets in PHP settings files using environment variables (e.g., getenv('MY_API_KEY')). To manage these through gitops, list the key names in a custom-keys.yaml file in the repo root, set the values in .env (e.g., via canasta config set MY_API_KEY=...), and the gitops tooling handles the rest — canasta gitops init converts them to placeholders in env.template and extracts their values into vars.yaml, just like the built-in keys.
Example custom-keys.yaml:
# Additional .env keys that vary per host and should become
# {{placeholders}} in env.template. Values are stored in
# each host's vars.yaml (encrypted by git-crypt).
keys:
- MY_API_KEY
- SMTP_PASSWORD
- EXTERNAL_DB_PASSWORD
At deploy time, canasta gitops pull renders env.template + vars.yaml into the .env file and writes config/admin-password_* files from the corresponding vars. The generated .env and admin password files are gitignored.
What is NOT tracked (gitignored)
.env— generated from template + vars at deploy timeconfig/admin-password_*— generated from vars at deploy timedocker-compose.yml— managed by Canasta CLI (Compose only)config/Caddyfile— auto-generated from wikis.yaml on restartimages/— uploaded files (covered bycanasta backup)
This process manages configuration only — it does not perform database dumps and therefore does not save wiki content. Use canasta backup separately to back up databases and uploaded files.
Extensions and skins as submodules
User-installed extensions and skins (those in the installation's extensions/ and skins/ directories) are added as Git submodules, pinned to a specific commit. This provides:
- Reproducibility — exact version deployed is recorded in the repo
- Auditability — extension upgrades show up as commits
- Easy rollback — revert the submodule pointer to go back
Private or vendored extensions that aren't in a public repo can be committed directly instead of as submodules.
Host inventory
A hosts.yaml file defines the deployment targets and repository-wide settings.
The top-level pull_requests setting controls how canasta gitops push behaves:
pull_requests: false(default) — push commits directly tomain. Suitable for single-server setups or small teams that do not need formal review.pull_requests: true— push creates a new branch and opens a pull request for review before merging. Requires theghCLI.
Each host has a role that controls the direction of git flow:
- source — config changes originate here (e.g., staging, dev). Can push to the repo.
- sink — receives config from the repo (e.g., production). Only pulls.
- both — can both push and pull (e.g., a single server, or a server that serves as both staging and production).
When there is only one host, the role defaults to both and can be omitted. In multi-server deployments, roles act as a safety guardrail — a sink host will refuse to push, preventing accidental commits of local drift on production servers.
Single-server example:
canasta_id: mywiki
hosts:
myserver:
hostname: wiki.example.com
Multi-server example:
canasta_id: mywiki
pull_requests: true
hosts:
staging:
hostname: staging.example.com
role: source
production:
hostname: wiki.example.com
role: sink
production-2:
hostname: wiki2.example.com
role: sink
dev:
hostname: dev.example.com
role: source
Each host's per-host variables are stored in hosts/{name}/vars.yaml, derived from the host name in hosts.yaml.
Workflow
Initial setup: creating a new managed wiki farm
The first server is set up with canasta create followed by canasta gitops init:
- Create the wiki farm as usual:
canasta create -i mywiki -w main -n wiki.example.com
- Initialize gitops in the installation directory:
canasta gitops init -i mywiki --host myserver
The --role flag defaults to both for a single-server setup. For multi-server deployments, specify --role source on the first server.
canasta gitops init (with no --repo flag) bootstraps a new gitops repository from the existing installation. It performs the following steps:
- Initializes a git repo in the installation directory
- Sets up
.gitignoreand.gitattributes - Initializes git-crypt and exports the symmetric key (the user is instructed to store it securely)
- Creates
env.templateby extracting the current.envand replacing host-specific values with{{placeholders}}. A built-in list of known secret keys (e.g.,MYSQL_PASSWORD,WIKI_DB_PASSWORD,MW_SECRET_KEY) and host-specific keys (e.g.,MW_SITE_SERVER,MW_SITE_FQDN,HTTPS_PORT) determines which values become placeholders and which remain literal. If the user has created acustom-keys.yamlfile in the installation directory beforehand, its keys are also converted to placeholders and their values are added tovars.yaml - Creates
hosts.yamlwith this server as the first entry (using the provided--hostname and--role) - Creates
hosts/{name}/vars.yamlwith the actual secret values extracted from.envand admin password files - Converts user-installed extensions and skins to git submodules
- Makes an initial commit
The user then adds a remote and pushes:
git remote add origin git@github.com:yourorg/mywiki-config.git git push -u origin main
Adding a server
To add a new server (e.g., a production copy) to an existing managed wiki farm:
Step 1: Back up the existing wiki farm
On the existing server, create a backup that includes all wiki databases and uploaded files:
canasta backup create -i mywiki
Step 2: Create and restore the installation on the new server
canasta create -i mywiki -w main -n wiki.example.com canasta backup restore -i mywiki -b /path/to/backup.tar.gz
This creates the installation with its own database passwords and admin credentials, then restores all wiki databases and uploaded files from the backup. This ensures the new server has all wikis and their content, not just the first wiki. The restore overwrites the database contents but does not change .env, so the database passwords in .env (set by canasta create) remain valid.
Step 3: Set any custom environment variables
If the repo contains a custom-keys.yaml listing additional host-specific keys (e.g., MY_API_KEY), set their values on the new server before running init:
canasta config set MY_API_KEY=... -i mywiki
This ensures init can extract all required values into vars.yaml. If any custom keys are missing from .env, init will report an error listing the missing keys.
Step 4: Join the gitops repo
canasta gitops init -i mywiki --repo git@github.com:yourorg/mywiki-config.git --host production
canasta gitops init with --repo joins an existing gitops repository rather than creating a new one. It performs the following steps:
- Clones the repo into the installation directory (merging with the existing files)
- Unlocks git-crypt (the user is prompted for the key file path)
- Adds this host to
hosts.yaml(since--host productiondoes not yet exist in the file) - Creates
hosts/production/vars.yamlby extracting the current passwords, domain, and other host-specific values from the installation's.envand admin password files — including any keys listed incustom-keys.yaml - Overlays shared configuration (settings files, wikis.yaml, Caddy files) from the repo onto the installation
- Updates submodules to install the correct extension and skin versions
- Pushes the new host entry and vars back to the repo — directly to
mainor via a PR, depending on thepull_requestssetting
This approach ensures the database passwords in vars.yaml match the ones used to initialize the database during canasta create.
If pull requests are enabled, canasta gitops pull -i mywiki works normally on the new server after the PR is merged.
Removing a server
To remove a server from gitops management:
On a source host:
- Remove the host entry from
hosts.yaml - Optionally remove the
hosts/{name}/directory (or keep it for historical reference) canasta gitops push -i mywiki -m "Remove production-2 server"- If pull requests are enabled: review and merge the PR
The Canasta installation on the removed server continues to function — it simply is no longer managed through gitops. No action is needed on the removed server itself unless you want to clean up the git repo files from the installation directory.
Day-to-day: change a setting
- Edit a settings file
- Test the change
canasta gitops push -i mywiki- If pull requests are enabled: review and merge the PR
- If there are sink hosts:
canasta gitops pull -i mywikion each one
Updating an extension
- Update the submodule:
cd extensions/MyExtension git fetch && git checkout v2.0.0 cd ../..
- Test, run
canasta maintenance updateif schema changes are expected canasta gitops push -i mywiki -m "Update MyExtension to v2.0.0"- If pull requests are enabled: review and merge the PR
- If there are sink hosts:
canasta gitops pull -i mywikion each one, then run maintenance update if needed
Adding a new extension
- Add it as a submodule:
git submodule add https://github.com/org/NewExtension.git extensions/NewExtension
- Add the
wfLoadExtensioncall to the appropriate settings file - Test the change
canasta gitops push -i mywiki- If pull requests are enabled: review and merge the PR
- If there are sink hosts:
canasta gitops pull -i mywikion each one, then restart
What needs a restart?
| Change | Restart needed? |
|---|---|
| Settings PHP files | No — takes effect on next request |
| wikis.yaml | Yes — Caddyfile must be regenerated |
| Caddyfile.site / Caddyfile.global | Yes — Caddy reloads on restart |
| docker-compose.override.yml | Yes |
| .env changes | Yes |
| New extension/skin | Yes — need canasta maintenance update
|
| Extension version update (no schema change) | No |
| Extension version update (with schema change) | Yes — need canasta maintenance update
|
Promoting changes across servers
With pull requests disabled (single-server or small team):
[server] --edit & test--> canasta gitops push --commit--> [git repo]
With pull requests enabled (multi-server with review):
[source host] --edit & test--> canasta gitops push --PR--> [review & merge] --pull--> [sink hosts]
In multi-server deployments, sink hosts pull from the repo using a read-only deploy key. Changes always flow through a source host first. Direct edits on sink hosts are discouraged — treat their config as read-only.
Implementation
CLI subcommands
A new canasta gitops subcommand group will be added to the CLI:
| Command | Purpose |
|---|---|
canasta gitops init -i mywiki |
Initialize gitops for an installation. Without --repo: bootstrap a new repo (set up git, git-crypt, env.template, hosts.yaml, submodules). With --repo: join an existing repo (clone, unlock git-crypt, render config). Requires --host name; --role defaults to both when bootstrapping.
|
canasta gitops pull -i mywiki |
Pull latest config from the repo, update submodules, render .env and admin password files from template + vars, report what changed and whether a restart is needed
|
canasta gitops push -i mywiki |
Stage all tracked changes, commit, and push to the repo. Pushes directly to main by default; creates a branch and opens a PR when pull_requests: true is set. Source/both hosts only.
|
canasta gitops diff -i mywiki |
Show what would change on pull (dry run) |
canasta gitops status -i mywiki |
Show current state: uncommitted changes, ahead/behind remote, host role |
As with other Canasta commands, the -i flag is optional when running from within the installation directory.
How pull works
canasta gitops pull performs the following steps:
- Check for uncommitted local changes. If any are found, refuse to proceed and list the modified files. This prevents silent conflicts and enforces the discipline that all changes should flow through the gitops process. The user should commit and push their changes with
canasta gitops pushbefore pulling, or discard them if they are not needed. git pullfrom the remotegit submodule update --initto update extensions and skins- Identify the current host from
hosts.yaml(matched by hostname) - Render
env.templatewith the host'svars.yamlto produce.env - Write
config/admin-password_*files from the vars - Compare the previous and new state to report what changed
- Advise whether a restart or maintenance update is needed. If extension or skin submodule versions have changed, warn that it may be necessary to run
canasta maintenance updateto apply database schema changes.
How push works
canasta gitops push performs the following steps:
- Verify this host's role is
sourceorboth - Stage all changes to tracked files (settings, wikis.yaml, Caddy files, submodule pointers, env.template, custom/, orchestrator overrides)
- Commit with a message (provided via
-mor prompted)
The remaining steps depend on the pull_requests setting in hosts.yaml:
When pull_requests: false (default):
- Push the commit directly to
main
When pull_requests: true:
- Create a new branch
- Push the branch to the remote
- Open a pull request against
main
Changes are then merged to main through the normal PR review process. As a best practice, the repository should be configured to delete branches automatically on merge.
Template syntax
The env.template file uses simple {{key}} placeholder syntax. At render time, each {{key}} is replaced with the corresponding value from the host's vars.yaml. No external template engine is used — placeholder substitution is implemented directly in the CLI. If a placeholder in the template has no matching key in vars.yaml, the render step fails with an error listing the missing keys.
Prerequisites
The CLI requires the following tools to be installed on the system:
- git-crypt — for secret management (transparent encryption of host vars files)
- gh — the GitHub CLI, only required when
pull_requests: trueis set inhosts.yaml. Used bycanasta gitops pushandcanasta gitops init --repoto create pull requests. Not needed for single-server setups or when pushing directly tomain.
The canasta gitops init command checks for required tools and provides installation instructions if any are missing.
Secret management with git-crypt
git-crypt provides transparent encryption of files in a Git repository. Files matching patterns in .gitattributes are encrypted on push and decrypted on pull — no manual encrypt/decrypt step needed.
.gitattributes:
hosts/** filter=git-crypt diff=git-crypt
This means:
- On servers with the git-crypt key: vars files are readable as plain text
- On GitHub / for users without the key: vars files are encrypted blobs
Installing git-crypt
git-crypt is available through standard package managers:
- macOS:
brew install git-crypt - Ubuntu/Debian:
sudo apt install git-crypt - RHEL/Fedora:
sudo dnf install git-crypt
The canasta gitops init command checks for git-crypt and provides installation instructions if it is not found.
Key management
git-crypt supports two modes of key management:
Option 1: Symmetric key (recommended for small teams)
A single key file is generated during canasta gitops init and must be securely distributed to each server and team member who needs access to secrets.
canasta gitops initrunsgit-crypt initand exports the key to a file- The key file is copied securely to each server (e.g., via
scpor a secrets manager) - On each server,
canasta gitops init(or a setup step) runsgit-crypt unlock /path/to/keyfile - After unlocking, the key file can be stored in a secure location outside the repo (e.g.,
/etc/canasta/gitops-key)
The key file must be kept safe — anyone with it can decrypt all secrets in the repo. It should never be committed to the repo itself.
Option 2: GPG-based (better for larger teams or when individual access revocation is needed)
Each team member and server has its own GPG key. Access is granted per-identity:
canasta gitops initrunsgit-crypt init- For each authorized user or server:
git-crypt add-gpg-user GPG_KEY_ID - On each server,
git-crypt unlockdecrypts automatically using the local GPG key — no shared key file needed
Revoking access requires re-keying: remove the user's GPG key, run git-crypt init again with a new key, and re-add the remaining authorized users. This is more complex but avoids sharing a single secret.
Which mode to use
| Concern | Symmetric key | GPG-based |
|---|---|---|
| Setup complexity | Low — one key file | Higher — GPG keys for every user/server |
| Key distribution | Must securely copy the key file | No shared secret; each identity unlocks independently |
| Revoking access | Change the key and redistribute | Remove the GPG identity and re-key |
| Best for | Small teams, few servers | Larger teams, frequent access changes |
The initial implementation will support symmetric key mode, with GPG support as a future enhancement.