Help:User journeys/GitOps with GPG keys on Docker Compose

From Canasta Wiki

Journey info  ·  Platform: Docker Compose / GitHub  ·  Time: ~45 minutes

This journey puts a Docker Compose instance's configuration under GitOps, with the repository's secrets unlocked by GPG keys instead of a shared git-crypt key file. Each person who manages the wiki unlocks the repository with their own GPG key, and the server gets a key of its own, so it can unlock its checkout without anyone forwarding a GPG agent to it. No secret file is passed between people, and a person's access is added or withdrawn by changing the list of recipients. It is a worked example, not the only way; for the concepts, see Help:GitOps, especially its Key management section, and Help:Secrets.

The examples use the instance ID mywiki, the wiki hostname wiki.example.com, the host name prod in the GitOps repository, and the repository git@github.com:example-org/wiki.example.com.git. Substitute your own.

Prerequisites

  • A host running a Docker Compose Canasta instance that is working and configured the way you want it (see Help:Installation). GitOps takes the current configuration as its starting point.
  • A shell on the host as the user that runs canasta, with sudo for installing packages.
  • A GitHub account that can create a private repository in the organization or account that will own it. Any git host works; the deploy key steps differ slightly.
  • A workstation (Linux or macOS) for your personal GPG key, and a password manager for its backup.
  • Each other person who will unlock the repository creates a GPG key the same way and sends you the public key.

Phase 1: Create your GPG key

Do this on your workstation. The private key never leaves it, apart from the backup.

1. Install GnuPG

On macOS:

brew install gnupg pinentry-mac

pinentry-mac shows the passphrase prompt as a macOS dialog and can keep the passphrase in the Keychain. Point GnuPG at it (run gpg --list-keys once first if ~/.gnupg does not exist yet):

echo "pinentry-program $(brew --prefix)/bin/pinentry-mac" >> ~/.gnupg/gpg-agent.conf

On Debian or Ubuntu, sudo apt install gnupg.

2. Generate the key

gpg --full-generate-key

Accept the default kind of key (ECC, sign and encrypt) and curve (Curve 25519). For the expiration, enter 2y; an expiry limits the damage of a lost key, and you can extend it before it runs out. Enter your name, your email address and a strong passphrase.

Check the expiry dates. Both the primary key and its encryption subkey ([E]) should show the date you intended:

gpg --list-keys --keyid-format long you@example.com

The 40-character string under pub is the key's fingerprint. If a date is wrong, change both:

gpg --quick-set-expire <fingerprint> 2y
gpg --quick-set-expire <fingerprint> 2y '*'

3. Back up the key

Export the private key, store the file in your password manager, and delete it:

gpg --armor --export-secret-keys <fingerprint> > ~/gpg-private.asc
rm ~/gpg-private.asc   # after storing it

Also store the revocation certificate GnuPG created in ~/.gnupg/openpgp-revocs.d/<fingerprint>.rev. It cancels the key if the key is ever compromised. In a macOS file chooser, Cmd+Shift+. shows hidden folders such as ~/.gnupg.

Repeat the backup whenever you change the key's expiry.

4. Export the public key

gpg --armor --export <fingerprint> > you.asc

The public key is not secret. Copy it to the host:

scp you.asc wiki.example.com:

Phase 2: Prepare the host

1. Install GnuPG and git-crypt

sudo apt install gnupg
canasta install git-crypt

2. Create the server's key

The server's key lets the host unlock its own checkout, for example after a restore, without a person forwarding a GPG agent to it. It has no passphrase, because GitOps commands run unattended. This adds little exposure: the instance's .env on the same host already holds every secret in plain text.

gpg --batch --passphrase '' --quick-generate-key "wiki.example.com server (gitops) <admin@example.com>" ed25519 cert,sign never
SERVER_FPR=$(gpg --list-secret-keys --with-colons "wiki.example.com server" | awk -F: '/^fpr/{print $10; exit}')
gpg --batch --passphrase '' --quick-add-key "$SERVER_FPR" cv25519 encr never
echo "$SERVER_FPR"

Note the fingerprint for Phase 4.

3. Import and trust your public key

git-crypt add-gpg-user only accepts a key that GnuPG trusts, so import your key and mark it trusted:

gpg --import ~/you.asc
echo "<fingerprint>:6:" | gpg --import-ownertrust
rm ~/you.asc

Check that both keys are listed, each with [ultimate]:

gpg --list-keys --keyid-format long

Phase 3: Create the repository and deploy key

1. Create an empty private repository

Create example-org/wiki.example.com as a private repository with no README, license or .gitignore. GitOps refuses a repository that already has commits. With the GitHub CLI:

gh repo create example-org/wiki.example.com --private

2. Add a deploy key for the host

On the host, follow Set up a deploy key: generate ~/.ssh/gitops_deploy with no passphrase, add the public half to the repository as a deploy key with write access, and add the Host github.com block to ~/.ssh/config. Then confirm:

ssh -T git@github.com

You should see a greeting naming the repository.

Phase 4: Initialize GitOps

1. Remove stray files

GitOps commits the instance's config/ directory. Remove or move out any leftover copies, such as Settings.php.bak, so they are not committed:

