Skip to content
gtcnsl
EN RU

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.

Updated for v1.3.0

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
!
--force logs everyone out
Overwriting 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.

i
Don't pass secret keys as --var
The secret values are injected for you. Your --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.

!
An empty SECRET_KEY is Gitea's public default
If [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.241.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.

i
--reencrypt and --dry-run are SECRET_KEY-only
Passing either flag while rotating 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.
  • mssql isn'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 --reencrypt refuses 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.