Managing SSH, Composer Auth and ENV Secrets Securely
CI/CD variables are the configuration contract of every GitLab pipeline. Get SSH keys, Composer auth tokens and ENV files wrong, and you risk exposed secrets, broken builds and unclear environment separation. This article shows how to set up variables in GitLab correctly, scope them properly and consume them safely in pipelines.
Table of Contents
- 1. What CI/CD variables are for, and what they are not
- 2. Variable types: Protected, Masked and File
- 3. Environment scope: separating staging and production
- 4. Storing an SSH private key securely in GitLab
- 5. Composer auth: Magento Marketplace and private packages
- 6. The ENV file and app/etc/env.php as a variable
- 7. Consuming variables correctly in .gitlab-ci.yml
- 8. Comparison: insecure vs. correct variable configuration
- 9. Common failure patterns and how to spot them
- 10. Summary
- 11. FAQ
1. What CI/CD variables are for, and what they are not
CI/CD variables in GitLab are the mechanism that keeps configuration values, credentials and secrets out of the repository while still making them available to pipelines. They are not a full secrets management solution like HashiCorp Vault, but for most Magento deployment teams they are exactly the right place for SSH keys, Composer credentials, database passwords and path variables. The principle is simple: anything that differs between environments or must stay secret belongs in a variable, not in the code.
What CI/CD variables do not do: they are no substitute for structured secrets management across dozens of services or highly sensitive credentials that need rotation. For Magento projects of typical team size, though, they cover the need completely, as long as scopes, protected and masked flags are applied consistently. A common mistake is treating variables as plain environment variables without paying attention to visibility, scope and masking. That opens security gaps that often only surface once a staging secret accidentally shows up in a production build.
2. Variable types: Protected, Masked and File
Protected variables are only visible in pipelines running on protected branches or tags. This is the primary safeguard against unintended secret leakage: a feature branch cannot reach production credentials because the branch itself is not protected. For every production-specific variable, SSH keys to the production server, database passwords, API keys, the protected flag is mandatory. Masked variables are obscured in job logs. The catch: the value must not contain line breaks and must be at least eight characters long. That means masking does not fully work for multi-line values such as SSH private keys.
The third type is file variables. Instead of the value sitting directly in the environment variable, the content is written to a temporary file on the runner, and the variable holds the path to that file. This is the right choice for SSH private keys, auth.json files and env.php content, because many tools expect a file path rather than the content directly as a variable. Anyone who creates an SSH key as a regular variable instead of a file variable has to handle writing it to a file inside the job script themselves, which is more error prone. The combination of protected and file is the most robust choice for most critical credentials.
# Example: .gitlab-ci.yml, consuming CI/CD Variables correctly
variables:
# Pipeline-level defaults (non-secret)
DEPLOY_PATH: "/var/www/magento"
RELEASE_RETENTION: "5"
GIT_STRATEGY: fetch
deploy:production:
stage: deploy
environment: production
only:
- tags
before_script:
# SSH_PRIVATE_KEY is a File Variable, $SSH_PRIVATE_KEY contains the file path
- chmod 600 "$SSH_PRIVATE_KEY"
- eval "$(ssh-agent -s)"
- ssh-add "$SSH_PRIVATE_KEY"
# Write known hosts from variable
- mkdir -p ~/.ssh
- echo "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
- chmod 644 ~/.ssh/known_hosts
script:
- rsync -az --delete ./ "$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/releases/$CI_PIPELINE_ID/"
- ssh "$DEPLOY_USER@$DEPLOY_HOST" "ln -sfn $DEPLOY_PATH/releases/$CI_PIPELINE_ID $DEPLOY_PATH/current"
3. Environment scope: separating staging and production
Environment scope is the most important tool for making sure staging credentials never end up in a production build, and vice versa. Every variable can be restricted to a specific environment name or a wildcard pattern. A variable with scope production is only visible in jobs whose environment: key points to that name. A scope of staging* with a wildcard covers all staging environments.
In practice this means: two variables with the same name, one scoped to staging and one scoped to production, keep the environments cleanly separated without touching the pipeline YAML at all. The build job always sees the variable that matches its environment. A mistake teams make regularly is creating a variable with scope * (all environments) and not noticing that feature branches then get access to the same credentials. Anyone who wants to avoid that risk needs to define a dedicated scope for every real environment value, even if that means more maintenance up front.
4. Storing an SSH private key securely in GitLab
The SSH private key is one of the most sensitive variables in a Magento pipeline: it grants direct server access. The correct approach is to create the key as a file variable with the protected flag set. The public key goes into ~/.ssh/authorized_keys on the target server for the deployment user. The target server's fingerprint is verified manually once and stored as a separate SSH_KNOWN_HOSTS variable. Setting StrictHostKeyChecking=no in the pipeline bypasses that protection entirely, and that is a security problem, not a convenience.
The deployment user on the target server should be a dedicated account without sudo rights, with access limited to the release directory. The principle of least privilege applies here too: the pipeline does not need root access. Another common problem is creating the SSH key as a regular variable instead of a file variable. That causes the trailing line break of the private key to get lost, which invalidates the key, an error that shows up in the job log as a cryptic SSH failure message and costs unnecessary debugging time.
5. Composer auth: Magento Marketplace and private packages
Magento projects need Composer credentials for the Magento Marketplace (repo.magento.com) and often for other private package sources such as Hyva Themes or other commercial extensions. These credentials belong in GitLab as a COMPOSER_AUTH variable, as a JSON object in the format Composer understands natively. In the build job, the variable is written to the ~/.composer/auth.json file, or passed directly via the COMPOSER_AUTH environment variable.
Storing Composer credentials in the repository as auth.json is a classic mistake that leads to immediate account compromise in public repositories and forces unnecessary credential rotation in private repositories every time a developer offboards. The GitLab variable is the right place for it: it is not visible in the repository history, can be scoped per environment and can be rotated without touching the code. For CI systems managing multiple Magento projects, group-level variables that are available to every project in the group are a good fit.
# Build job consuming COMPOSER_AUTH as environment variable
build:composer:
stage: build
image: php:8.4-cli
variables:
# COMPOSER_AUTH is set as a GitLab CI/CD Variable (masked, protected)
# Format: {"http-basic": {"repo.magento.com": {"username": "...", "password": "..."}}}
COMPOSER_CACHE_DIR: ".cache/composer"
cache:
key: composer-$CI_COMMIT_REF_SLUG
paths:
- .cache/composer/
script:
# Composer reads COMPOSER_AUTH env variable automatically
- composer install --no-dev --prefer-dist --no-interaction --no-ansi
- composer dump-autoload --optimize --no-dev
artifacts:
paths:
- vendor/
expire_in: 1 day
6. The ENV file and app/etc/env.php as a variable
Magento's app/etc/env.php holds database connections, Redis configuration, session settings and crypt keys, all highly sensitive and environment specific. This file must never live in the repository. In a GitLab deployment model there are two common approaches: either the file lives permanently in a shared directory on the server and is symlinked in on each release, or its content is stored as a file variable in GitLab and transferred to the server on every deploy.
The first approach is the more robust one for most teams, because it decouples the file from the CI system and no env.php content ever flows through runner processes. The second approach makes sense when env.php needs to be fully controlled by the pipeline, for example with blue-green deployments using different database connections. Either way, the variable must be created as protected and as a file variable. Anyone who stores env.php as a regular string variable risks losing line breaks and ending up with a broken PHP file on the server.
7. Consuming variables correctly in .gitlab-ci.yml
How variables are consumed in the pipeline matters just as much as how they are created. File variables are passed as a file path, so the script needs to read the content via that path rather than using it directly as a string. Regular variables are available as environment variables. A common trap is embedding variable values directly into shell strings without quotes. $DEPLOY_PATH/current breaks if DEPLOY_PATH contains spaces. "$DEPLOY_PATH/current" is correct.
For SSH setup in before-script blocks, a clear order applies: start the SSH agent, add the key, write known hosts, and only then run SSH or rsync commands. Getting that order wrong results in either authentication errors or, worse, accepting unverified host keys. Variables set in before_script are available in the script block of the same job. Variables set inside the script block are not visible in subsequent jobs, because every job starts in a fresh environment.
8. Comparison: insecure vs. correct variable configuration
The difference between an improvised and a clean variable configuration is, in Magento projects, often the difference between a pipeline that happens to work and one that is reproducible and secure.
| Variable | Insecure / Wrong | Correct | Reason |
|---|---|---|---|
| SSH Private Key | String variable, not protected | File variable, protected | No line break loss; visible only on protected branches |
| COMPOSER_AUTH | auth.json in the repository | Masked variable, scope prod/staging | Not in Git history; separated by environment |
| env.php | Checked into the repository | File variable or shared directory | Crypt key and DB password never in code |
| DEPLOY_HOST | Scope * (all environments) | Scope production / staging | Feature branches cannot deploy to production |
| DB_PASSWORD | Plain text in .gitlab-ci.yml | Masked + protected variable | Not visible in pipeline logs |
The table shows that every weakness has a concrete countermeasure in GitLab. Protected prevents branch leakage, masked prevents log leakage, file variable prevents formatting errors with multi-line content, and scope prevents environment mixing. All four mechanisms together produce a variable configuration that stays secure even under pressure, urgent hotfixes, new team members, inexperienced pipelines.
9. Common failure patterns and how to spot them
The most common failure pattern with CI/CD variables is the SSH key error: "Permission denied (publickey)". The cause is almost always either a key without correct line breaks (string variable instead of file variable), an incorrect file permission (700 instead of 600 for the key), a missing known-hosts entry, or the wrong deployment user. The diagnosis: use ssh -v instead of ssh in the job script, the verbose output shows exactly which authentication step is failing.
The second common failure pattern is the Composer auth error: "Could not find a matching version". Cause: the COMPOSER_AUTH variable is not set or has an incorrect JSON format. Diagnosis: print composer config --list in the build job and check whether the http-basic credentials for repo.magento.com are present. The third failure pattern is a scope mismatch: a job cannot access a variable it needs, because its environment name does not match the variable's scope. The check: look under "Variables" in the job details in the GitLab UI to see which variables were visible for that specific job. That immediately reveals whether scope and environment name line up.
# Debugging CI/CD Variable issues in a job
debug:variables:
stage: build
environment: staging
script:
# Print all variable names (NOT values, never print secret values)
- env | grep -E '^(DEPLOY_|COMPOSER_|SSH_KNOWN|APP_)' | cut -d= -f1
# Verify SSH key file exists and has correct permissions
- ls -la "$SSH_PRIVATE_KEY"
# Verify Composer auth is readable
- composer config --list | grep -i "http-basic" || echo "No composer auth found"
# Verify known hosts
- ssh-keyscan "$DEPLOY_HOST" 2>/dev/null | ssh-keygen -lf - || echo "Host not reachable"
when: manual
allow_failure: true
10. Summary
Setting up CI/CD variables in GitLab correctly is not a minor detail on the edge of pipeline configuration, it is the security and stability foundation of every Magento deployment. SSH private keys as a file variable with the protected flag set, never as a string variable. Composer auth as a masked variable with an environment scope, never in the repository. app/etc/env.php in the shared directory or as a file variable, never checked in. Every variable with the narrowest possible scope, every critical credential marked protected.
The biggest lever is consistently applying the scoping principle: staging and production never share the same variables, because environment scope systematically prevents it. Combined with protected branches that restrict production deployments to approved pipelines, this creates a security architecture that keeps producing no unwanted secret leaks even under operational pressure, urgent fixes, staff changes, new projects.
CI/CD Variables in GitLab: The Essentials at a Glance
SSH Keys
Create as a file variable, set the protected flag. Known hosts as a separate variable. Never StrictHostKeyChecking=no.
Composer Auth
As a masked plus protected variable with environment scope. Pass the JSON format directly to Composer. Never in auth.json in the repository.
Environment Scope
Staging and production get their own variable sets. Scope * only for non-sensitive defaults like RELEASE_RETENTION.
Diagnosis
Print variable names (not values) in the job. Check job details in the GitLab UI. Debug the SSH connection with -v.