
September 09, 2026
14 min read
By Kokil Thapa | Last reviewed: September 2026
Pipeline logs are public to anyone with repo access. A leaked database password there can undo months of hardening work. You must handle secrets in CI/CD pipelines safely from day one, not after an incident. On production Laravel apps I deploy with GitLab CI and Deployer 7, credentials touch the runner, the build output, SSH sessions, and server-side .env files. Each handoff is a leak point. This guide maps the full flow: store, inject, mask, rotate, and audit pipeline secrets without turning your YAML into a credential dump.
What is the safest way to handle secrets in CI/CD pipelines?
The safest pattern treats the pipeline as untrusted infrastructure. Secrets never live in Git. They never appear in job artifacts. They reach the runtime through a narrow, audited channel. Think short-lived credentials, least privilege, and automatic redaction.
In practice, that means three layers. First, a secrets store: GitLab CI/CD variables, GitHub Actions secrets, HashiCorp Vault, AWS Secrets Manager, or Azure Key Vault. Second, injection at job start: the runner reads secrets into environment variables or temporary files. Third, enforcement: protected branches, masked output, and scanners like Gitleaks on every push.
For small PHP and Laravel teams, platform-native secret stores cover 80% of needs. You do not need Vault on day one. You do need protected variables, branch rules, and a rotation calendar. When you outgrow that, external vaults integrate cleanly with the same injection pattern.
I follow the same baseline on sister legal-tech sites that share a Deployer 7 pipeline on shared EC2. One compromised variable there could affect multiple domains. Shared runners raise the stakes for scoping and masking.
Core principles to enforce
- Separate by environment. Staging and production secrets must never share the same CI variable name without an environment scope.
- Minimise lifetime. Prefer deploy keys and database users that expire or rotate quarterly.
- Assume logs are hostile. Every
echo,printenv, and debug dump is a potential leak. - Scan before merge. Run Gitleaks or similar on every push so history stays clean.
- Audit access. Review who can edit CI variables monthly. Remove ex-team members the same day they leave.
How do you store secrets in GitLab CI and GitHub Actions?
Both platforms encrypt secrets at rest and inject them as environment variables at job runtime. The configuration differs, but the mental model is identical: define once in the UI or API, reference by name in YAML, never by value.
GitLab CI/CD variables
In GitLab, open Settings → CI/CD → Variables. Create each secret with these flags where applicable:
- Masked — hides the value if it appears in job logs (GitLab validates maskable patterns).
- Protected — available only on protected branches and tags.
- Environment scope — binds a variable to
production,staging, or a wildcard.
Reference variables in .gitlab-ci.yml without quoting the value:
deploy_production:
stage: deploy
environment:
name: production
url: https://example.com
script:
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
- dep deploy production -o forward_agent=false
only:
- main
variables:
DEPLOY_ENV: production For Laravel deploys, I store SSH_PRIVATE_KEY, DB_PASSWORD (for migration jobs only), and third-party API keys as masked, protected variables. The shared .env on the server holds runtime config. The pipeline never writes secrets into the Git tree. See the full Laravel GitLab CI deploy walkthrough for the end-to-end pattern.
GitHub Actions secrets
GitHub stores secrets under Settings → Secrets and variables → Actions. Repository secrets suit single-repo apps. Organisation secrets suit multi-repo teams with consistent naming.
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- name: Deploy via SSH
env:
SSH_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
run: |
install -m 600 <(echo "$SSH_KEY") ~/.ssh/id_ed25519
ssh -o StrictHostKeyChecking=yes deploy@"$DEPLOY_HOST" 'cd /var/www && git pull' GitHub also supports environment protection rules. You can require manual approval before production jobs read production-scoped secrets. That single gate stops many Friday-afternoon mistakes.
Choosing between platforms? The GitHub Actions vs GitLab CI comparison for 2026 covers secret-handling trade-offs alongside runner costs and PHP support.
Which CI/CD secrets mistakes cause the most production incidents?
Most leaks are boring. Someone committed an .env file. Someone ran php artisan config:show in CI with debug on. Someone pasted a Stripe key into a Slack thread linked from a failed job. The fix is rarely exotic. It is discipline plus automation.
Fork pull request exposure
On public repositories, fork-based PRs run with read-only tokens. Secrets must not be exposed to those jobs. GitLab hides protected variables from fork pipelines by default when configured correctly. GitHub does not expose secrets to workflows triggered from forks unless you use pull_request_target, which is dangerous if misused.
Never pass untrusted PR code elevated secret access. Run tests without production credentials. Use label-gated workflows for maintainer-reviewed runs that need secrets.
Debug output and Laravel-specific traps
Laravel makes several footguns easy in CI:
php artisan config:cacheafter injecting env vars bakes secrets into bootstrap cache — ensure that cache never lands in artifacts.APP_DEBUG=truein CI can dump stack traces with env values on failure.- Telescope, Debugbar, and Ray must stay disabled in pipeline environments.
- Composer scripts that dump
$_ENVduring post-install hooks belong nowhere near production keys.
For PHP 8.3+ and Laravel 12 or 13 projects, keep APP_KEY generation on the server or in a one-time setup job. Do not regenerate it on every deploy. That pattern avoids accidental key rotation that invalidates sessions and encrypted columns.
How do you inject secrets during deployment without exposing them in logs?
Injection is where theory meets messy shell scripts. The goal is simple: secrets exist in memory or short-lived files for seconds, not in persistent job output.
SSH keys for Deployer and rsync
I deploy most Laravel apps with Deployer 7 over SSH. The private key never touches disk unencrypted on the runner longer than necessary:
before_script:
- eval $(ssh-agent -s)
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
- mkdir -p ~/.ssh && chmod 700 ~/.ssh
- echo "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
after_script:
- ssh-add -D || true Store SSH_KNOWN_HOSTS as a variable. Pin host keys. Do not rely on StrictHostKeyChecking=no. That opens you to person-in-the-middle attacks during deploy.
Database credentials for migration jobs
Run migrations in a dedicated job with the narrowest DB user possible. Grant ALTER only during migration windows if your host allows split users. Pass credentials via env, not CLI flags:
migrate_production:
stage: migrate
script:
- export DB_HOST="$PROD_DB_HOST"
- export DB_DATABASE="$PROD_DB_DATABASE"
- export DB_USERNAME="$PROD_DB_USERNAME"
- export DB_PASSWORD="$PROD_DB_PASSWORD"
- php artisan migrate --force --no-interaction
environment: production
when: manual Mark migration jobs when: manual on small teams. One extra click beats an automated schema change on the wrong database. I have seen staging credentials pointed at production because variable scopes were misconfigured.
Writing .env on the server without logging values
The server-side .env should live in Deployer's shared directory. Update it with a heredoc or Ansible Vault, not by echoing from CI logs. For Ansible-based setups, see Ansible Vault for encrypted variable files.
# On server — not in CI logs
cat > /var/www/shared/.env <<'ENVEOF'
APP_ENV=production
APP_KEY=base64:...
DB_PASSWORD=...
ENVEOF
chmod 600 /var/www/shared/.env CI should trigger deploy, not print production config. If you must sync env keys, use SSH with a restricted user and a script that writes locally on the target host.
Should you use a dedicated secrets manager for CI/CD pipelines?
Not on day one for a single Laravel app on a VPS. Yes when you run multiple environments, multiple clients, or compliance audits that demand access logs and automatic rotation.
Platform variables hit limits quickly in these cases:
- More than twenty distinct secrets across five projects with shared naming drift.
- Regulatory requirements for credential rotation every 90 days with proof.
- Dynamic database credentials where each pipeline job gets a unique user that expires after the job.
- Multi-cloud deploys where the same secret must reach AWS, Azure, and on-prem runners.
HashiCorp Vault, AWS Secrets Manager, and Azure Key Vault all support JWT/OIDC auth from CI jobs. The runner authenticates with a short-lived identity token, fetches secrets, and never stores long-lived API keys on the runner image. The GitLab CI secrets documentation describes native Vault integration. GitHub documents a similar pattern for using secrets in Actions.
| Approach | Setup effort | Rotation | Audit log | Typical monthly cost |
|---|---|---|---|---|
| GitLab/GitHub native variables | Low — minutes | Manual | Platform audit (tier-dependent) | Rs 0–3,000 (~USD 0–22) |
| Ansible Vault in repo | Medium — key ceremony | Manual re-encrypt | Git blame only | Rs 0 |
| AWS Secrets Manager | Medium — IAM policies | Automatic optional | CloudTrail | Rs 400+/secret (~USD 3+) |
| HashiCorp Vault | High — cluster ops | Dynamic engines | Full request log | Rs 13,000+ infra (~USD 100+) |
For Nepal-based agencies billing in NPR, native variables plus strict process beats an idle Vault cluster on a Rs 5,000/month VPS. Upgrade when client contracts require it. Our AWS Secrets Manager pipeline guide covers the migration path when you reach that point.
Rotation checklist that actually gets done
Rotation fails when it depends on memory. Put it in the calendar and automate reminders:
- Quarterly: SSH deploy keys, payment gateway API keys (Stripe, eSewa, Khalti).
- Monthly: review CI variable editor list and remove unused entries.
- On every team change: rotate all secrets the departing member could view.
- After any suspected leak: rotate immediately, then run Gitleaks across full Git history.
- Generate new keys with a strong random password generator — never reuse passwords across services.
Document rotation in a private runbook. Not in the repo. Link to the runbook from your internal wiki only.
How do you harden CI/CD pipelines with DevSecOps practices?
Secret handling is one slice of shifting security left in CI/CD. Treat it as part of pipeline design, not a security-team afterthought.
Scan every commit
Add a secret_scan stage before build:
secret_scan:
stage: test
image:
name: ghcr.io/gitleaks/gitleaks:latest
entrypoint: [""]
script:
- gitleaks detect --source . --verbose --redact
allow_failure: false Run the same scan locally before push. Developers catch issues faster than CI. Pair scanning with pre-commit hooks where the team tolerates the friction.
Restrict runner privileges
Self-hosted runners on production subnets are convenient and risky. A compromised job inherits network access to internal databases. Isolate runners in a DMZ. Use outbound firewall rules. Never mount Docker sockets into untrusted jobs unless you accept container escape risk.
For teams I support through Linux system administration, runner hardening includes separate deploy users, sudo limited to reload commands, and UFW rules that allow SSH only from the runner IP.
Separate build and deploy credentials
Build jobs need Composer and npm tokens. Deploy jobs need SSH. Test jobs need nothing sensitive. Split stages and variable scopes so a failed unit test job never sees production SSH keys.
On a Laravel Livewire booking platform, payment gateway callbacks and supplier API keys stay on the server. CI runs Pest tests against an isolated database. Deployer swaps releases and reloads PHP-FPM. No secret crosses stage boundaries it does not need.
OWASP-aligned pipeline hygiene
The OWASP Secrets Management Cheat Sheet recommends separating secrets from code, restricting access, and monitoring for exposure. Map those controls directly to your YAML stages and variable flags. If you cannot explain which job reads which secret, your scoping is too loose.
Broader pipeline guidance lives in CI/CD secrets management best practices and CI/CD best practices for small teams. PHP-specific variable naming and Composer cache keys are covered in GitLab CI for PHP projects.
Key Takeaways
- Store credentials in masked, protected CI variables or an external vault — never in Git, YAML values, or job artifacts.
- Scope secrets by environment and pipeline stage so test jobs cannot read production SSH keys or payment API tokens.
- Inject via environment variables and short-lived temp files; use
after_scriptcleanup and never echo secret values. - Run Gitleaks on every pipeline and rotate credentials quarterly or immediately after any team or leak event.
- Keep server-side
.envin Deployer shared paths; let CI trigger deploys without printing config to logs. - Upgrade from native variables to Vault or cloud secret managers when audit, rotation, or multi-tenant scale demands it.
People Also Ask
Can CI/CD pipeline logs expose secrets even when variables are masked?
Yes. Masking only redacts known patterns. Base64-encoded values, URL-encoded passwords, and secrets printed by application debug tools can slip through. Avoid verbose curl output, disable Laravel debug in CI, and never run env or printenv in scripts. Treat any custom logging as potentially public.
Should .env files ever be committed to a private repository?
No. Private repos become public by accident, get forked, or lose access control when staff leave. Commit .env.example with empty placeholders only. Production values belong in CI variables, server shared directories, or encrypted vault files — not in Git history that Gitleaks must chase forever.
How often should you rotate CI/CD secrets?
Rotate SSH deploy keys and third-party API tokens at least every 90 days. Rotate immediately when a team member with CI access leaves or when scanning detects a leak. Payment and OAuth credentials should follow vendor guidance, which is often faster than quarterly for high-risk keys.
Are self-hosted GitLab runners safer for secrets than shared SaaS runners?
Self-hosted runners give you network isolation and disk control, but you inherit patching and hardening duty. Shared SaaS runners are ephemeral, which limits persistence after a job ends. Either can be safe if secrets are scoped, masked, and never baked into runner images. The weaker link is usually misconfigured variable protection, not runner type alone.
Build pipelines you can trust
Handle secrets in CI/CD pipelines safely by treating every job log, artifact, and fork PR as a potential exposure surface. Start with protected masked variables, stage-scoped access, Gitleaks in CI, and Deployer-friendly SSH injection. Add a vault when audit and rotation requirements outgrow the platform UI. If you want help hardening a Laravel or PHP deploy pipeline on VPS or shared hosting, contact us for a CI/CD security review or explore custom software development and ongoing maintenance options. You can also review related work in our portfolio and generate strong credentials with the Base64 encoder when configuring key-based auth. Read more from Kokil Thapa on production engineering, or browse the full blog archive for deployment guides.
Frequently Asked Questions
0 Comments
Leave a comment
Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

