Configuration and Secrets Management in GitHub Actions: A Systematic Approach
Published on 2026-02-18
When a project moves beyond local development and starts using CI/CD, a fundamental question arises: how to securely pass configuration parameters into build and deployment pipelines. This concerns not only database passwords, but also container registry tokens, SSH keys, JWT signing keys, message queue connection strings, and any other parameters that must not be stored in plain text.
Sensitive data ending up in a Git repository is not just bad practice. The commit history is kept forever. Even if a secret is removed in a later commit, it has already become part of the history and can be retrieved via git log, git show or when cloning a fork. In a corporate environment this becomes a real risk during audits, team expansion, or if repository access is leaked.
To solve this problem, GitHub provides built-in configuration management mechanisms in GitHub Actions: Secrets and Variables.
Architectural difference: Secrets and Variables
Separating data into two types is not a formality, but an element of security architecture.
Secrets
Intended for storing sensitive data:
MYSQL_PASSWORDREDIS_PASSWORDJWT_PRIVATE_KEYSSH_PRIVATE_KEYGHCR_TOKENAWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEY
The main property of secrets is that GitHub automatically masks their values in execution logs. If a secret value appears in stdout, it will be replaced with ***. This significantly reduces the risk of accidental leakage through command output.
Variables
Used for non-sensitive configuration parameters:
APP_ENVDOMAINMYSQL_HOSTREDIS_PORTDOCKER_IMAGE_NAMEDEPLOY_PATH
Variables are not masked in logs. They remain readable, which makes diagnosing errors and debugging easier.
Storage levels: organization, repository, environment
GitHub provides three storage levels for parameters:
- Organization
- Repository
- Environment
Organization level
Suitable for tokens used across many services. For example, if one GHCR Personal Access Token is used by multiple microservices, it makes sense to store it centrally.
Repository level
The most common option. Navigate via:
Settings → Secrets and variables → Actions
There the data is separated into Secrets and Variables tabs.
Environment
Allows you to:
- store different sets of secrets for
stagingandproduction; - restrict access to the environment;
- configure required deployment approvals;
- set branch protection for a specific environment.
This is especially important for production infrastructure.
Usage in a workflow
Inside a workflow data is referenced through different contexts:
${{ secrets.NAME }}${{ vars.NAME }}
Example:
env:
DB_PASSWORD: ${{ secrets.MYSQL_PASSWORD }}
DB_HOST: ${{ vars.MYSQL_HOST }}
DB_PORT: ${{ vars.MYSQL_PORT }}
Substitution happens at the GitHub Expression Engine level before the job is passed to the runner.
Authenticating to a container registry
A typical scenario is authentication to GHCR or Docker Hub:
- name: Login to GHCR
run: |
echo "${{ secrets.GHCR_TOKEN }}" | docker login ghcr.io \
-u "${{ github.actor }}" \
--password-stdin
Using --password-stdin is preferable to passing the token via an argument, because process arguments can be visible via ps on a self-hosted runner.
Dynamically generating .env
Storing a .env file in the repository is an architectural mistake. It’s safer to generate it dynamically:
- name: Create .env
run: |
cat > .env <<EOF
APP_ENV=${{ vars.APP_ENV }}
DB_URL=mysql://${{ vars.MYSQL_USER }}:${{ secrets.MYSQL_PASSWORD }}@${{ vars.MYSQL_HOST }}:3306/${{ vars.MYSQL_DB }}
CACHE_URL=redis://${{ vars.REDIS_USER }}:${{ secrets.REDIS_PASSWORD }}@${{ vars.REDIS_HOST }}:${{ vars.REDIS_PORT }}
EOF
Benefits:
- no secrets stored in Git;
- centralized configuration management;
- simplified rotation of credentials.
Integration with Docker Compose
services:
app:
env_file:
- .env
Using Environments
Example production environment:
jobs:
deploy:
environment: production
GitHub will automatically:
- pull production secrets;
- apply approval rules;
- record the deployment history.
This protects against accidentally deploying a staging configuration to production.
Working with multiline keys
PEM keys often cause problems due to line breaks.
Local encoding:
base64 -w 0 private.pem > private.pem.b64
In the workflow:
- name: Restore private key
run: |
echo "${{ secrets.JWT_PRIVATE_KEY_B64 }}" | base64 -d > private.pem
This prevents corruption of the key structure.
Security of self-hosted runners
If a self-hosted runner is used:
- secrets are passed to it in plain text;
- the runner must be isolated;
- it is not recommended to run it on shared servers;
- root access should be restricted;
- using ephemeral runners is preferable.
Otherwise CI/CD becomes a point of compromise for the entire infrastructure.
Secret rotation
Updating a password comes down to:
- Changing the value in GitHub.
- Re-running the workflow.
There is no need to:
- edit files on the server;
- rebuild images manually;
- connect via SSH.
Common mistakes
- Using
set -xin bash. - Running
printenvin a production job. - Logging environment variables.
- Passing secrets via Docker build args.
- Storing
.envin the repository. - Using a single token for staging and production.
Conclusion
The separation into Secrets and Variables is a foundation of CI/CD architecture.
A properly built system allows you to:
- centralize configuration;
- minimize the risk of leakage;
- ensure controlled deployment;
- simplify auditing;
- speed up credential rotation;
- separate responsibilities between developers and administrators.
In mature DevOps practice, configuration is treated as a managed layer of infrastructure. GitHub Actions provides all the necessary tools to build such a layer — it only remains to apply them systematically and in a disciplined way.