It was 11pm on a Friday. Deploy failed. Missing environment variable. After twenty minutes of debugging, I found it: I’d added a secret to CI but forgot to add it to the server. Two-minute fix, twenty-minute hunt.
I’d been burned enough times to finally fix this properly.
The Core Insight
One source of truth for production secrets. 1Password holds the secrets. Kamal fetches them. GitHub Actions triggers the deploy. No scattered env files on servers, no secrets in CI config.
The payoff: zero drift between environments, trivial secret rotation, and no more touching servers manually. Whether I’m running kamal deploy from my terminal or GitHub Actions is deploying on push to main, the same .kamal/secrets file pulls from the same vault.
How Secrets Flow
Production Vault"] --> B[".kamal/secrets"] B --> C["Kamal"] C --> D["Production
Container"] E["GitHub Actions"] -.->|"service account token"| B F["Terminal"] -.->|"1Password app"| B
One file, one vault. Both deploy methods pull from the same source.
The .kamal/secrets file fetches secrets and exports them as environment variables. Kamal reads those variables and injects them into your production container. The deploy.yml just declares which secrets to look for.
Setup
Before you start: you need a 1Password account, a service account token (for CI), and Kamal 2.5+ installed locally.
1Password Structure
Create a vault with your secrets:
MyApp/
Production/
- KAMAL_REGISTRY_PASSWORD
- RAILS_MASTER_KEY
- DATABASE_PASSWORD
- CHATKIT_PK
- MAILGUN_INGRESS_SIGNING_KEY
Server/
- SSH_KEY (private key for server access)
I keep Production and Server separate because app secrets rotate more often than SSH keys, and it’s easier to audit access when they’re in different items.
Create a Service Account with read access to your vault. Service accounts are designed for non-interactive environments like CI—your personal 1Password login won’t work in GitHub Actions. Save the token for later.
Kamal Configuration
config/deploy.yml:
| |
The env.secret array tells Kamal which variables to pull from your secrets file.
The Secrets File
This is the heart of the setup.
.kamal/secrets:
| |
One file. Works for manual deploys, works in CI.
GitHub Actions Workflow
The workflow installs the 1Password CLI, loads the SSH key, then runs kamal deploy. The service account token authenticates everything.
.github/workflows/hetzner-cd.yml:
| |
In your repository settings, add one secret: OP_SERVICE_ACCOUNT_TOKEN. Everything else lives in 1Password.
Note: This workflow uses a self-hosted runner. If you’re on GitHub-hosted runners, change runs-on to ubuntu-latest and ensure the runner has network access to your server.
Adding New Secrets
This is the part people get wrong. Adding a secret requires changes in three places. Miss one and the deploy fails silently.
1Password: Create the secret in your vault (e.g., MyApp/Production/NEW_SECRET).
.kamal/secrets: Add to both the fetch and extract:
| |
config/deploy.yml: Add to the secret array:
| |
The common mistake: adding to the fetch but forgetting the extract line. Or adding to extract but not to deploy.yml. The deploy runs, the secret is empty, and you spend fifteen minutes wondering why your app can’t connect to the database.
Teaching This to Your Coding Agent
I use AI coding agents for most implementation work. They’re great at editing code but can’t touch 1Password. This template draws a clear line between human-only steps and agent-only steps:
Add a new production secret called `YOUR_SECRET_NAME`.
Prerequisites (I will handle manually):
- [ ] Create secret in 1Password at MyApp/Production/YOUR_SECRET_NAME
Code changes needed:
1. Update `.kamal/secrets`:
- Add YOUR_SECRET_NAME to the `kamal secrets fetch` command on line 1
- Add extraction line: YOUR_SECRET_NAME=$(kamal secrets extract YOUR_SECRET_NAME ${SECRETS})
2. Update `config/deploy.yml`:
- Add YOUR_SECRET_NAME to the `env.secret` array
Reference existing secrets in these files for the exact format.
The explicit split matters. The agent can’t access 1Password, and I don’t want it trying. Clear boundaries, clear ownership. After it commits, I verify with kamal shell and printenv | grep SECRET_NAME.
Troubleshooting
Secret not found during deploy. The deploy succeeds but the app crashes with a missing env var. Check: secret exists in 1Password with exact spelling, name matches in fetch command, extraction line exists, and deploy.yml includes it.
SSH connection failures in CI. Permission denied (publickey) in GitHub Actions. Check: field in 1Password is named SSH_KEY (not SSH_PRIVATE_KEY), it’s the private key not public, format includes -----BEGIN OPENSSH PRIVATE KEY-----, and server’s authorized_keys has the corresponding public key.
“op not found” in CI. The 1Password CLI install step must run before any secret access. Ensure 1password/install-cli-action@v2 comes first.
Kamal can’t authenticate with 1Password. For manual deploys: 1Password desktop app needs to be running and unlocked. For CI: verify OP_SERVICE_ACCOUNT_TOKEN is set and the service account has vault access.
What Changed
The first time the pipeline worked end-to-end, I pushed a commit and walked away. Came back to a successful deploy. No SSH’ing into servers, no copying env files, no “wait, which secret changed?”
Secrets live in one place. Deployments are fully automated. Rotating a secret means updating 1Password and redeploying.
The only friction left is remembering to update all three files when adding a secret. But it beats the 11pm debugging sessions. Fewer surprises, fewer late-night deploys, a pipeline I can trust.
For preview environments on pull requests, the pattern extends naturally. I wrote about that in Building Review Apps with Kamal and GitHub Actions.