# Kamal + 1Password + GitHub Actions: Production Deployment Pipeline
> Set up automated Kamal deployments with 1Password secrets management. The same .kamal/secrets file works for manual deploys and GitHub Actions CI/CD.

Canonical URL: https://rida.me/blog/kamal-1password-cicd-deployment/


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

```mermaid
flowchart LR
    A["1Password<br/>Production Vault"] --> B[".kamal/secrets"]
    B --> C["Kamal"]
    C --> D["Production<br/>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`**:

```yaml
service: myapp
image: your-username/myapp

servers:
  web:
    - your.server.ip.address

proxy:
  ssl: true
  host: your-app.example.com

registry:
  server: ghcr.io
  username: your-email@example.com
  password:
    - KAMAL_REGISTRY_PASSWORD

env:
  secret:
    - RAILS_MASTER_KEY
    - DATABASE_PASSWORD
    - CHATKIT_PK
    - MAILGUN_INGRESS_SIGNING_KEY
  clear:
    SOLID_QUEUE_IN_PUMA: true
    DB_HOST: infra-postgres

volumes:
  - "myapp_storage:/rails/storage"
  - "/var/run/docker.sock:/var/run/docker.sock"

builder:
  arch: amd64
```

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`**:

```bash
SECRETS=$(kamal secrets fetch --adapter 1password --account your-account.1password.com --from MyApp/Production KAMAL_REGISTRY_PASSWORD RAILS_MASTER_KEY DATABASE_PASSWORD MAILGUN_INGRESS_SIGNING_KEY CHATKIT_PK)
KAMAL_REGISTRY_PASSWORD=$(kamal secrets extract KAMAL_REGISTRY_PASSWORD ${SECRETS})
RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY ${SECRETS})
DATABASE_PASSWORD=$(kamal secrets extract DATABASE_PASSWORD ${SECRETS})
MAILGUN_INGRESS_SIGNING_KEY=$(kamal secrets extract MAILGUN_INGRESS_SIGNING_KEY ${SECRETS})
CHATKIT_PK=$(kamal secrets extract CHATKIT_PK ${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`**:

```yaml
name: CD

on:
  push:
    branches:
      - main
  workflow_dispatch:

concurrency:
  group: production-deploy
  cancel-in-progress: true

jobs:
  Deploy:
    runs-on: [self-hosted, linux, x64, privileged]

    env:
      OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
      VERSION: ${{ github.sha }}
      DOCKER_BUILDKIT: 1
      RAILS_ENV: production

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Install 1Password CLI
        uses: 1password/install-cli-action@v2

      - name: Load SSH key from 1Password
        uses: 1password/load-secrets-action@v3
        with:
          export-env: true
        env:
          OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
          SSH_PRIVATE_KEY: "op://MyApp/Server/SSH_KEY"

      - name: Set up Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: 3.4.4
          bundler-cache: true

      - name: Install Kamal
        run: gem install kamal -v 2.5.3

      - name: Set up SSH agent
        uses: webfactory/ssh-agent@v0.9.1
        with:
          ssh-private-key: ${{ env.SSH_PRIVATE_KEY }}

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Deploy
        run: kamal deploy
```

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:

```bash
# Add to fetch command
SECRETS=$(kamal secrets fetch ... EXISTING_SECRETS NEW_SECRET)

# Add extraction line
NEW_SECRET=$(kamal secrets extract NEW_SECRET ${SECRETS})
```

**`config/deploy.yml`:** Add to the secret array:

```yaml
env:
  secret:
    - RAILS_MASTER_KEY
    - NEW_SECRET  # Add here
```

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](/blog/kamal-github-review-apps/).