find ~/mywiki -path ~/mywiki/images -prune -o \( -name '*.bak' -o -name '*.orig' -o -name '*~' \) -print

2. Run init with your key as the recipient

canasta gitops init -i mywiki -n prod \
  --repo git@github.com:example-org/wiki.example.com.git \
  --git-crypt-gpg-user <fingerprint> \
  --git-user-name "Your Name" --git-user-email you@example.com

You should see:

GitOps initialized for 'mywiki'. git-crypt key encrypted to GPG recipient <fingerprint>

The --git-user-name and --git-user-email options set the global git identity of the host's user, so every GitOps commit made on the host carries that name. See Production considerations if several people will commit from the same host.

3. Add the server's key

cd ~/mywiki
git-crypt add-gpg-user "$SERVER_FPR"
canasta gitops push -i mywiki

git-crypt add-gpg-user commits an encrypted copy of the repository key for that recipient under .git-crypt/keys/, and canasta gitops push pushes the commit.

4. Add other people

For each person who will unlock the repository, copy their public key to the host, then:

gpg --import ~/their.asc
echo "<their-fingerprint>:6:" | gpg --import-ownertrust
cd ~/mywiki && git-crypt add-gpg-user <their-fingerprint>
canasta gitops push -i mywiki

Give them access to the repository on GitHub as well.

Phase 5: Validation

1. Status is clean

canasta gitops status -i mywiki

You should see No changes. and Up to date with remote.

2. Each recipient is listed

ls ~/mywiki/.git-crypt/keys/default/0/

There is one .gpg file per recipient, named by fingerprint.

3. Secrets are encrypted on the remote

The host's vars files hold the real secret values. On GitHub they must be ciphertext:

cd ~/mywiki && git fetch -q && git show origin/main:hosts/prod/vars.yaml | head -c 10 | od -c | head -1

The output begins with \0 G I T C R Y P T. In env.template, which is not encrypted, every secret appears only as a placeholder such as {{mw_secret_key}}:

grep -E 'PASSWORD|SECRET|KEY' ~/mywiki/env.template

4. Unlock a clone with your own key

On your workstation, with git-crypt installed (brew install git-crypt on macOS, sudo apt install git-crypt on Debian or Ubuntu):

git clone git@github.com:example-org/wiki.example.com.git
cd wiki.example.com
git-crypt unlock
head -3 hosts/prod/vars.yaml

GnuPG asks for your passphrase, and the vars file is readable afterward. A recipient who is not on the list gets an error from git-crypt unlock instead.

Cleanup / teardown

To stop managing the instance through GitOps, in this order:

  1. On the host, remove the files GitOps added to the instance directory: .git, .git-crypt, .gitattributes, .gitignore, .gitops-host, .gitops-applied, env.template, wikis.yaml.template and hosts/. The instance keeps running from its .env and config/.
  2. Delete the repository on GitHub. This also removes its deploy key.
  3. On the host, delete ~/.ssh/gitops_deploy and ~/.ssh/gitops_deploy.pub, and remove the Host github.com block from ~/.ssh/config.
  4. On the host, delete the server's key: gpg --delete-secret-and-public-key "$SERVER_FPR".

Your personal key is not tied to the instance; keep it for other repositories, or revoke it with its revocation certificate if you no longer need it.

Production considerations

  • Removing a person's access. git-crypt cannot take a recipient off the list. Anyone who could unlock the repository already has its key, and the old commits stay readable to them. To withdraw access, create a new repository and initialize it again without that person, and rotate the secrets they could read (see Help:Secrets).
  • Commit identity. Commits made on the host use the global git identity set at init. When several people commit from the same host account, each can override it for a session with GIT_AUTHOR_NAME, GIT_AUTHOR_EMAIL, GIT_COMMITTER_NAME and GIT_COMMITTER_EMAIL, or the host's identity can be set to a neutral name such as the server's.
  • Key expiry. git-crypt cannot add a recipient whose key has expired, so a teammate's key must be current when you add them. Extend personal keys before they run out, and back them up again afterward.
  • Repository access. Organization members with access to private repositories can read the repository's unencrypted files, such as config/ and env.template. Only recipients can read the vars files.
  • Making changes. Change configuration with canasta config set and other canasta commands, then canasta gitops push. Apply changes made elsewhere with canasta gitops pull, not git pull, which skips rendering .env from the templates.

Troubleshooting

git-crypt add-gpg-user says the key is not trusted
Mark the key trusted with echo "<fingerprint>:6:" | gpg --import-ownertrust and run the command again. canasta gitops init runs git-crypt add-gpg-user too, so the same applies to the key named in --git-crypt-gpg-user.
gitops init cannot find the recipient
The recipient's public key must be in the host user's GPG keyring before init runs. Import it with gpg --import.
Permission denied (publickey) on the initial push
The deploy key is missing, lacks write access, or is not selected by ~/.ssh/config. Check with ssh -T git@github.com.
GitOps reports the checkout is locked and cannot unlock it
The host's keyring has no private key for any recipient. Add the server's key as a recipient from an unlocked checkout, or forward the GPG agent of a recipient to the host, as described in Key management.