DEV Community

Managing Secrets and Env Vars in GitHub Actions

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.

Comments

No comments yet. Start the discussion.