Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Handle Secrets in CI/CD Pipelines Safely

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.

CI/CD Secrets — Zero-Trust FlowGit RepoNo secretsVault / StoreEncrypted at restCI RunnerEphemeral injectDeploySSH / APINever Do ThisHard-code API keys in .gitlab-ci.yml or workflow YAMLCommit .env files or echo $SECRET in scriptsPass secrets as plain CLI args visible in ps outputReuse production DB creds in feature-branch pipelines
Handle secrets in CI/CD pipelines safely by keeping credentials out of Git and injecting them only at runner runtime

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

  1. Separate by environment. Staging and production secrets must never share the same CI variable name without an environment scope.
  2. Minimise lifetime. Prefer deploy keys and database users that expire or rotate quarterly.
  3. Assume logs are hostile. Every echo, printenv, and debug dump is a potential leak.
  4. Scan before merge. Run Gitleaks or similar on every push so history stays clean.
  5. 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.

Where CI/CD Secrets LeakJob Logsecho / curl -vArtifacts.env in zipGit Historyforce-pushed keyFork PRssecret in YAMLMitigation LayerMasking + protected vars + Gitleaks + no secrets on fork pipelinesSafe PatternWrite secrets to temp files with 0600 perms; delete in after_script
Most pipeline secret leaks happen through logs, build artifacts, Git history, or unprotected fork workflows — not through encrypted variable stores

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:cache after injecting env vars bakes secrets into bootstrap cache — ensure that cache never lands in artifacts.
  • APP_DEBUG=true in CI can dump stack traces with env values on failure.
  • Telescope, Debugbar, and Ray must stay disabled in pipeline environments.
  • Composer scripts that dump $_ENV during 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.

Secrets Store Options for CI/CDPlatform VariablesGitLab / GitHub native+ Fast setup, free tier+ Masking built-in- Limited rotation API- No dynamic DB credsBest for: solo / small teamsLaravel VPS deploysExternal VaultVault / AWS SM / Azure KV+ Central audit trail+ Dynamic short-lived creds- Ops overhead- Extra cost at scaleBest for: multi-app / complianceKubernetes + microservicesgrow into
Platform CI variables suit most Laravel VPS pipelines; external vaults add rotation and audit when teams or compliance requirements grow

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.

ApproachSetup effortRotationAudit logTypical monthly cost
GitLab/GitHub native variablesLow — minutesManualPlatform audit (tier-dependent)Rs 0–3,000 (~USD 0–22)
Ansible Vault in repoMedium — key ceremonyManual re-encryptGit blame onlyRs 0
AWS Secrets ManagerMedium — IAM policiesAutomatic optionalCloudTrailRs 400+/secret (~USD 3+)
HashiCorp VaultHigh — cluster opsDynamic enginesFull request logRs 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:

  1. Quarterly: SSH deploy keys, payment gateway API keys (Stripe, eSewa, Khalti).
  2. Monthly: review CI variable editor list and remove unused entries.
  3. On every team change: rotate all secrets the departing member could view.
  4. After any suspected leak: rotate immediately, then run Gitleaks across full Git history.
  5. 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.

Laravel Pipeline — Secret Scoping by StagelintNo secretstestCI DB onlybuildnpm tokendeploySSH keymigrateDB adminProduction Example — Booking AppAdventure Third Pole Trek: Livewire + Laravel 12 on VPSTest uses SQLite in-memory — zero prod credentials in CIDeploy stage alone reads SSH_PRIVATE_KEY (protected + masked)Payment keys live only on server .env — never in pipeline YAML
Separate CI/CD secret scopes by pipeline stage so test and lint jobs never receive production SSH or payment credentials

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_script cleanup 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 .env in 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

Treat the pipeline as untrusted infrastructure. Secrets never live in Git, job artifacts, or YAML values. Store them in GitLab CI/CD variables, GitHub Actions secrets, or an external vault. Inject at job runtime only through environment variables or short-lived files. Enable masked output, protect variables to production branches, scope by environment, run Gitleaks on every push, and rotate on a schedule. For small Laravel teams on VPS deploys, platform-native stores cover most needs without Vault on day one.

Open Settings, then CI/CD, then Variables. Create each secret with Masked to hide values in logs, Protected so only protected branches and tags can read it, and Environment scope to bind it to production or staging. Reference by name in .gitlab-ci.yml, never by value. For Laravel deploys with Deployer 7, store SSH_PRIVATE_KEY, DB_PASSWORD for migration jobs only, and third-party API keys this way. Runtime config stays in the server-side shared .env, not in the Git tree.

Add secrets under Settings, Secrets and variables, Actions. Repository secrets suit single-repo apps; organisation secrets suit multi-repo teams with consistent naming. Reference them in workflow YAML as ${{ secrets.SECRET_NAME }} and pass into job env blocks. Use environment protection rules on production so jobs require manual approval before reading production-scoped secrets. That gate stops many accidental production deploys. The mental model matches GitLab: define once in the UI, inject at runtime, never commit the value.

