Automatic deployments with GitHub Actions: from main to production
Add pull request checks, a dedicated CI SSH key, GHCR publishing, and automatic Kamal deployments.
In the first article, we deployed a single HTML page with Kamal. Now we will move publishing into GitHub Actions: changes pass checks, reach main, and trigger an automatic deployment.
This builds on the same hello-kamal project: a Dockerfile, Nginx with /up, a working config/deploy.yml, and a completed first deployment. All examples use the fictional example.com, 203.0.113.10, and your-github-user. Secret values are represented only by ********.
The flow
Pull request → build and check the container
Merge to main → the same checks → GHCR → Kamal → HTTPS check
Pull requests do not need server access. The SSH key is available only in the deployment job, which runs for main. A successful image build is not the final check: the workflow also verifies the website after deployment.
A dedicated CI key
Generate a project-specific key on your computer. It has no passphrase because Actions must use it without interactive input:
ssh-keygen -t ed25519 -N '' -C hello-kamal-actions -f ~/.ssh/hello_kamal_actions
If that file exists, choose a different name rather than overwriting a working key. Add the public half to the server’s deploy user:
awk '{print "restrict " $0}' ~/.ssh/hello_kamal_actions.pub |
ssh deploy@203.0.113.10 'umask 077; mkdir -p ~/.ssh; cat >> ~/.ssh/authorized_keys'
restrict disables capabilities including port forwarding and PTY allocation. It does not limit Docker access to one application: a key for a Docker-enabled user remains privileged. Store it only in a trusted repository.
Upload the private half to an Actions secret via stdin:
gh secret set DEPLOY_SSH_KEY --repo your-github-user/hello-kamal < ~/.ssh/hello_kamal_actions
Do not print the private key with cat to copy it into an article, log, or conversation.
Verify the server’s identity
The runner must verify the server’s host key. First make a regular SSH connection and compare its fingerprint with the value obtained through a trusted server console or hosting control panel.
Then extract the already verified entry from your local known_hosts:
ssh-keygen -F 203.0.113.10 | awk '!/^#/ {print}' > /tmp/hello-kamal-known-hosts
test -s /tmp/hello-kamal-known-hosts
gh secret set DEPLOY_KNOWN_HOSTS --repo your-github-user/hello-kamal < /tmp/hello-kamal-known-hosts
rm /tmp/hello-kamal-known-hosts
The server’s public host key is not a password, but its value is omitted here too: an illustrative value would be ********. There is no need for StrictHostKeyChecking=no.
GHCR authentication without a personal token
The workflow receives a temporary GITHUB_TOKEN to publish images. We grant packages: write only to the deployment job. This scenario does not need a long-lived Personal Access Token. See GitHub’s container publishing guide.
Keep .kamal/secrets from the first article:
KAMAL_REGISTRY_PASSWORD=$(gh auth token --hostname github.com)
Locally, this uses GitHub CLI authentication. In Actions, it reads the token supplied through GH_TOKEN. The YAML expression ${{ github.token }} references a runtime token; it is not the token’s literal value.
If the image was previously published manually, check the repository’s package access. Under Manage Actions access in the GHCR package settings, add the repository with write access. Matching image names alone do not grant permission. See package access configuration.
The workflow
This example builds on a GitHub runner. Use a local builder in config/deploy.yml; if you previously configured a remote builder, replace that block:
builder:
arch: amd64
local: true
Create .github/workflows/deploy.yml:
name: Check and deploy
on:
pull_request:
branches: [main]
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
jobs:
check:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- run: docker build -t hello-kamal-check .
- name: Check the container
run: |
docker run --rm --network none --entrypoint sh hello-kamal-check -c '
set -eu
nginx -t
nginx
test "$(wget -qO- http://127.0.0.1:8080/up)" = ok
wget -qO /dev/null http://127.0.0.1:8080/
'
deploy:
needs: check
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
runs-on: ubuntu-24.04
timeout-minutes: 20
concurrency:
group: production
cancel-in-progress: false
permissions:
contents: read
packages: write
env:
GH_TOKEN: ${{ github.token }}
REGISTRY_USER: ${{ github.repository_owner }}
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.4'
- run: gem install kamal --version 2.11.0 --no-document
- name: Configure SSH
env:
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
run: |
test -n "$DEPLOY_SSH_KEY"
test -n "$DEPLOY_KNOWN_HOSTS"
install -d -m 700 ~/.ssh
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts
chmod 600 ~/.ssh/known_hosts
ssh -o BatchMode=yes -o StrictHostKeyChecking=yes deploy@203.0.113.10 true
- name: Authenticate GHCR
run: gh auth token --hostname github.com | docker login ghcr.io --username "$REGISTRY_USER" --password-stdin
- run: kamal deploy
- name: Verify HTTPS
run: curl --fail --silent --show-error --location --retry 3 --max-time 30 https://example.com --output /dev/null
- name: Remove SSH credentials
if: always()
run: rm -f ~/.ssh/id_ed25519 ~/.ssh/known_hosts
The container check publishes no ports and has no external network. It checks Nginx configuration, /up, and the homepage. For an application with its own build and tests, add those checks to the check job.
Action versions use tags here for readability. In a working repository, pin verified full commit SHAs. Those identify public source code and are not secrets.
Why needs and concurrency matter
needs: check prevents deployment after a failed check. The if condition excludes pull request deployments. A push to main covers both merges and direct commits; workflow_dispatch allows a manual rerun.
concurrency prevents two jobs in the group from deploying simultaneously. With cancel-in-progress: false, a new run does not interrupt a deployment already in progress. A pending run may still be replaced by a newer one: this is not a FIFO queue guarantee. See the concurrency documentation.
The first run
Open a pull request containing the workflow. Confirm that only check executes, then merge into main. Both jobs should complete in the Actions tab. Open the website and check the new content, not just an HTTP 200.
If HTTPS verification fails after a successful kamal deploy, the new version may already be serving traffic. This workflow does not automatically roll back. Follow the deployment recovery guide to investigate and restore service.
Keep secrets out of logs
Avoid set -x in secret-handling steps. Do not print env, private keys, Docker config, or configuration output containing passwords. Public examples should contain only masks:
registry_password: ********
ssh_private_key: ********
Authorization: Bearer ********
A mask shows where a secret belongs; it is not a usable credential. If a real token reaches Git or logs, revoke or rotate it first. Replacing it with asterisks in a later commit does not remove the earlier exposure.