Secrets
Gitea's session, JWT, and LFS secrets live in their own file. gtcnsl generates them, checks they are real, and rotates one at a time with a backup and restart.
The four managed secrets
gtcnsl manages these in /etc/gitea/secrets.ini (mode 0640, owned root:git), kept separate from the config template so a config apply never touches them:
| Secret | Role |
|---|---|
SECRET_KEY |
global session / OAuth / 2FA secret |
INTERNAL_TOKEN |
JWT for Gitea's internal APIs |
JWT_SECRET |
OAuth2 JWT secret |
LFS_JWT_SECRET |
Git LFS protocol JWT secret |
Generate
gtcnsl secrets generate --yes
Generates fresh values via gitea generate secret <TYPE> and writes the file. If it already exists, you must opt in:
gtcnsl secrets generate --force --yes
SECRET_KEY invalidates every active session, 2FA binding, and OAuth grant. Only --force on a fresh box or during a deliberate reset.Check
gtcnsl secrets check
Read-only. Exits 0 when all four hold real values, 1 if any is missing or still a stub (changeme, REPLACE_ME, …). It fails explicitly if secrets.ini is absent — handy as a CI or doctor gate.
Rotate one secret
Rotation regenerates a single secret and re-applies your config template with the secrets injected:
gtcnsl secrets rotate JWT_SECRET \
--template app.ini.tmpl \
--var DOMAIN=git.example.com \
--yes
<TYPE> is case-sensitive and one of SECRET_KEY, INTERNAL_TOKEN, JWT_SECRET, LFS_JWT_SECRET. The flow backs up secrets.ini.bak + app.ini.bak, writes both, restarts, and rolls both back together if the health check fails.
--var flags carry only the operator variables (DOMAIN, ROOT_URL, …); a secret key in --var is rejected.SECRET_KEY is special
Gitea encrypts a whole class of database rows under [security] SECRET_KEY: Actions secrets, 2FA (TOTP) seeds, webhook Authorization headers, LDAP bind passwords, and migration task credentials. Gitea has no built-in key-rotation support (go-gitea/gitea#16832, open since 2021) — swapping the key the naive way silently orphans all of that data. This isn't hypothetical: it's what happened on gtcnsl's own production host, where CI stopped receiving Actions secrets right after a plain rotation.
[security] SECRET_KEY is empty, Gitea falls back to a hardcoded value baked into its own source — known to anyone who reads the code. The data "encrypted" under it is effectively unprotected. gtcnsl doctor warns when it sees this.The guard
gtcnsl secrets rotate SECRET_KEY --template app.ini.tmpl --var DOMAIN=git.example.com --yes
hard-stops before touching any file if it finds data encrypted under the current key, and lists per category how many rows would be orphaned (Actions secrets, 2FA seeds, webhook auth headers, LDAP bind passwords, migration task credentials). Rotating INTERNAL_TOKEN, JWT_SECRET, or LFS_JWT_SECRET is unaffected — the guard only fires for SECRET_KEY.
The safe path: --reencrypt
gtcnsl secrets rotate SECRET_KEY --reencrypt \
--template app.ini.tmpl \
--var DOMAIN=git.example.com \
--yes
Order of operations: stop Gitea → back up the database (sqlite: a file copy next to the original, suffix .reencrypt-bak; mysql/postgres: no file-level backup — the re-encryption transaction is the safety net, take your own backup for belt-and-suspenders) → write the new secrets.ini/app.ini → re-encrypt every affected row, verifying decrypt(new) == plaintext for each one before writing it → start Gitea → health check. A failure at any stage after the database backup rolls everything back automatically — database, secrets.ini, app.ini, restart — so you never have to hand-reconcile a half-migrated instance.
Re-running --reencrypt is always safe: every run generates a fresh key and migrates whatever decrypts under the current one, verifying each row before writing it; rows it cannot read are never touched. A re-run after a rolled-back failure simply migrates the same rows again under the next key.
The verified-versions envelope
The re-encryption crypto isn't a public Gitea contract — an upstream minor could change it without a changelog mention — so --reencrypt only runs against a Gitea minor gtcnsl has actually re-verified it against (currently 1.24–1.27). Outside that envelope it refuses before touching anything, naming the installed version and the verified range. The explicit, separate override:
gtcnsl secrets rotate SECRET_KEY --reencrypt \
--reencrypt-unverified-version-i-understand \
--template app.ini.tmpl --var DOMAIN=git.example.com --yes
It doesn't replace --orphan-encrypted-data-i-understand — the two guard against different risks (an unverified crypto scheme vs. deliberately accepting data loss) and can both apply independently. The guard's read-only --dry-run classification still runs on any version; outside the envelope its per-site counts are flagged as possibly incomplete.
--dry-run
gtcnsl secrets rotate SECRET_KEY --reencrypt --dry-run \
--template app.ini.tmpl --var DOMAIN=git.example.com
Classifies the data and reports what a real --reencrypt run would touch (per-site counts, the affected table/row/field, never a value) — no stop, no writes, no restart. --dry-run doesn't need --yes.
--orphan-encrypted-data-i-understand
gtcnsl secrets rotate SECRET_KEY --orphan-encrypted-data-i-understand \
--template app.ini.tmpl --var DOMAIN=git.example.com --yes
Skips the migration and accepts the data loss — for when you're already planning to re-issue Actions secrets and have users re-enroll 2FA by hand. Passing it is enough to get past the guard; you don't need --reencrypt too.
INTERNAL_TOKEN, JWT_SECRET, or LFS_JWT_SECRET is rejected — they only mean something for SECRET_KEY.SECRET_KEY_URI instances
Gitea has let you source [security] SECRET_KEY from a file via SECRET_KEY_URI (a file: URI) since 1.19, instead of a verbatim value. gtcnsl resolves it exactly the way Gitea does at boot, and secrets rotate SECRET_KEY keeps that file in sync: the new key is written there too — with its own timestamped backup and rollback on any failure — not just recorded in secrets.ini. Setting both SECRET_KEY and SECRET_KEY_URI at once is refused, matching Gitea's own boot-time behaviour. A verbatim-configured instance is unaffected by any of this.
Caveats
- Rows that decrypt under neither the old nor the new key are never touched — they're only listed (table and row ID, never a value) for you to investigate separately.
- 2FA survives re-encryption: existing TOTP seeds keep working, no need to re-enroll. If a user already re-enrolled 2FA under the new key before you ran
--reencrypt, that row already counts as migrated and is skipped — also fine. mssqlisn't supported yet: gtcnsl can't inspect an mssql database at all, so the guard can't count what would be orphaned — it refuses the SECRET_KEY rotation outright (and--reencryptrefuses too) unless you pass--orphan-encrypted-data-i-understand. Supported engines:sqlite3,mysql,postgres.
Next steps
Wire gtcnsl secrets check into your gtcnsl doctor routine so a missing or stubbed secret surfaces before Gitea does something surprising.