Skip to content

Credentials

Every secret SailEver uses: where it lives in the password manager, what's in it, and where the code expects it. The password manager (1Password) is the source of truth; GitHub Actions secrets and files on the Mac are copies. Values never go into git, chats, issues or logs (workspace AGENTS.md §4).

1. Accounts (logins, all with 2FA)

One 1Password Login item each, named like this:

Item Login for
account-cloudflare dash.cloudflare.com (DNS, R2, Workers, Tunnel, Access)
account-oracle-cloud cloud.oracle.com, tenancy of the beta servers
account-github github.com (repo trahfo/sailever, Actions secrets)
account-garmin-developer Connect IQ store (apps.garmin.com/developer)
account-google-play Play Console
account-sentry Sentry (optional, SENTRY_DSN)

2. The item Develop/SailEver

Every secret the tools read sits in one item, SailEver in the vault Develop, one field per secret, the field named exactly like its environment variable. The tools read the fields themselves once the 1Password app is unlocked (op, the 1Password CLI, with the desktop app integration):

  • Kamal (apps/server, infra/worker): each line of .kamal/secrets calls secret (mise run -q secret NAME), which takes the environment variable when it is set (the GitHub deploy jobs) and otherwise reads op://Develop/SailEver/NAME.
  • OpenTofu (infra/oci): mise tofu plan runs the command with the four tofu fields as environment variables (op-env; op run masks them in the output). It also switches off the AWS SDK's default checksums, which OCI's S3 API rejects with a misleading "SignatureDoesNotMatch: the secret key … could not be found".
  • Anything else: mise op-env NAME,NAME command.

SAILEVER_OP_ITEM (default Develop/SailEver) and SAILEVER_OP_ACCOUNT (default the account ID from op account list) override the item and the account.

