vibescoder

Updating Coder To Get User Secrets and the Art of Knowing Where Your Secrets Belong

·9 min read

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 coder

One 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 MethodCountExamples
Vercel env vars23Database URLs, OAuth secrets, API keys, encryption keys
GitHub Actions secrets4Deploy hooks, mirror tokens, Slack webhooks
Coder External Auth1GitHub token (auto-rotating)
Needs migration2MCP 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:

  1. Set TF_VAR_fitness_tracker_mcp_token=<token> in /etc/coder.d/coder.env on the Coder server
  2. Terraform variable var.fitness_tracker_mcp_token picks it up in main.tf
  3. Injected as FITNESS_TRACKER_MCP_TOKEN env var into the agent
  4. Startup script conditionally merges a fitness-tracker entry into ~/.mcp.json using jq
  5. 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_TOKEN

One 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_TOKEN

Watch 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-token

Step 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_token

The 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
fi

Step 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.env

Verify they're gone:

grep -i "mcp_token" /etc/coder.d/coder.env
# Should return nothing

Step 4: Apply and Test

Pull the template changes and push to Coder:

cd ~/coder-templates && git pull && ./docker/apply.sh

Restart 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
fi

Now 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 login paste failures: 1 (use --token flag)
  • Trailing newline warnings: 1 (use echo -n carefully)
  • Git identity hardcodes eliminated: 1 per user, forever
  • .env.example files created or updated: 2
  • Time from upgrade to fully migrated: ~45 minutes

Comments