The most common beginner mistake with GitHub Actions secrets isn't accidentally committing them — it's putting them in the wrong place and then wondering why the deployment job can't read them.
Three Layers of Secrets in GitHub Actions
1. Repository Secrets
Available to all workflows in the repository:
GitHub repo → Settings → Secrets and variables → Actions → New repository secret
Use for: CODECOV_TOKEN, API keys used in tests, non-environment-specific values.
2. Environment Secrets
Only available when a job targets a specific environment:
GitHub repo → Settings → Environments → New environment → Add secret
Use for: DB_PASSWORD (different for staging vs production), SSH_PRIVATE_KEY (different server per environment), APP_KEY (must be different per environment).
3. Variables (Not Secrets)
Plaintext, visible in logs. Use for: APP_URL, APP_ENV, non-sensitive configuration.
The .env.example Contract
Your .env.example is a contract. Every key your application reads must appear there. Every CI workflow copies this file and overrides specific values.
Rules for .env.example:
- All sensitive values:
APP_KEY=,DB_PASSWORD=,STRIPE_KEY=— left empty, filled by CI or developer - All non-sensitive defaults filled in:
APP_NAME=MyApp,DB_CONNECTION=mysql - Every key that exists in production must exist here
Wrong pattern — hardcoding secrets in YAML:
- name: Run tests
run: ./vendor/bin/pest
env:
DB_PASSWORD: supersecretpassword123 # visible in git history forever
Right pattern — using secrets:
- name: Run tests
run: ./vendor/bin/pest
env:
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
Passport / OAuth Keys
Laravel Passport generates RSA key pairs (oauth-private.key, oauth-public.key). These are gitignored. In CI, generate them fresh each run:
- name: Generate Passport keys
run: php artisan passport:keys --force
Run this step after composer install, before migrations or tests. The --force flag overwrites if they already exist — safe in CI since the runner starts fresh every time.
For production, generate once locally and store the values as base64-encoded environment secrets:
# Run locally, then store the output as GitHub environment secrets
php artisan passport:keys
cat storage/oauth-private.key | base64
cat storage/oauth-public.key | base64
In your deploy script, decode them back:
echo "$PASSPORT_PRIVATE_KEY" | base64 -d > storage/oauth-private.key
echo "$PASSPORT_PUBLIC_KEY" | base64 -d > storage/oauth-public.key
Using Environment Secrets in Deploy Jobs
deploy-production:
name: Deploy to Production
runs-on: ubuntu-latest
needs: tests
if: github.ref == 'refs/heads/main'
environment: production # unlocks the 'production' environment secrets
steps:
- uses: actions/checkout@v4
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.VPS_HOST }}
username: deploy
key: ${{ secrets.VPS_SSH_KEY }}
The environment: production line is what unlocks the secrets you added under the production environment in GitHub settings. If you omit it, the job can only access repository-level secrets.
What to Never Put in YAML
- Real passwords — use
${{ secrets.X }} - Production API keys — use
${{ secrets.X }} - Private keys — base64-encode, store as secret
- Production
APP_KEY— environment secret per environment
The test DB password (secret) is fine in plain YAML because the MySQL container only lives for the duration of the CI job and is not accessible from outside the runner network.
In the next post we wire up the final piece: a deploy job that SSHs into your VPS and ships the new release with zero downtime.
Originally published at dineshstack.com — read the full version with code samples and updates there.
Top comments (0)