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/secretscallssecret(mise run -q secret NAME), which takes the environment variable when it is set (the GitHub deploy jobs) and otherwise readsop://Develop/SailEver/NAME. - OpenTofu (
infra/oci):mise tofu planruns the command with the four tofu fields as environment variables (op-env;op runmasks 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.