CI/CD pipelines need credentials: SSH keys to deploy to your VPS, registry tokens, cloud API keys, and database passwords for tests. GitHub Actions secrets store these encrypted, and expose them only to the workflow runs that need them. Used well, they keep credentials out of your code. Used carelessly, they can still leak.
Types of secrets
| Scope | Where to set | Use |
|---|---|---|
| Repository | Repo → Settings → Secrets and variables → Actions | Most projects |
| Environment | Repo → Settings → Environments → (env) | Per-environment values with protection rules (e.g. production) |
| Organization | Org settings | Shared across selected repositories |
Non-secret settings (hostnames, region names) belong in variables (vars.NAME), which are visible in logs.
Add and use a secret
With the GitHub CLI:
gh secret set VPS_SSH_KEY < ~/.ssh/deploy_key
gh secret set VPS_HOST --body "203.0.113.10"
gh secret set DB_PASSWORD --env production
gh secret list
In a workflow:
jobs:
deploy:
runs-on: ubuntu-latest
environment: production # uses environment secrets + protection rules
steps:
- uses: actions/checkout@v4
- name: Deploy
env:
SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
HOST: ${{ secrets.VPS_HOST }}
run: |
install -m 600 /dev/null ~/.ssh_key
printf '%s\n' "$SSH_KEY" > ~/.ssh_key
ssh -i ~/.ssh_key -o StrictHostKeyChecking=accept-new deploy@"$HOST" 'cd /opt/app && docker compose pull && docker compose up -d'
Pass secrets through env:, not directly into run: scripts with ${{ }}. That avoids shell-injection problems with special characters. See GitHub Actions deploy to VPS.
Protect production with environments
Environments add:
- required reviewers: a person must approve deployments;
- wait timers and deployment branch rules (only
mainor tags can deploy); - environment-specific secrets, available only to jobs that target that environment.
That stops a random branch, or a pull request, from deploying to production.
Use a dedicated, limited deploy key
- Create an SSH key only for CI, and add it to a dedicated
deployuser on the VPS, not root. See SSH keys. - Restrict what it can do: a forced command in
authorized_keys,from="…"with runner IP ranges (hard with GitHub-hosted runners), orsudorules limited to the deploy script. See sudo and sudoers. - Pin the server’s host key in a secret or
known_hostsfile instead of disabling host key checking.
Prefer OIDC over long-lived cloud keys
For cloud providers that support it (AWS, Azure, GCP, and others), GitHub’s OIDC integration issues short-lived credentials per run. No static keys to leak:
permissions:
id-token: write
contents: read
Then configure the provider to trust tokens from your repository and branch.
How secrets leak (and how to prevent it)
- Logs: GitHub masks exact secret values in logs, but not transformed ones (base64, substrings). Don’t echo secrets, or run commands in debug mode that print them.
- Pull requests from forks don’t receive secrets by default. Keep it that way. Be careful with
pull_request_targetworkflows, which run with secrets on untrusted code. - Third-party actions run with access to your environment. Pin them to a full commit SHA (
uses: owner/action@<sha>) for sensitive workflows. - Artifacts and caches: don’t write secret files into uploaded artifacts.
- Set minimal
permissions:forGITHUB_TOKEN(default to read-only).
If a secret leaks: rotate it immediately (generate a new key or token, update the secret, and revoke the old one). See secrets management.
Self-hosted runners
Secrets are available on whatever machine runs the job. Use self-hosted runners only for private repositories, and keep them isolated. See self-hosted GitHub runner.
Quick reference
| Task | Command / syntax |
|---|---|
| Set secret | gh secret set NAME |
| Environment secret | gh secret set NAME --env production |
| Use in workflow | env: X: ${{ secrets.NAME }} |
| Non-secret config | vars.NAME |
| Protect deploys | environment: production + required reviewers |
| No static cloud keys | OIDC (id-token: write) |
Related: GitHub Actions Docker builds and Ansible Vault.