Jump to content

Proposal: GitOps Configuration Management for Canasta

From notes

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 time
  • config/admin-password_* — generated from vars at deploy time
  • docker-compose.yml — managed by Canasta CLI (Compose only)
  • config/Caddyfile — auto-generated from wikis.yaml on restart
  • images/ — uploaded files (covered by canasta 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 to main. 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 the gh CLI.

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:

  1. Create the wiki farm as usual:
canasta create -i mywiki -w main -n wiki.example.com
  1. 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:

  1. Initializes a git repo in the installation directory
  2. Sets up .gitignore and .gitattributes
  3. Initializes git-crypt and exports the symmetric key (the user is instructed to store it securely)
  4. Creates env.template by extracting the current .env and 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 a custom-keys.yaml file in the installation directory beforehand, its keys are also converted to placeholders and their values are added to vars.yaml
  5. Creates hosts.yaml with this server as the first entry (using the provided --host name and --role)
  6. Creates hosts/{name}/vars.yaml with the actual secret values extracted from .env and admin password files
  7. Converts user-installed extensions and skins to git submodules
  8. 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:

  1. Clones the repo into the installation directory (merging with the existing files)
  2. Unlocks git-crypt (the user is prompted for the key file path)
  3. Adds this host to hosts.yaml (since --host production does not yet exist in the file)
  4. Creates hosts/production/vars.yaml by extracting the current passwords, domain, and other host-specific values from the installation's .env and admin password files — including any keys listed in custom-keys.yaml
  5. Overlays shared configuration (settings files, wikis.yaml, Caddy files) from the repo onto the installation
  6. Updates submodules to install the correct extension and skin versions
  7. Pushes the new host entry and vars back to the repo — directly to main or via a PR, depending on the pull_requests setting

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:

  1. Remove the host entry from hosts.yaml
  2. Optionally remove the hosts/{name}/ directory (or keep it for historical reference)
  3. canasta gitops push -i mywiki -m "Remove production-2 server"
  4. 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

  1. Edit a settings file
  2. Test the change
  3. canasta gitops push -i mywiki
  4. If pull requests are enabled: review and merge the PR
  5. If there are sink hosts: canasta gitops pull -i mywiki on each one

Updating an extension

  1. Update the submodule:
cd extensions/MyExtension
git fetch && git checkout v2.0.0
cd ../..
  1. Test, run canasta maintenance update if schema changes are expected
  2. canasta gitops push -i mywiki -m "Update MyExtension to v2.0.0"
  3. If pull requests are enabled: review and merge the PR
  4. If there are sink hosts: canasta gitops pull -i mywiki on each one, then run maintenance update if needed

Adding a new extension

  1. Add it as a submodule:
git submodule add https://github.com/org/NewExtension.git extensions/NewExtension
  1. Add the wfLoadExtension call to the appropriate settings file
  2. Test the change
  3. canasta gitops push -i mywiki
  4. If pull requests are enabled: review and merge the PR
  5. If there are sink hosts: canasta gitops pull -i mywiki on 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:

  1. 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 push before pulling, or discard them if they are not needed.
  2. git pull from the remote
  3. git submodule update --init to update extensions and skins
  4. Identify the current host from hosts.yaml (matched by hostname)
  5. Render env.template with the host's vars.yaml to produce .env
  6. Write config/admin-password_* files from the vars
  7. Compare the previous and new state to report what changed
  8. 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 update to apply database schema changes.

How push works

canasta gitops push performs the following steps:

  1. Verify this host's role is source or both
  2. Stage all changes to tracked files (settings, wikis.yaml, Caddy files, submodule pointers, env.template, custom/, orchestrator overrides)
  3. Commit with a message (provided via -m or prompted)

The remaining steps depend on the pull_requests setting in hosts.yaml:

When pull_requests: false (default):

  1. Push the commit directly to main

When pull_requests: true:

  1. Create a new branch
  2. Push the branch to the remote
  3. 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: true is set in hosts.yaml. Used by canasta gitops push and canasta gitops init --repo to create pull requests. Not needed for single-server setups or when pushing directly to main.

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.

  1. canasta gitops init runs git-crypt init and exports the key to a file
  2. The key file is copied securely to each server (e.g., via scp or a secrets manager)
  3. On each server, canasta gitops init (or a setup step) runs git-crypt unlock /path/to/keyfile
  4. 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:

  1. canasta gitops init runs git-crypt init
  2. For each authorized user or server: git-crypt add-gpg-user GPG_KEY_ID
  3. On each server, git-crypt unlock decrypts 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.