Rotate a secret¶
Replace a secret's value everywhere it is held, then retire the old one.
Every case below uses the made-up example_app stack. Where you see the
example's names, use your own. Background and reasons are in
BACKGROUND.md. Adding a secret for the first time is
ADD.md.
Before you start¶
- The
keymanalias is set up on your workstation. See README.md, one-time setup. - Key operations run only in the key-manager container.
- Swarm secrets can't be changed. A rotation always makes a new name:
_v1becomes_v2, the compose file is changed to point at it, and the PR's merge is what makes it live. - A secret that is not a Swarm secret -- a file bound into a container on a
standalone host, a file an app reads through a placeholder, or a Portainer
stack variable -- is rotated the same way, but nothing below puts the new
value where the stack reads it. Place it by hand, as when it was added: write
the file with
feed, as in ADD.md, step 6b, or set the stack variable in Portainer. Until then the stack keeps the old value.rotatestill creates the new version as a Swarm secret, which nothing mounts.
Pick your case¶
Ask: if this value changed, which other system would have to be told?
| Case | Who else holds the value | Example secret | Is there an outage? |
|---|---|---|---|
| A | nobody | example_app_session_secret_v1, a cookie-signing key only the app uses |
no |
| B | a provider that can issue a second credential | example_app_api_token_v1, issued by Example Cloud |
no |
| C | a database, one password per user | example_app_db_password_v1, MariaDB user example |
yes, a few minutes |
| D | another system where you set it by hand | example_app_portal_password_v1, the service account from ADD.md |
yes, a few minutes |
list shows B and D as not-generated, and rotate refuses them: nothing here
can mint a value a provider or a web UI would accept.
How the branches fit together¶
Cases A and C: rotate makes and pushes the branch itself. The container
must be on main, and a fresh keyman session is.
main
|
+-- rotate/example-app-session-secret-v2 made and pushed by `rotate`, in the container:
| secrets.enc.yaml gains _v2, compose.yml points at _v2
|
+-- retire/example-app-session-secret-v1 made and pushed by `retire` afterwards (retire.md)
|
+-> deploy/swarm/example_app moves when each PR merges
Cases B and D: you make the branch on your workstation, as in ADD.md, and the container adds the value to it.
main
|
+-- stacks/example-app-api-token-v2 you create it and point compose at _v2 (workstation),
| the container adds the value (container)
|
+-- retire/example-app-api-token-v1 made and pushed by `retire` afterwards (retire.md)
|
+-> deploy/swarm/example_app moves when each PR merges
Case A. Nobody else holds the value¶
Example: example_app_session_secret_v1, which example_app uses to sign its
login cookies. Rotating it logs every user out, and nothing else.
A1. Generate and stage the new value (in the container)¶
A1.1 On your workstation, start a fresh container and sign in to GitHub when it asks:
A1.2 Confirm the container is on main. It prints main:
A1.3 Sign in to Key Vault:
A1.4 See what rotate would do, without doing it:
A1.5 Do it:
A1.6 It shows the compose change, example_app_session_secret_v1 to
example_app_session_secret_v2. Read it, then at
Apply this to the compose file(s)? [y/N] type y.
A1.7 It prints pushed branch rotate/example-app-session-secret-v2 and
MERGING THAT PR RESTARTS: example_app. Open the PR and note its number, 124
here:
A1.8 Refresh the store copy in Key Vault:
A1.9 Leave the container:
What
--applydid in A1.5: it generated a 43-character value you never see, wrote it to Key Vault, the store and Swarm asexample_app_session_secret_v2, verified Swarm against the store, changed the compose file, and committed and pushed both files.Nothing is live yet. The running container still mounts
_v1, becausemainstill says_v1. You can abandon the PR at this point with no effect.
A2. Merge and deploy (on your workstation)¶
A2.1 Merge the PR:
A2.2 Within five minutes Portainer redeploys example_app from
deploy/swarm/example_app. Check the service restarted: in a keyman session,
run this and look for a new task started after the merge:
A2.3 Log in to example_app. Expect to be logged out first: that is the old
signing key no longer being accepted.
A3. Retire the old value¶
A3.1 Follow RETIRE.md for example_app_session_secret_v1.
Case B. A provider can issue a second credential¶
Example: example_app_api_token_v1, an API token Example Cloud issued. Example
Cloud lets two tokens work at once, so there is no outage if the old token is
revoked last.
B1. Get the new credential (at the provider)¶
B1.1 In Example Cloud's console, create a new token with the same permissions as the old one. Copy it.
B1.2 Do not revoke the old token yet.
B2. Point compose at the new name (on your workstation)¶
B2.1 Create the branch:
cd ~/repos/homelab-stacks
git checkout main
git pull --ff-only
git checkout -b stacks/example-app-api-token-v2
B2.2 In stacks/swarm/example_app/compose.yml, change every
example_app_api_token_v1 to example_app_api_token_v2. For this example that
is three places:
environment:
- API_TOKEN_FILE: /run/secrets/example_app_api_token_v1
+ API_TOKEN_FILE: /run/secrets/example_app_api_token_v2
secrets:
- - example_app_api_token_v1
+ - example_app_api_token_v2
secrets:
- example_app_api_token_v1:
+ example_app_api_token_v2:
external: true
B2.3 Commit, push, and open a draft PR. The number is 125 here:
git add stacks/swarm/example_app/compose.yml
git commit -m "example_app: use the new Example Cloud token"
git push -u origin stacks/example-app-api-token-v2
gh pr create --draft --fill
B3. Store the new value (in the container)¶
B3.1 Start a fresh container and sign in to GitHub when it asks:
B3.2 Check out the branch and sign in to Key Vault:
B3.3 Record the value:
B3.4 Answer the prompts. There is no rename question, because the name
already ends in _v2:
| Step | Prompt | Answer for the example |
|---|---|---|
| B3.4.1 | Paste the value, then press Ctrl-D. Each character shows as *. |
paste once, check the * count, press Ctrl-D |
| B3.4.2 | Can rotate replace it on its own? [Y/n] |
n |
| B3.4.3 | why (one line): |
issued by Example Cloud; mint a new token there |
| B3.4.4 | Longest value the consuming service accepts? [enter = no limit] |
Enter |
| B3.4.5 | Does anything ELSE store this value -- a database, an appliance? [y/N] |
n |
| B3.4.6 | Note (why this exists), or enter: |
example_app token for Example Cloud |
B3.5 Check the length matches the token, then create, verify and back up:
list | grep example_app_api_token
provision --only example_app_api_token_v2 --apply
verify --only example_app_api_token_v2
backup --apply
B3.6 Commit, push, leave:
git add -A
git commit -m "secrets: example_app Example Cloud token v2"
git pull --rebase
git push
exit
B4. Merge and deploy (on your workstation)¶
B4.1 Mark the PR ready and merge it:
B4.2 Within five minutes Portainer redeploys example_app. Check it can
reach Example Cloud, for example in its logs.
B5. Retire and revoke the old token¶
B5.1 Follow RETIRE.md for example_app_api_token_v1.
B5.2 Then revoke the old token in Example Cloud's console.
Why revoke last: both tokens work until B5.2, so the old one is your way back if B4 goes wrong.
Case C. A database holds one copy¶
Example: example_app has a service app and a MariaDB service db.
example_app_db_password_v1 is the password of database user example, and
example_app_db_root_password_v1 is the root password.
A database user has one password. Once you change it, the app can't open new connections until it restarts with the new secret, a gap of a few minutes.
C1. Find the database's node¶
C1.1 On your workstation, start a fresh container and sign in to GitHub when it asks:
C1.2 Find where db runs. It prints a node name, docker02 here:
Why:
feedin C3 runsdocker execinto the database container, and the key-manager container can only reach containers on its own node. So C3 runs in a second container, on that node.
C2. Generate and stage the new value (in the same container)¶
C2.1 Confirm the container is on main, and sign in to Key Vault:
C2.2 Rotate:
C2.3 Read the compose change, then at
Apply this to the compose file(s)? [y/N] type y. It pushes
rotate/example-app-db-password-v2.
C2.4 Open the PR, 126 here, and refresh the store copy in Key Vault:
C2.5 Leave the container:
Why here, and not on the database's node:
rotatecreates a Swarm secret, which only a manager can do. On a worker it stops withthis node is not a Swarm MANAGER.keymanruns on a manager; the database may not.Still nothing is live. The database has not been touched, and
mainstill says_v1.
C3. Change the password in the database (in a container on that node)¶
C3.1 On your workstation, start key-manager on docker02. This is the
keyman command with the node changed:
ssh -t docker02 'docker pull -q ghcr.io/scyto/key-manager:latest && docker run --rm -it -v /var/run/docker.sock:/var/run/docker.sock --dns-search mydomain.com -e AKV_VAULT=YourKeyVaultName -e AKV_TENANT=00000000-0000-0000-0000-000000000000 -e GIT_AUTHOR_NAME=you -e GIT_AUTHOR_EMAIL=you@mydomain.com -e HOMELAB_REPO_URL=https://github.com/you/your-repo.git ghcr.io/scyto/key-manager:latest'
C3.2 Sign in to GitHub when it asks. Then check out the rotation's branch,
the only branch whose store has _v2 yet, and sign in to Key Vault:
C3.3 Find the database container on this node:
C3.4 Prove the pipeline with a harmless query. It must end with exited 0:
feed example_app_db_root_password_v1 --template "MYSQL_PWD='{example_app_db_root_password_v1}' mysql -u root -N -B -e 'SELECT 1;'" --apply -- docker exec -i "$DB" sh -s
C3.5 Point of no return. Set the new password:
feed example_app_db_root_password_v1 --also example_app_db_password_v2 --template "MYSQL_PWD='{example_app_db_root_password_v1}' mysql -u root -e \"ALTER USER 'example'@'%' IDENTIFIED BY '{example_app_db_password_v2}';\"" --apply -- docker exec -i "$DB" sh -s
C3.6 Check the new password works. It must end with exited 0:
feed example_app_db_password_v2 --template "MYSQL_PWD='{example_app_db_password_v2}' mysql -u example -N -B -e 'SELECT 1;'" --apply -- docker exec -i "$DB" sh -s
C3.7 Check the old password is refused. It must not exit 0:
feed example_app_db_password_v1 --template "MYSQL_PWD='{example_app_db_password_v1}' mysql -u example -N -B -e 'SELECT 1;'" --apply -- docker exec -i "$DB" sh -s
C3.8 Leave the container:
Why
feed: the value has to reachmysqlwithout being typed, appearing inps, or being written to a file.feedrenders the template in memory and hands it tosh -son stdin, and never prints it.
--applygoes before the--. Everything after--is the command, andfeedrefuses a--applyplaced there rather than silently doing a dry run.The host is part of the user.
'example'@'%'and'example'@'localhost'are separate accounts with separate passwords. If both exist, change both.From C3.5 on, reverting the PR undoes nothing. The database already has the new password. Going back means setting it back to the
_v1value, which is why_v1stays in the store until it is retired.
C4. Merge and deploy straight away (on your workstation)¶
C4.1 Merge the PR:
C4.2 Within five minutes Portainer redeploys example_app and the gap ends.
Check the app works, not just that it started: it can read and write its data.
C5. Retire the old value¶
C5.1 Follow RETIRE.md for example_app_db_password_v1.
Case D. You set it by hand in another system¶
Example: example_app_portal_password_v1, the service account password from
ADD.md. example-portal holds one password for that account, and you
change it in its web UI.
D1. Point compose at the new name (on your workstation)¶
D1.1 Create the branch:
cd ~/repos/homelab-stacks
git checkout main
git pull --ff-only
git checkout -b stacks/example-app-portal-password-v2
D1.2 In stacks/swarm/example_app/compose.yml, change every
example_app_portal_password_v1 to example_app_portal_password_v2: the
PORTAL_PASSWORD_FILE path, the service's secrets: list, and the top-level
secrets: block.
D1.3 Commit, push, and open a draft PR. The number is 127 here:
git add stacks/swarm/example_app/compose.yml
git commit -m "example_app: use the new example-portal password"
git push -u origin stacks/example-app-portal-password-v2
gh pr create --draft --fill
D2. Generate and store the new value (in the container)¶
D2.1 Start a fresh container and sign in to GitHub when it asks:
D2.2 Check out the branch and sign in to Key Vault:
D2.3 Generate the password with the command in
ADD.md step 4.1, copy it, and press
Enter. Do not set it in example-portal yet.
D2.4 Record it, answering as in ADD.md step 5.2.
There is no rename question, because the name already ends in _v2:
D2.5 Check the length is 32, then create, verify and back up:
list | grep example_app_portal_password
provision --only example_app_portal_password_v2 --apply
verify --only example_app_portal_password_v2
backup --apply
D2.6 Commit and push. Stay in the container, with the value still on your clipboard:
git add -A
git commit -m "secrets: example_app example-portal password v2"
git pull --rebase
git push
Everything up to here can be abandoned.
example-portalstill has the old password, andmainstill says_v1.
D3. Switch over (web UI, then workstation, straight after each other)¶
D3.1 Point of no return. In example-portal's web UI, set the new
password on example_app's service account.
D3.2 On your workstation, mark the PR ready and merge it:
D3.3 Within five minutes Portainer redeploys example_app. Check its logs
show it logging in to example-portal again.
D3.4 In the container, clear the terminal and leave:
The gap runs from D3.1 until the redeploy finishes. During it,
example_apppresents the old password andexample-portalrefuses it. Doing D2 first keeps the gap to the merge and one Portainer poll.
D4. Retire the old value¶
D4.1 Follow RETIRE.md for example_app_portal_password_v1.
If the new value must be shorter, or have no symbols¶
For cases A and C, where rotate makes the value. Some systems truncate, reject
symbols, or stop at 16 or 20 characters. Record the limit once, and every later
rotation honours it.
1 Rotate with the cap and the reason:
rotate example_app_db_password_v1 --max-chars 20 --alphabet alnum --why "example_app's config parser rejects symbols and stops at 20" --apply
2 It prints the entropy it actually got. For this example that is
new value 19 chars, alnum, 113 bits: 19 characters, not 20, because it
works in whole bytes of entropy. 113 bits is above the 112-bit floor, so there
is no warning. Below the floor it adds a CONSTRAINED line and carries on,
because the cap is recorded. Leaving out --why does not stop it either.
For B and D the value comes from you, so the cap is whatever you generate or paste.
add'sLongest value the consuming service accepts?prompt records it.
Rotation, in more depth¶
The procedure is above. This is what is going on underneath.
The new name¶
Swarm secrets are immutable, so rotate always creates a new name:
npm_db_password becomes npm_db_password_v2. Nothing uses that name until
the compose file says so, which is why nothing has broken yet.
The edit is not one line. In stacks/swarm/npm/compose.yml the real rotation
touched four places:
# 1. the path the entrypoint wrapper reads
- DB_MYSQL_PASSWORD="$$(cat /run/secrets/npm_db_password)";
+ DB_MYSQL_PASSWORD="$$(cat /run/secrets/npm_db_password_v2)";
# 2. the service's own secrets list
secrets:
- - npm_db_password
+ - npm_db_password_v2
# 3. the same two again in the db service, which mounts it too
# 4. the top-level block that declares them external
secrets:
- npm_db_password:
+ npm_db_password_v2:
external: true
Miss one and the stack either mounts a secret nothing reads, or reads a path
that does not exist. key-manager check catches the second.
%%{init: {'theme':'neutral'} }%%
sequenceDiagram
autonumber
participant Op as Operator
participant AKV as Key Vault
participant Store as sops store
participant Swarm as Swarm
participant Auth as Authority (e.g. MariaDB)
participant App as Running app
rect rgba(60,160,90,0.18)
Note over Op,Swarm: rotate --apply, all abortable
Op->>AKV: 1-2. generate, write (write-ahead log)
Op->>Store: 3. sops set
Op->>Swarm: 4. docker secret create NAME_vN
Op->>Swarm: 5. verify --only NAME_vN
end
rect rgba(200,70,70,0.15)
Note over Op,App: 6 is the point of no return
Op->>Auth: 6. feed (ALTER USER, or provider API)
Auth-->>Op: 7. new accepted, old refused
Note over App: app cannot log in: it still has the old value
Op->>Swarm: 8. compose → _vN, PR, merge, deploy
Swarm->>App: restart on the new secret
Note over App: app can log in again
Op->>App: 9. verify FUNCTION
Op->>Auth: 10. revoke old, retire, backup --apply
end
Key Vault is written first on purpose. A provider issues a credential once; if the rotation dies partway an unrecorded value is gone and the service is unreachable. A run here failed at step 3 after the vault write, and the value was still durable and the rotation resumable.
Effects a rotation can have¶
But the change may still be visible to users. The app was using the old value for something, and that something stops working. Two examples from this estate:
- a cookie-signing secret invalidates every existing session, so everyone is logged out
- a key used to encrypt stored data makes that data unreadable, permanently
Neither is a failure of the rotation. They are what the value was doing. Before you run it, read the application's own documentation for what it uses the secret for, and decide whether the effect is acceptable now or should wait for a quiet moment.
Some services let you avoid the outage case entirely (case C or D). unifiapibrowser used to be here,
using a UniFi account password. Switching it to a UniFi API key moved it to case B,
because the controller issues several keys and a password is singular. Same
service, no interruption, because the kind of credential changed. Where a
service offers both, take the key.
What ALTER USER actually does¶
MariaDB does not store the password. It stores a hash:
ALTER USER 'npm'@'%' IDENTIFIED BY '<new>' recomputes that hash and
overwrites the row. Nothing restarts, no data changes, and open connections are
unaffected. Only new connections are checked against the new hash, which is
why the app often keeps working right up until it restarts.
The host is part of the identity: 'npm'@'%' and 'npm'@'localhost' are
different rows with different hashes. The root rotation needed both.
ALTER USER is not recoverable from the database, because the old hash is
gone. It is reversible only because the old plaintext is still in the store,
which is why retiring comes last.
Rotation-due dates in Key Vault¶
rotate stamps two things on the vault item: a minted tag with the date, and
an expiry 90 days later. Both are labels for a human reading the portal. For
Key Vault secrets, unlike keys, exp and nbf are informational and a
get still succeeds outside the window, so an lapsed date never breaks
recovery. That was worth checking rather than assuming.
Per secret, with constraints.rotate_days.
The date is stamped at rotation, not at backup. Setting it on every backup
would push it forward each run and tell you nothing. backup reads the current
expiry and carries it forward, because set_secret creates a new version with
exactly the properties given and would otherwise erase it.
A secret with no expiry has never been rotated through this tool. That absence is information, so nothing invents a date to fill it.
For a provider-issued credential, case B is the procedure: create the new one in their console, store it, deploy, then revoke the old.