Field Contains Used by Made in
CLOUDFLARE_API_TOKEN one token (40 characters; rolled tokens in Cloudflare's newer format are longer, 53 in 2026-10) OpenTofu (infra/oci) Cloudflare → My Profile → API Tokens, custom token sailever-infra-oci (2026-10-18). Permissions: Account · Cloudflare Tunnel Edit, Access: Apps and Policies Edit, Access: Service Tokens Read, Workers Scripts Edit, Workers R2 Storage Edit; Zone sailever.com · DNS Edit, Workers Routes Edit, Zone Read
TF_VAR_state_passphrase one password (≥ 16 characters) OpenTofu: encrypts the state chosen by the owner. Cannot be changed without re-encrypting the state
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY access key (40 characters) + secret (44) OpenTofu: the state bucket sailever-tofu-state OCI → Identity → Users → tofu → Customer secret keys. "AWS" only because the state backend speaks the S3 protocol; nothing is on Amazon
OCI_CONFIG the [SAILEVER] profile for ~/.oci/config (user, fingerprint, tenancy, region, key_file=~/.oci/sailever-tofu.pem) OpenTofu's OCI provider, the OCI CLI OCI → Users → tofu → API keys
OCI_API_KEY the private API signing key of user tofu (a PEM file) written once to ~/.oci/sailever-tofu.pem (§2.1) same API key
RAILS_MASTER_KEY 32 hex characters server deploy (apps/server/config/credentials/production.key) bin/rails credentials:edit --environment production (new key 2026-10-17)
POSTGRES_PASSWORD one password server deploy, Postgres accessory generated by the owner (2026-10-17)
SMTP_USERNAME, SMTP_PASSWORD username (OCID-like) + password server deploy: mail through OCI Email Delivery OCI → Users → sailever-app → SMTP credentials
OCI_S3_ACCESS_KEY_ID, OCI_S3_SECRET_ACCESS_KEY access key (40) + secret (44) server deploy: Active Storage bucket sailever-files, backups OCI → Users → sailever-app → Customer secret keys sailever-app-s3. Not the tofu state key
SENTRY_DSN URL (optional) server deploy Sentry project settings
DEPLOY_SSH_KEY OpenSSH private key (ed25519), also at ~/.ssh/sailever_deploy server and worker deploy (user deploy on all three VMs) ssh-keygen -t ed25519 -f ~/.ssh/sailever_deploy
TUNNEL_SERVICE_TOKEN_ID, TUNNEL_SERVICE_TOKEN_SECRET client id (ends in .access) + client secret server and worker deploy reach SSH through the Cloudflare tunnels (ssh, ssh-db, ssh-worker) Cloudflare → Zero Trust → Access → Service auth, token github-actions-deploy (expires after 1 year: renew)
R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY access key (32) + secret (64) the worker's tile builds: basemap and depth layer (infra/worker), through the Worker deploy Cloudflare → R2 → Manage API tokens, github-actions-tiles, Object Read & Write on sailever-tiles only
BACKUP_AGE_IDENTITY age private key (AGE-SECRET-KEY-1…), plus an offline copy restoring backups (§4) age-keygen (rotated 2026-10-18)

Quick check of the tofu fields (prints lengths only): mise op-env tofu sh -c 'echo ${#AWS_ACCESS_KEY_ID} ${#AWS_SECRET_ACCESS_KEY}' → 40 44. 32 64 means the R2 key was put into the tofu fields. Matching lengths don't prove the key is tofu's: the app's OCI_S3_… key has the same lengths; mise op-env tofu -- aws s3 ls s3://sailever-tofu-state/ --region eu-amsterdam-1 --endpoint-url https://axrihavopcof.compat.objectstorage.eu-amsterdam-1.oraclecloud.com must list oci/.

2.1 Files on the Mac, from the item

Written once per Mac (or after a key rotation), never committed:

mkdir -p ~/.oci && chmod 700 ~/.oci
op read "op://Develop/SailEver/OCI_API_KEY" > ~/.oci/sailever-tofu.pem && chmod 600 ~/.oci/sailever-tofu.pem
grep -q '^\[SAILEVER\]' ~/.oci/config 2>/dev/null || { op read "op://Develop/SailEver/OCI_CONFIG" >> ~/.oci/config; chmod 600 ~/.oci/config; }
op read "op://Develop/SailEver/DEPLOY_SSH_KEY" > ~/.ssh/sailever_deploy && chmod 600 ~/.ssh/sailever_deploy

3. GitHub Actions secrets (repo → Settings → Secrets and variables → Actions)

Copies of the item's fields under the same names: RAILS_MASTER_KEY, POSTGRES_PASSWORD, SMTP_USERNAME, SMTP_PASSWORD, OCI_S3_ACCESS_KEY_ID, OCI_S3_SECRET_ACCESS_KEY, SENTRY_DSN (optional), DEPLOY_SSH_KEY, TUNNEL_SERVICE_TOKEN_ID, TUNNEL_SERVICE_TOKEN_SECRET, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY. The owner sets them from the item (agents never run gh secret); the values go straight from 1Password to GitHub without showing up on screen:

for name in RAILS_MASTER_KEY POSTGRES_PASSWORD SMTP_USERNAME SMTP_PASSWORD OCI_S3_ACCESS_KEY_ID OCI_S3_SECRET_ACCESS_KEY \
            DEPLOY_SSH_KEY TUNNEL_SERVICE_TOKEN_ID TUNNEL_SERVICE_TOKEN_SECRET R2_ACCESS_KEY_ID R2_SECRET_ACCESS_KEY; do
  op read "op://Develop/SailEver/$name" | gh secret set "$name" --repo trahfo/sailever
done

GITHUB_TOKEN (registry login) is made by GitHub for each run; nothing to store. The worker VM has no secret of its own: its deploy reuses DEPLOY_SSH_KEY, the tunnel service token and GITHUB_TOKEN, and its jobs get R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY. Rotating the R2 token therefore means: new field values, the loop above, then a worker deploy.

4. Backups and production data

Field Contains Used for
BACKUP_AGE_IDENTITY (item Develop/SailEver) age private key (AGE-SECRET-KEY-1…), plus an offline copy Restoring the nightly backups (bin/backup-restore-test, bin/db-pull-production). The matching public key is BACKUP_AGE_RECIPIENT in apps/server/config/deploy.yml (not secret). Rotated 2026-10-18 after a leak: the old key must be deleted (DEPLOYMENT.md)

5. App signing

Entry Contains Used for
garmin-developer-key the Connect IQ signing key file, plus an offline copy; also at ~/.config/sailever/developer_key (mode 600) Every Garmin build. Losing it ends updates of the published apps; it can't be replaced
android-upload-keystore the .jks file + storePassword, keyAlias, keyPassword Play uploads; apps/android/keystore.properties (from apps/android/play/keystore.properties.example). Resettable through Play App Signing
apple-… — Not built yet (App Store Connect API key, match passphrase)

6. Not in the password manager

Made and kept by the infrastructure itself; nothing to store:

  • Cloudflare tunnel tokens (one per VM) and the Postgres TLS keys: in the encrypted tofu state and the VMs' instance metadata.
  • The Postgres CA certificate (apps/server/config/postgres/ca.crt, tofu output postgres_ca_cert): public.
  • wrangler login: browser sign-in, stored by wrangler on the Mac.

7. Rotation

  • Yearly, or at once on suspicion: the Cloudflare tokens, the Access service token (it expires after a year), the OCI Customer Secret Keys and API key, the SMTP credentials, POSTGRES_PASSWORD, the deploy SSH key. Make the new one, update the field and the GitHub secret (§3), then delete the old one at its source.
  • On suspicion only: RAILS_MASTER_KEY (new key, re-encrypt the credentials), the Android upload key (reset through Play App Signing).
  • Never without a migration: TF_VAR_state_passphrase (re-encrypt the state), BACKUP_AGE_IDENTITY (keep the old key until no backup needs it, unless it leaked), garmin-developer-key (can't be replaced at all).
  • If a secret was pasted visibly anywhere (a chat, an issue, a screen share): treat it as leaked and rotate it.

8. Moving to the item (owner, 2026-10-20)

Copy each value into a field of Develop/SailEver (custom field, type password or text), check the field's name letter by letter, then delete the old entry. Keep every field outside sections: op item edit SailEver --vault Develop "NAME[password]=…" updates a field only when it isn't in a section, and otherwise adds a second field with the same name, which op read then may skip. To list the fields with lengths only: op item get SailEver --vault Develop --format json | jq -r '.fields[] | select(.label != "") | [(.section.label // "-"), .label, (.value // "" | length)] | @tsv'. Old entry → field(s):

Old entry (as it may be called today) Field(s) Note
"SAILEVER" namespace none The Object Storage namespace axrihavopcof isn't secret (it's in config/deploy.yml); drop the entry
tofu user and password none A console login for the OCI user tofu isn't used by any tool (tofu uses its API key and Customer Secret Key). If you want it, keep it as a Login item account-oracle-cloud-tofu; better: remove the console password from that user in OCI
Cloudflare service token github-actions-deploy: client id, secret, and a newer secret TUNNEL_SERVICE_TOKEN_ID, TUNNEL_SERVICE_TOKEN_SECRET Use the secret that is in GitHub now (the deploy works with it). If you're unsure which, take the newer one, run the loop in §3, run a server deploy; when it passes, drop the other
private key server DEPLOY_SSH_KEY Must match ~/.ssh/sailever_deploy (ssh-keygen -y -f ~/.ssh/sailever_deploy shows its public key)
tofu-state-key (key + secret) AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY Lengths 40 and 44
SailEver tofu state passphrase TF_VAR_state_passphrase
customer-secret-key s3 (key + secret) OCI_S3_ACCESS_KEY_ID, OCI_S3_SECRET_ACCESS_KEY Lengths 40 and 44, user sailever-app
SailEver sailever-app SMTP user and password SMTP_USERNAME, SMTP_PASSWORD
SailEver backup age key and secret BACKUP_AGE_IDENTITY Only the AGE-SECRET-KEY-1… line. The age1… public key is BACKUP_AGE_RECIPIENT in config/deploy.yml, not secret. The old, leaked key (before 2026-10-18) is deleted, not moved
SailEver beta Postgres password POSTGRES_PASSWORD
SailEver beta Rails production key RAILS_MASTER_KEY 32 hex characters, the same as apps/server/config/credentials/production.key. The Hetzner-era RAILS_MASTER_KEY already in develop/SailEver (with KAMAL_REGISTRY_PASSWORD, POSTMARK_API_TOKEN) is overwritten; delete those two fields too
Cloudflare API token R2 (id + secret) R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY Lengths 32 and 64
[DEFAULT] block (user, fingerprint, tenancy, region, key_file) OCI_CONFIG Rename the header to [SAILEVER] and set key_file=~/.oci/sailever-tofu.pem. The private key file it points to → OCI_API_KEY
missing: the Cloudflare tofu token sailever-infra-oci CLOUDFLARE_API_TOKEN If you don't have its value any more: Cloudflare → API Tokens → sailever-infra-oci → Roll, store the new value
missing: Sentry DSN SENTRY_DSN Only if Sentry is set up

Then: 1. Delete what is no longer used: the old Cloudflare token for tofu (before sailever-infra-oci), also in Cloudflare → API Tokens; the old age private key; Hetzner-era secrets; older tofu or sailever-app Customer Secret Keys in OCI (keep only the ones in the item). 2. Check the GitHub secrets list (gh secret list) against §3: exactly those names. 3. Check offline copies exist for garmin-developer-key and BACKUP_AGE_IDENTITY.