Most leaks are boring, not exotic. Someone committed an .env file. Someone ran php artisan config:show in CI with debug on. Someone pasted a Stripe key into Slack linked from a failed job. Fork pull request workflows expose secrets when misconfigured. Debug dumps, build artifacts, and Git history are common leak paths. Laravel footguns include config:cache baking secrets into bootstrap cache that lands in artifacts, APP_DEBUG=true dumping env on failure, and Telescope or Debugbar enabled in pipeline environments.

Secrets should exist in memory or short-lived files for seconds, not in persistent job output. Load SSH keys via ssh-agent from a CI variable, strip carriage returns, pin known hosts, and run ssh-add -D in after_script. Pass database credentials via exported env vars, not CLI flags. Mark production migration jobs as manual. Write server-side .env in Deployer's shared directory on the host with a heredoc, not by echoing values from CI. CI should trigger deploy, not print production config.

Not on day one for a single Laravel app on a VPS. Yes when you run multiple environments, multiple clients, or compliance audits requiring access logs and automatic rotation. Platform variables hit limits with more than twenty secrets across five projects, 90-day rotation with proof, dynamic per-job database credentials, or multi-cloud deploys. HashiCorp Vault, AWS Secrets Manager, and Azure Key Vault support JWT or OIDC auth from CI jobs. Native GitLab or GitHub variables plus strict process beats an idle Vault cluster on a Rs 5,000 per month VPS until contracts require more.

GitLab or GitHub native variables: Rs 0 to 3,000 per month (~USD 0 to 22). AWS Secrets Manager: Rs 400+ per secret (~USD 3+). HashiCorp Vault: Rs 13,000+ infrastructure (~USD 100+). Ansible Vault in repo costs nothing but needs manual key ceremony.

Quarterly for SSH deploy keys and payment gateway API keys including Stripe, eSewa, and Khalti. Monthly, review the CI variable editor list and remove unused entries. On every team departure, rotate all secrets the leaving member could view. After any suspected leak, rotate immediately and run Gitleaks across full Git history. Document rotation in a private runbook outside the repo.

Add a secret_scan stage running Gitleaks on every commit with allow_failure false. Restrict self-hosted runner privileges: isolate runners in a DMZ, use outbound firewall rules, avoid mounting Docker sockets into untrusted jobs. Separate build credentials from deploy credentials so test jobs never see production SSH keys or payment tokens. Map OWASP Secrets Management controls to YAML stages and variable flags. If you cannot explain which job reads which secret, scoping is too loose.

Yes, on public repositories. Fork-based PRs run with read-only tokens, but misconfiguration exposes production credentials. GitLab hides protected variables from fork pipelines when configured correctly. GitHub does not expose secrets to fork-triggered workflows 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 genuinely need secrets.

php artisan config:cache after injecting env vars bakes secrets into bootstrap cache, which must never land in artifacts. APP_DEBUG=true in CI dumps stack traces with env values on failure. Telescope, Debugbar, and Ray must stay disabled in pipeline environments. Composer post-install scripts that dump $_ENV belong nowhere near production keys. For PHP 8.3+ and Laravel 12 or 13, generate APP_KEY on the server or in a one-time setup job, not on every deploy, to avoid accidental session and encrypted-column invalidation.

Store SSH_PRIVATE_KEY and SSH_KNOWN_HOSTS as masked, protected variables. In before_script, start ssh-agent, pipe the key through tr -d carriage returns into ssh-add, create ~/.ssh with mode 700, and append known hosts. Pin host keys; do not use StrictHostKeyChecking=no. Run dep deploy with forward_agent=false. Clear the agent in after_script with ssh-add -D. The private key should never sit unencrypted on disk longer than necessary.

No. Staging and production secrets must never share the same CI variable name without an environment scope. Misconfigured scopes have pointed staging credentials at production databases. Use GitLab environment scope or GitHub environment protection to bind each secret to the correct target.

Masking hides secret values if they appear in job logs. GitLab validates maskable patterns when you enable the Masked flag on CI/CD variables. GitHub redacts secrets referenced through the secrets context in workflow output. Pipeline logs are visible to anyone with repo access, so every echo, printenv, and debug dump is a potential leak. Assume logs are hostile and combine masking with discipline: never echo secret values, and disable debug tooling in pipeline environments.

Use a dedicated migration job with the narrowest database user possible, granting ALTER only during migration windows if your host allows split users. Pass credentials via exported environment variables, not CLI flags. Set environment to production and mark the job when: manual on small teams so one extra click prevents automated schema changes on the wrong database. Keep DB_PASSWORD scoped to migration jobs only, not lint or test stages.

Share this article

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.

Quick Contact Options
Choose how you want to connect me: