Orchestrating Remote Server Deployments via SSH in GitLab CI/CD

The integration of Secure Shell (SSH) protocols within a GitLab CI/CD pipeline transforms a static code repository into a dynamic delivery engine. While modern infrastructure often leans toward container orchestration and Kubernetes clusters, the necessity of deploying to traditional Linux virtual machines—such as those hosted on Azure or other cloud providers—remains a cornerstone of professional DevOps. Achieving a seamless connection between a GitLab Runner and a remote target requires a precise synchronization of security credentials, environment configurations, and network permissions. When these elements are aligned, the pipeline can autonomously execute scripts, manage file transfers, and trigger remote git operations without human intervention.

The fundamental challenge in this architecture is the ephemeral nature of the GitLab Runner. Whether using Shared Runners provided by GitLab—which offer a generous allocation of 400 free minutes per month—or a self-managed runner, the environment is wiped clean after every job. Consequently, the SSH private key must be securely injected into the runtime environment, the SSH agent must be initialized to manage those keys, and the remote server's identity must be verified to prevent man-in-the-middle attacks.

Infrastructure Requirements and Prerequisite Components

Before initiating the configuration of a deployment pipeline, specific architectural components must be in place. Failure to establish these prerequisites often results in the "Permission denied" or "Connection timeout" errors frequently encountered during the initial setup phase.

  • GitLab Account: A fully functional account is required to host the project and manage the CI/CD variables.
  • Remote Server: A target environment is necessary, such as a Linux Virtual Machine (VM) on Azure. This server must have an SSH daemon running and be reachable via the network on the designated port (typically port 22, though port 443 is sometimes used for specific routing).
  • GitLab Project: A project must be created to hold the source code and the .gitlab-ci.yml configuration file. For those testing the workflow, templates such as "Pages/Plain HTML" can be used to generate a baseline project containing a README.md, an initial .gitlab-ci.yml, and a public directory with index.html and style.css.
  • SSH Key Pair: A public/private key pair is mandatory. The private key remains within GitLab's secure variable store, while the public key is deployed to the remote server.

Secure Management of SSH Keys in GitLab CI/CD

GitLab does not provide a built-in, native manager for SSH keys within the build environment. Instead, it relies on the injection of keys via CI/CD variables. This is critical because the build environment is temporary; any key stored locally on a runner would be lost upon job completion.

Variable Configuration Strategies

There are two primary methods for storing SSH keys within GitLab, each with distinct implications for security and formatting.

  • File Type Variables: This is the recommended approach. When a variable is set to "File" type, GitLab stores the content in a temporary file on the runner and provides the path to that file in the environment variable.
  • Regular Variables: The key is stored as a string. This requires the user to manually pipe the variable into a file or the ssh-add command using shell redirection.

When configuring these variables, specifically the SSH_PRIVATE_KEY, several strict rules must be followed to ensure the key is accepted by the OpenSSH client:

  • Visibility Settings: The visibility must be set to "Visible". This is because SSH keys contain whitespace characters and newlines. GitLab's "Masked" variables cannot contain whitespace; attempting to mask an SSH key will result in an error.
  • Newline Termination: The value of the private key must end with a newline (LF character). Users must press Enter or Return at the end of the last line before saving the variable to avoid "invalid format" errors during the ssh-add process.
  • Branch Restrictions: To prevent unauthorized access, it is a best practice to restrict these variables to protected branches and tags. This ensures that a developer cannot simply modify a feature branch's .gitlab-ci.yml to cat the private key into the job log.

The Public Key Deployment Process

The private key is useless without the corresponding public key residing on the target server. The public key must be appended to the ~/.ssh/authorized_keys file of the user account intended for deployment (e.g., a deployer user). If the pipeline needs to clone from another private GitLab repository during the deployment process, the public key must also be added as a "Deploy Key" within that specific repository's settings.

Technical Execution of the SSH Connection

The transition from a running job to a connected remote session involves several discrete steps that must be executed in the before_script section of the .gitlab-ci.yml file.

Environment Setup and Dependency Installation

Most GitLab pipelines utilize lightweight Docker images, such as alpine:latest. These images are stripped of non-essential binaries, meaning the ssh client is not installed by default. The pipeline must first install the necessary tools.

  • Package Installation: Using apk add openssh-client ensures the environment has the binaries required to execute ssh, ssh-add, and ssh-keyscan.
  • Directory Preparation: The .ssh directory must be created using mkdir -p ~/.ssh and its permissions must be strictly set to 700 (drwx------) to satisfy OpenSSH security requirements. Failure to set these permissions can lead to the client rejecting the directory as "too open".

The SSH Agent and Key Loading

The ssh-agent is a background process that holds the private keys in memory, allowing the ssh command to authenticate without requiring a password for every single request.

  • Agent Initialization: The command eval $(ssh-agent -s) starts the agent and sets the necessary environment variables (SSH_AUTH_SOCK and SSH_AGENT_PID).
  • Key Injection: The private key is loaded into the agent. If using a regular variable, the command echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add - is used. The tr -d '\r' command is vital for removing carriage returns that may have been introduced if the key was copied from a Windows environment, which would otherwise corrupt the key format.
  • Manual File Placement: In some configurations, the key is written directly to a file: echo "$SSH_PRIVATE_KEY" | base64 -d > ~/.ssh/id_rsa. In this scenario, the file must be given 600 permissions via install -m 600 -D /dev/null ~/.ssh/id_rsa to ensure it is not world-readable.

Handling Host Verification

When connecting to a server for the first time, SSH asks the user to verify the authenticity of the host. In an automated pipeline, this interactive prompt will cause the job to hang and eventually time out. To bypass this, the server's public key must be added to the known_hosts file.

  • Keyscan Operation: The command ssh-keyscan $VM_IPADDRESS >> ~/.ssh/known_hosts fetches the remote server's public key and adds it to the local trust store.
  • Permissioning: The known_hosts file should be set to 644 to ensure it is readable by the system.

Analysis of Common Failures and Troubleshooting

Despite a logical configuration, several common failure points often emerge in the logs of GitLab CI/CD pipelines.

Connection Timeouts and Port Misconfigurations

A "Connection timeout" often occurs when the network path between the GitLab Runner and the remote server is blocked.

  • Port 22 vs 443: Standard SSH operates on port 22. However, some restrictive firewalls block this port. In such cases, using an alternative hostname like altssh.gitlab.com on port 443 (as seen in some GitLab configurations) can circumvent these restrictions.
  • Debugging Verbosity: When timeouts occur, adding the -vvv flag to the SSH command (ssh -vvv [email protected]) provides a detailed trace of the connection attempt, revealing exactly where the handshake fails.

Permission Denied (publickey, password)

This error typically signifies a failure in the authentication chain.

  • Libcrypto Errors: Errors such as Load key "/root/.ssh/id_rsa": error in libcrypto usually point to a malformed private key. This is often caused by missing newlines at the end of the variable or the presence of hidden \r characters.
  • Missing Key in Agent: If the ssh-add command fails or the agent is not started, the ssh command will attempt to use default keys or prompt for a password, leading to a "Permission denied" error because the runner is non-interactive.

Implementation Reference for .gitlab-ci.yml

The following implementation demonstrates the comprehensive application of the aforementioned principles. This configuration uses an Alpine Linux image and ensures all security and connectivity requirements are met before executing the remote command.

```yaml
image: alpine:latest

pages:
stage: deploy
beforescript:
- 'command -v ssh-agent >/dev/null || ( apk add --update openssh )'
- eval $(ssh-agent -s)
- echo "$SSH
PRIVATEKEY" | tr -d '\r' | ssh-add -
- mkdir -p ~/.ssh
- chmod 700 ~/.ssh
- ssh-keyscan $VM
IPADDRESS >> ~/.ssh/knownhosts
- chmod 644 ~/.ssh/known
hosts
script:
- ssh $SSHUSER@$VMIPADDRESS "hostname && echo 'Welcome!!!' > welcome.txt"
artifacts:
paths:
- public
only:
- master
```

Alternatively, for deployments involving Git operations (such as pulling code on the remote server), the following logic is employed:

```yaml
stages:
- deploy

deploy:
image: alpine:latest
stage: deploy
only:
- prelive
beforescript:
- apk update
- apk add openssh-client
- install -m 600 -D /dev/null ~/.ssh/id
rsa
- echo "$SSHPRIVATEKEY" | base64 -d > ~/.ssh/idrsa
- ssh-keyscan -H $SSH
HOST > ~/.ssh/knownhosts
script:
- ssh $SSH
USER@$SSHHOST "cd $WORKDIR && git checkout $PRELIVEBRANCH && git pull && exit"
after
script:
- rm -rf ~/.ssh
```

Comparative Analysis of Configuration Methods

The following table summarizes the differences between the various methods of handling SSH keys and host verification within the pipeline.

Feature File Type Variable Regular Variable (String) Manual File Creation
Storage Method Path to temporary file Value in memory echo to file
Whitespace Handling Native support Requires tr or quoting Requires base64 or echo
Masking Capability Not Masked Not Masked Not Masked
Implementation Effort Low Medium Medium
Recommended Use High-security keys Simple scripts Complex key rotations
Risk of Exposure Low (via file path) Medium (via logs) Medium (via logs)

Detailed Analysis of the Deployment Workflow

The process of deploying via SSH is not merely about the connection, but about the lifecycle of the environment. A critical but often overlooked step is the after_script section. In a shared runner environment, the cleanup is usually handled by the orchestrator, but in a self-managed runner, explicitly removing the .ssh directory using rm -rf ~/.ssh ensures that no sensitive remnants of the private key remain on the disk after the job concludes.

Furthermore, the use of ssh-keyscan is a non-negotiable requirement for automation. Without it, the ssh command fails because it cannot verify the host key against a known list, and since the runner cannot provide a "yes" response to the prompt "Are you sure you want to continue connecting (yes/no)?", the process terminates.

The impact of using a "Deploy Key" versus a "User Key" is also significant. A deploy key is specific to a repository and can be configured as read-only, which adheres to the principle of least privilege. Using a personal user key for CI/CD pipelines is a security risk, as it provides the pipeline with the full permissions of the user across all their projects. Therefore, generating a dedicated key pair for each project's deployment pipeline is the only acceptable professional standard.

Sources

  1. dev.to/aws-builders/gitlab-ci-pipeline-run-script-via-ssh-to-remote-server-49l0
  2. forum.gitlab.com/t/ssh-connection-within-gitlab-ci-cd-with-deploy-key-gets-connection-timeout/53341
  3. docs.gitlab.com/ci/jobs/ssh_keys/
  4. forum.gitlab.com/t/deploying-code-from-gitlab-repository-into-remote-server/108095
  5. www.garybell.co.uk/using-gitlab-ci-to-deploy-via-ssh

Related Posts