Updating Coder To Get User Secrets and the Art of Knowing Where Your Secrets Belong
Coder 2.34 dropped last week. It's the June Extended Support Release, which means it's the version the Coder team is telling you to park on if you want stability. It's also the version that finally gave me a reason to rethink how secrets flow into my workspaces.
The headline features are Coder Agents improvements (chat sharing, personal skills, auto-configure models, GitLab integration) and a new User Secrets system. Most of the Agents improvements were already working for me — I've been using personal skills via ~/.agents/skills/ for weeks. But User Secrets caught my eye because it solves a problem I'd been working around with duct tape.
What User Secrets Actually Is
User Secrets is per-user secret storage built into Coder. You store a key-value pair once, and Coder injects it as an environment variable, a file, or both into every workspace you own. Automatically. At startup.
No more copying .env files between workspaces. No more baking tokens into Terraform variables. No more extracting credentials from config files with jq hacks in startup scripts.
The basics:
- Up to 50 secrets per user, 24 KiB max per env var secret
- Create via CLI (
coder secret create), REST API, or the dashboard UI - Secret values are never shown after creation — write-only
- Secrets apply at workspace start (restart to pick up changes)
- Available in every shell, terminal, app, and SSH session
It's in beta, and it's available in the open-source AGPL build — no Premium license required.
The Upgrade
The upgrade itself was straightforward. On the homelab workstation:
curl -fsSL https://coder.com/install.sh | sh -s -- --version 2.34.0
sudo systemctl restart coderOne gotcha: my CLI session on the host had expired, probably from the version jump. coder login opens a browser, gives you a session token, and expects you to paste it back into the terminal. If your terminal fights you on paste, skip the interactive prompt:
coder login http://localhost:3000 --token <your-session-token>Breaking Changes Worth Knowing
The one that matters for most people: AI Gateway is now enabled by default (CODER_AI_GATEWAY_ENABLED=true). If you don't want it, explicitly set it to false in your Coder env. The other breaking changes are API-level — pointer fields in patchTemplateMeta, structured chat errors — and won't affect you unless you have custom API clients.
The Audit: 29 Secrets, 4 Projects, 2 Migrations
Before throwing everything into User Secrets, I wanted to understand the full picture. Where do secrets live across my entire setup? What manages them? And which ones actually belong in User Secrets?
I ran a comprehensive audit across four projects (coder-templates, the-vibe-coder blog, the-vibe-coder-content, fitness-tracker), plus workspace config files, shell profiles, and GitHub Actions workflows. The results:
| Management Method | Count | Examples |
|---|---|---|
| Vercel env vars | 23 | Database URLs, OAuth secrets, API keys, encryption keys |
| GitHub Actions secrets | 4 | Deploy hooks, mirror tokens, Slack webhooks |
| Coder External Auth | 1 | GitHub token (auto-rotating) |
| Needs migration | 2 | MCP bearer tokens |
29 total secrets. Only 2 belonged in User Secrets.
Why Most Secrets Don't Belong Here
User Secrets solves one specific problem: getting credentials from the user into the workspace. It's not a replacement for Vercel env vars, GitHub Actions secrets, or any other secret management system. Here's how I thought about it:
Vercel env vars (23 secrets) — These are server-side application secrets. Database connection strings, OAuth client secrets, API keys for Anthropic and Dev.to, encryption keys. They run on Vercel's infrastructure, not in Coder workspaces. Coder never needs them.
GitHub Actions secrets (4 secrets) — These are CI/CD secrets. Deploy hooks, mirror repo tokens, Slack webhooks. They run in GitHub's infrastructure. Coder never needs them.
Coder External Auth (1 secret) — GITHUB_TOKEN is already handled by Coder's external auth system, which auto-rotates OAuth tokens on every use. It's better than anything User Secrets could offer — dynamic, short-lived, automatically refreshed.
The two that fit (2 secrets) — FITNESS_TRACKER_MCP_TOKEN and VIBESCODER_MCP_TOKEN. These are bearer tokens that MCP servers need inside workspaces. They're user-specific, workspace-relevant, and were previously managed through an awkward chain of Terraform variables and environment injection.
The Old Way Was Ugly
Here's how the fitness tracker token used to flow:
- Set
TF_VAR_fitness_tracker_mcp_token=<token>in/etc/coder.d/coder.envon the Coder server - Terraform variable
var.fitness_tracker_mcp_tokenpicks it up inmain.tf - Injected as
FITNESS_TRACKER_MCP_TOKENenv var into the agent - Startup script conditionally merges a
fitness-trackerentry into~/.mcp.jsonusingjq - Agent skill file documents a jq fallback for extracting the token back out if the env var is missing
Five steps. Three config files. A Terraform variable with a sensitive = true annotation. A jq pipeline in the startup script. And a documented fallback in the skill file for when the env var wasn't available in agent chat sessions.
The New Way
echo -n "<your-token>" | coder secret create fitness-tracker-token \
--description "Fitness tracker MCP bearer token" \
--env FITNESS_TRACKER_MCP_TOKENOne command. The token is available in every workspace, every shell, every agent session. No Terraform variables, no jq extraction, no fallback documentation.
The Migration
Step 1: Create the User Secrets
On the host, for each token:
echo -n "<your-token>" | coder secret create fitness-tracker-token \
--description "Fitness tracker MCP bearer token" \
--env FITNESS_TRACKER_MCP_TOKEN
echo -n "<your-token>" | coder secret create vibescoder-token \
--description "Vibescoder MCP bearer token" \
--env VIBESCODER_MCP_TOKENWatch for the trailing newline warning — if you see WARN: secret value from stdin ends with a trailing newline, your token picked up a \n. Use echo -n carefully, and make sure the closing quote is on the same line as the token value. Fix with:
echo -n "<your-token>" | coder secret update vibescoder-tokenStep 2: Clean Up the Template
Remove the Terraform variables that are no longer needed:
# REMOVED — these variables
variable "fitness_tracker_mcp_token" {
type = string
default = ""
sensitive = true
}
variable "vibescoder_mcp_token" {
type = string
default = ""
sensitive = true
}Remove the env injections from the coder_agent resource:
# REMOVED — these env entries
FITNESS_TRACKER_MCP_TOKEN = var.fitness_tracker_mcp_token
VIBESCODER_MCP_TOKEN = var.vibescoder_mcp_tokenThe startup script that merges MCP entries into ~/.mcp.json stays — it still needs to read the token from the environment. It just reads it from User Secrets now instead of from Terraform variables:
# Token is now injected via Coder User Secrets.
if [ -n "${FITNESS_TRACKER_MCP_TOKEN:-}" ]; then
jq --arg auth "Bearer ${FITNESS_TRACKER_MCP_TOKEN}" \
'.mcpServers["fitness-tracker"] = {
command: "npx",
args: [
"mcp-remote",
"https://your-mcp-server.example.com/api/mcp/mcp",
"--header",
("Authorization: " + $auth)
]
}' ~/.mcp.json > ~/.mcp.json.tmp \
&& mv ~/.mcp.json.tmp ~/.mcp.json
fiStep 3: Clean Up the Server
Remove the now-dead TF_VAR lines from the Coder server config:
sudo sed -i '/TF_VAR_fitness_tracker_mcp_token/d' /etc/coder.d/coder.env
sudo sed -i '/TF_VAR_vibescoder_mcp_token/d' /etc/coder.d/coder.envVerify they're gone:
grep -i "mcp_token" /etc/coder.d/coder.env
# Should return nothingStep 4: Apply and Test
Pull the template changes and push to Coder:
cd ~/coder-templates && git pull && ./docker/apply.shRestart a workspace, then verify:
echo "FITNESS_TRACKER_MCP_TOKEN: $([ -n "$FITNESS_TRACKER_MCP_TOKEN" ] && echo 'YES' || echo 'NO')"
echo "VIBESCODER_MCP_TOKEN: $([ -n "$VIBESCODER_MCP_TOKEN" ] && echo 'YES' || echo 'NO')"Both should say YES. Test an actual API call to confirm the token works:
curl -sS -X POST https://your-mcp-server.example.com/api/mcp/mcp \
-H "Authorization: Bearer $FITNESS_TRACKER_MCP_TOKEN" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"list_workouts","arguments":{"limit":1}}}'Bonus: Update the Skill File
The fitness-tracker agent skill had a documented jq fallback for when the env var wasn't available. That fallback is now dead code in the documentation. Updated from:
- Fallback if the env var is missing — extract from `~/.mcp.json`:
export FITNESS_TRACKER_MCP_TOKEN=$(
jq -r '.mcpServers["fitness-tracker"].args[-1]' ~/.mcp.json \
| sed 's/^Authorization: Bearer //'
)To:
- Injected automatically via Coder User Secrets into every workspace.
- Verify with: `[ -n "$FITNESS_TRACKER_MCP_TOKEN" ] && echo "ok"`Simpler docs, simpler mental model.
The .env.example Cleanup
While auditing secrets, we found two documentation gaps:
the-vibe-coder was missing 4 env vars from its .env.example: MCP_API_TOKEN, VERCEL_DEPLOY_HOOK, SLACK_SIGNING_SECRET, and APP_BASE_URL. All were properly set in Vercel — just undocumented for anyone reading the repo.
fitness-tracker had no .env.example at all. 13 environment variables, zero documentation. We created one with sections for database, authentication, MCP/API, encryption, Peloton, Tonal, and app config.
Neither of these was a security issue — secrets were properly managed in Vercel. But if you can't figure out what env vars an app needs by reading the repo, you'll figure it out the hard way: runtime errors.
Housekeeping: Fixing Git Identity for Multi-User Setups
Unrelated to secrets, but worth mentioning: my wife uses the same Coder instance and kept having commits attributed to her Gmail address instead of her GitHub handle. Her agent would use the right identity for a while, then forget.
The root cause: she logs into Coder with Gmail, so coder_workspace_owner.me.email returns her Gmail address. That's what the template was setting as GIT_AUTHOR_EMAIL. I had a manual workaround — a hardcoded map in the template:
git_email_overrides = {
vibalknowledge = "[email protected]"
}This worked at the Terraform level but didn't survive agent sessions that reset git config. And it doesn't scale — every new user needs a manual entry.
The fix: replace the static map with a startup script that dynamically fetches the GitHub identity using the already-authenticated token:
if [ -n "$GITHUB_TOKEN" ]; then
GH_USER=$(gh api /user --jq '.login // empty' 2>/dev/null)
GH_NAME=$(gh api /user --jq '.name // empty' 2>/dev/null)
if [ -n "$GH_USER" ]; then
GH_NOREPLY=$(gh api /user --jq '"\(.id)+\(.login)@users.noreply.github.com"' 2>/dev/null)
git config --global user.email "$GH_NOREPLY"
git config --global user.name "${GH_NAME:-$GH_USER}"
fi
fiNow every user's git identity is automatically set from their GitHub profile at workspace startup. No manual overrides, no agent amnesia, no per-user config. Login with Gmail, Google, OIDC, carrier pigeon — as long as you've linked GitHub via Coder's external auth, your commits attribute correctly.
By the Numbers
- Version jump: 2.33.2 → 2.34.0
- Secrets audited: 29 across 4 projects
- Secrets migrated to User Secrets: 2
- Terraform variables removed: 2
- Server config lines removed: 2
- Lines of jq fallback documentation deleted: 7
coder loginpaste failures: 1 (use--tokenflag)- Trailing newline warnings: 1 (use
echo -ncarefully) - Git identity hardcodes eliminated: 1 per user, forever
- .env.example files created or updated: 2
- Time from upgrade to fully migrated: ~45 minutes