for Secure Deployments
Deployment keys that are scoped too broadly or stay in use for years without rotation are an underestimated risk. Ed25519 key pairs, GitLab deploy keys, correct known hosts management and safe rotation are the building blocks for auditable, revocable SSH connections in automated Magento deployments.
Table of Contents
- 1. SSH key types: RSA, ECDSA and Ed25519 compared
- 2. GitLab deploy keys vs. personal access tokens vs. CI variables
- 3. Generating an Ed25519 key pair for deployments
- 4. Storing the private key as a GitLab file variable
- 5. Configuring authorized_keys correctly on the target server
- 6. Known hosts: collecting and using fingerprints
- 7. Secure vs. insecure key management compared
- 8. Key rotation without deployment downtime
- 9. Fully integrating SSH key setup into the GitLab pipeline
- 10. Summary
- 11. FAQ
1. SSH key types: RSA, ECDSA and Ed25519 compared
For new deployment keys, Ed25519 is the right choice. The algorithm is based on elliptic curve cryptography and produces short, quickly processed keys that are significantly more compact than RSA-4096 at the same security level. Ed25519 has been available since OpenSSH 6.5 and is supported on all current Linux systems, including GitLab runner environments. Unlike ECDSA, where flawed RNG implementations have historically led to key compromises, Ed25519 is robust against such attack vectors by design.
RSA keys with 2048 bits should no longer be used for new deployments. RSA-4096 is still secure, but slower and produces noticeably longer key files, which brings no benefit in automated systems. For existing RSA keys the rule is: they do not need to be rotated immediately, but should switch to Ed25519 at the next scheduled rotation. The difference in security rating is not primarily about the algorithm but about key length and correct management.
One aspect that is often overlooked: the key type also affects the line length in authorized_keys. Ed25519 keys fit on one compact line and are easier to review and store in GitLab variables. RSA-4096 keys span multiple lines, which can cause problems in some CI variable configurations if line breaks get escaped unintentionally.
2. GitLab deploy keys vs. personal access tokens vs. CI variables
GitLab offers three ways to configure SSH access for automated processes. Deploy keys are SSH public keys bound directly to a repository, granting only read access (or optionally write access) to that single repository. They are ideal for cases where the runner needs to clone code from a private repository. A deploy key is not a personal account, it belongs to the repository, not to a user.
CI/CD variables of type File are the right way to make the private key available to the runner for server deployments. The difference from deploy keys: the private key in a CI variable authenticates the runner against a server, not against GitLab itself. This separation matters: use deploy keys for repository access, and CI variables with the deployment key pair for server deployments. Some teams combine both, one deploy key for Composer packages from private GitLab repositories, and a separate key pair for SSH access to the target system.
stages:
- build
- deploy
- verify
variables:
GIT_STRATEGY: fetch
COMPOSER_CACHE_DIR: .cache/composer
# Reusable SSH setup anchor, loaded before each deploy/verify job
.ssh_init: &ssh_init
before_script:
# Start SSH agent and load deployment private key (File variable)
- eval $(ssh-agent -s)
- chmod 600 "$SSH_PRIVATE_KEY"
- ssh-add "$SSH_PRIVATE_KEY"
# Write known_hosts from CI variable, prevents MitM attacks
- mkdir -p ~/.ssh && chmod 700 ~/.ssh
- echo "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
- chmod 644 ~/.ssh/known_hosts
deploy:production:
stage: deploy
<<: *ssh_init
script:
- ssh -o StrictHostKeyChecking=yes "$DEPLOY_USER@$DEPLOY_HOST" \
"bash -s -- $CI_COMMIT_TAG" < scripts/deploy.sh
environment:
name: production
url: https://mironsoft.de
only:
- tags
when: manual
3. Generating an Ed25519 key pair for deployments
A deployment key pair is generated once on a secure local machine and never created on the production server. The command is: ssh-keygen -t ed25519 -C "gitlab-deploy@mironsoft.de" -f ~/.ssh/magento_deploy_ed25519 -N "". The -N "" sets no passphrase, which is necessary for automated deployments because no interactive passphrase input is possible. The private key is stored in GitLab as a CI variable, and the public key is added to the authorized_keys of the deployment user on the target server.
The comment (-C) should clearly describe the purpose: who uses this key and for what. This makes management easier when a server has several keys listed in its authorized_keys. A typical naming scheme for deployment keys is gitlab-deploy-[project]-[environment]@[organization]. With this scheme you can immediately see whether a key is meant for production or staging and which project it belongs to.
4. Storing the private key as a GitLab file variable
In the project settings under Settings > CI/CD > Variables you create a new variable: name SSH_PRIVATE_KEY, type File, value: the full content of the private key file including the header lines -----BEGIN OPENSSH PRIVATE KEY----- and -----END OPENSSH PRIVATE KEY-----. The variable must be marked as Protected if it should only be available for protected branches and tags. Masked is not possible for file variables in GitLab because the content spans multiple lines.
A common mistake is creating the variable as a plain string instead of a file. As a string, the runner receives the key content as an environment variable, not as a file path. But the ssh-add command expects a path. The resulting error message is No such file or directory, even though the variable is set. As a file variable, GitLab writes the content to a temporary file and passes its path as the environment variable $SSH_PRIVATE_KEY, which is exactly what chmod 600 "$SSH_PRIVATE_KEY" && ssh-add "$SSH_PRIVATE_KEY" expects.
5. Configuring authorized_keys correctly on the target server
The public key of the deployment key pair is added to the file ~/.ssh/authorized_keys of the deployment user on the target server. The file and the .ssh directory must have the correct permissions: chmod 700 ~/.ssh and chmod 600 ~/.ssh/authorized_keys. Incorrect permissions cause sshd to silently ignore the key, with no error message stating the real reason.
For extra security, each authorized_keys entry can be restricted with options. The option no-pty,no-agent-forwarding,no-X11-forwarding prevents the deployment key from being misused for interactive sessions or agent forwarding. With from="[runner-ip]" the key can be restricted to connections from specific IP addresses, useful when the GitLab runner has a fixed IP. With command="/opt/deploy/entrypoint.sh" the key is restricted to a single script, though this severely limits deployment flexibility.
6. Known hosts: collecting and using fingerprints
The known_hosts file is the mechanism SSH uses to make sure the target server is the same one it connected to last time. In automated environments this file must be populated in advance, since no interactive fingerprint confirmation dialog is possible. The command ssh-keyscan -H $DEPLOY_HOST returns the server's host keys in known_hosts format. The -H flag hashes the hostname, which is recommended so a compromised known_hosts entry does not reveal hostnames.
The output of ssh-keyscan is copied into a GitLab CI/CD variable named SSH_KNOWN_HOSTS. In the before_script it is written to the runner's known_hosts file with echo "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts. The combination of a stored fingerprint and StrictHostKeyChecking=yes ensures the pipeline fails immediately if the server fingerprint does not match, an important indicator of a compromised or replaced server.
# Collecting SSH fingerprints for known_hosts (run locally, not in pipeline):
# ssh-keyscan -H production.mironsoft.de > known_hosts_production
# ssh-keyscan -H staging.mironsoft.de > known_hosts_staging
# Then paste content into GitLab CI variables SSH_KNOWN_HOSTS_PROD / SSH_KNOWN_HOSTS_STAG
verify:connection:
stage: verify
before_script:
- eval $(ssh-agent -s)
- chmod 600 "$SSH_PRIVATE_KEY"
- ssh-add "$SSH_PRIVATE_KEY"
- mkdir -p ~/.ssh && chmod 700 ~/.ssh
# Use environment-specific known_hosts variable
- echo "$SSH_KNOWN_HOSTS_PROD" > ~/.ssh/known_hosts
- chmod 644 ~/.ssh/known_hosts
script:
# Verify connection and basic Magento availability
- ssh -o StrictHostKeyChecking=yes "$DEPLOY_USER@$DEPLOY_HOST" \
"cd $DEPLOY_PATH/current && bin/magento --version"
# Verify HTTP health endpoint responds with 200
- curl --fail --silent --output /dev/null \
--write-out "HTTP %{http_code}" \
"https://mironsoft.de/health"
environment:
name: production
only:
- tags
7. Secure vs. insecure key management compared
The differences between secure and insecure SSH key management in GitLab deployments can be summarized in a few points, each with concrete consequences.
| Aspect | Insecure practice | Secure practice | Risk if insecure |
|---|---|---|---|
| Key type | RSA-2048 or older | Ed25519 | Weaker security margin |
| Variable type | String variable | File variable | ssh-add fails |
| Host verification | StrictHostKeyChecking=no | StrictHostKeyChecking=yes | MitM attack possible |
| Key permissions | Root user or broad sudo rights | Dedicated deploy user without sudo | Compromise equals root access |
| Key rotation | No rotation plan | Annually or on staff changes | Outdated keys in use |
The setting StrictHostKeyChecking=no in particular has been added as a quick fix in many projects to work around connection problems. This setting should not exist in any production pipeline. The correct fix is always to update the known_hosts variable, not to disable the security check.
8. Key rotation without deployment downtime
Rotating a deployment key without interrupting running deployments is possible with a simple two-step process. First, generate a new Ed25519 key pair. Add the new public key as a second entry in the authorized_keys of the deployment user, keeping the old key in place. Then update the GitLab CI/CD variable SSH_PRIVATE_KEY with the new private key and trigger a test pipeline run.
Once the test succeeds and confirms the pipeline correctly uses the new key, remove the old public key from authorized_keys. Running jobs still connected with the old key are not interrupted by this second step, because the SSH connection has already been established. Future jobs use only the new key. The rotation should be communicated to the team and take place during a maintenance window when no critical deployments are planned.
9. Fully integrating SSH key setup into the GitLab pipeline
A fully integrated SSH key setup in a GitLab pipeline uses YAML anchors (&anchor and *anchor) so the SSH initialization block does not need to be repeated in every job. The anchor contains the complete before_script block with SSH agent startup, key loading and known_hosts writing. Jobs that need SSH merge this anchor with <<: *ssh_init. This reduces repetition while still making sure SSH setup is freshly initialized in every job, since runner environments are reset after each job.
For multi-environment setups with staging and production, it is recommended to use one SSH_KNOWN_HOSTS variable per environment scope. GitLab allows defining different variable values for different environments (staging, production). This way the deploy job automatically picks up the correct known hosts for its environment, without having to manually adjust the pipeline configuration for each environment. The same applies to DEPLOY_HOST and DEPLOY_USER.
stages:
- build
- package
- deploy
- verify
# SSH initialization anchor, merged into any job needing SSH access
.ssh_init: &ssh_init
before_script:
- eval $(ssh-agent -s)
- chmod 600 "$SSH_PRIVATE_KEY"
- ssh-add "$SSH_PRIVATE_KEY"
- mkdir -p ~/.ssh && chmod 700 ~/.ssh
# SSH_KNOWN_HOSTS is environment-scoped in GitLab variable settings
- echo "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
- chmod 644 ~/.ssh/known_hosts
# Verify key is loaded before proceeding
- ssh-add -l
deploy:production:
stage: deploy
<<: *ssh_init
script:
- |
ssh -o StrictHostKeyChecking=yes \
-o ConnectTimeout=10 \
"$DEPLOY_USER@$DEPLOY_HOST" bash -s <<'REMOTE'
set -euo pipefail
readonly RELEASE_ID="$(date +%Y%m%d-%H%M%S)"
readonly RELEASE_PATH="$DEPLOY_PATH/releases/$RELEASE_ID"
mkdir -p "$RELEASE_PATH"
echo "[INFO] Prepared release directory: $RELEASE_PATH"
REMOTE
environment:
name: production
rules:
- if: '$CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/'
when: manual
verify:production:
stage: verify
<<: *ssh_init
script:
- curl --fail --max-time 10 "https://mironsoft.de/health"
- ssh -o StrictHostKeyChecking=yes "$DEPLOY_USER@$DEPLOY_HOST" \
"cd $DEPLOY_PATH/current && bin/magento cache:status"
environment:
name: production
rules:
- if: '$CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/'
10. Summary
Secure SSH key management in GitLab deployments starts with choosing the right algorithm, Ed25519 instead of RSA-2048, and ends with a defined rotation plan. The private key belongs in GitLab CI/CD as a file variable, the public key in the authorized_keys of a dedicated deployment user without sudo rights. Known hosts are collected in advance with ssh-keyscan and stored as a variable. StrictHostKeyChecking=yes is not an optional security measure, it is mandatory in any pipeline that touches production.
Rotating deployment keys is possible without downtime using the two-step process described above, and should be carried out at least once a year or whenever staff changes occur. Teams that have implemented these fundamentals cleanly can answer every further security audit with clear answers: which key is used for what purpose, when was it last rotated, and how can it be revoked immediately in an emergency.
SSH Keys and Deploy Keys in GitLab: The Essentials at a Glance
Key type
Ed25519 for all new deployment keys. Shorter, faster and more robust by design than RSA-2048.
GitLab variable
Create SSH_PRIVATE_KEY as a file variable, not as a string. ssh-add expects a file path, not a string variable.
Known hosts
Run ssh-keyscan -H $DEPLOY_HOST, store the output as a CI variable, write it to ~/.ssh/known_hosts before every job.
Key rotation
Add the new key, update the variable, test, remove the old key. Both keys briefly active, no deployment outage.