Secure SSH-Based Continuous Deployment via GitLab CI/CD Pipelines

The integration of GitLab CI/CD with remote server infrastructure via Secure Shell (SSH) represents a cornerstone of modern DevOps methodologies. In an era where rapid iteration and automated delivery are non-negotiable, the ability to bridge the gap between a code repository and a production environment using cryptographically secure authentication is vital. GitLab, an expansive open-source collaboration platform, facilitates this through its CI/CD pipelines, which can manage everything from issue tracking and package hosting to the orchestration of sophisticated deployment routines. This process involves not only the automation of code movement but also the rigorous management of SSH keys, user permissions, and runner configurations to ensure that the deployment pipeline is both seamless and resilient against unauthorized access.

When architecting a deployment workflow, the fundamental objective is to allow a GitLab Runner—the agent responsible for executing CI/CD jobs—to authenticate with a remote target server without manual intervention. Because these pipelines operate in non-interactive environments, traditional password-based authentication is unsuitable. Instead, engineers rely on SSH key pairs: a private key held by the runner and a public key placed on the target server. This mechanism provides a high degree of security while enabling the "hands-off" execution required for true Continuous Deployment (CD).

Architecting the Deployer Identity and SSH Key Infrastructure

The foundation of a secure deployment is the creation of a dedicated identity on the target server. Utilizing a personal user account for automated deployments is a significant security risk, as it grants the pipeline excessive permissions and ties automated actions to a specific human's credentials. Instead, a dedicated "deployer" user should be established.

To initiate this process, an administrator must switch to the newly created deployer user on the remote Linux host. This is accomplished using the su command:

bash su deployer

Upon execution, the system will prompt for the deployer's specific password to facilitate the transition. Once the shell is switched to the deployer user, the generation of a robust cryptographic identity begins. It is a best practice to use high-entropy keys to prevent brute-force attacks. For this purpose, the ssh-keygen utility is used with a specified bit length.

bash ssh-keygen -b 4096

During this generation process, the ssh-keygen command presents several prompts. To ensure compatibility with non-interactive CI/CD environments, it is critical to handle these prompts correctly. For the sake of automation, the user should simply press ENTER for both the file location prompt and the passphrase prompt. Storing the key in the default location is standard for most automation scripts, and leaving the passphrase empty is a functional necessity; if a passphrase were applied, the before_script in a GitLab pipeline would stall, as it cannot interactively prompt a human for the password during a build.

After the key pair is generated, the public key must be authorized to allow the deployer user to log in via SSH. This is performed by appending the contents of the public key file to the authorized_keys file located in the user's hidden .ssh directory. The ~ symbol serves as a shortcut for the current user's home directory in Linux environments.

bash cat ~/.ssh/id_rsa.pub >> ~/.ssh/authorized_keys

In this command, cat reads the public key, and the >> redirection operator appends that data to the authorized_keys file. This specific action establishes the "trust" relationship between the private key held by the GitLab runner and the remote server.

GitLab Runner Execution Models and Key Management

The method of managing SSH keys differs significantly depending on the type of GitLab Runner being utilized: the Shell executor or the Docker executor.

The Shell Executor Approach

If the GitLab Runner is installed directly on a machine and utilizes the Shell executor, the management of SSH keys can be more centralized. In this scenario, the keys are managed on the machine where the runner resides, allowing all projects running on that specific runner to share the same authentication context.

To configure this, an administrator must sign in to the server hosting the runner and elevate privileges to the gitlab-runner user:

bash sudo su - gitlab-runner

Once acting as the gitlab-runner user, a new SSH key pair can be generated. Similar to the deployer user setup, the key should not have a passphrase to avoid blocking the before_script phase of the CI/CD job. After generation, the public key must be added to the authorized_keys file of the target services (the remote servers) to grant access.

A critical step in this process is verifying the connection. After generating the keys, the user should attempt a manual login to the remote host to accept the server's fingerprint. This step is essential for preventing Man-in-the-Middle (MITM) attacks.

bash ssh [email protected]

Or, for a specific remote server:

bash ssh example.com

Checking the private server's public key ensures that the identity of the host is verified before the automated pipeline begins communicating with it.

The Docker Executor and CI/CD Variables

When using the Docker executor, the build environment is ephemeral—a container that is created, runs the job, and is destroyed. Therefore, keys cannot be stored on the runner's local disk permanently. Instead, the most widely supported and secure method is to inject the private key into the build environment using GitLab CI/CD variables.

The private key should be stored as a "File" type variable within the GitLab project settings, typically named SSH_PRIVATE_KEY. This prevents the key from being exposed in the job logs. In the .gitlab-ci.yml configuration, the before_script section is used to reconstruct the SSH directory and place the key in the correct location.

Step Command Purpose
Update Package List apk update Ensures the package manager has the latest index (for Alpine-based images).
Install SSH Client apk add openssh-client Provides the necessary tools to initiate SSH connections.
Create SSH Directory install -m 600 -D /dev/null ~/.ssh/id_rsa Creates the .ssh directory and the key file with restricted permissions.
Inject Private Key `echo "$SSHPRIVATEKEY" base64 -d > ~/.ssh/id_rsa` Decodes and writes the variable to the key file.
Host Verification ssh-keyscan -H $SSH_HOST > ~/.ssh/known_hosts Populates the known hosts file to prevent interactive fingerprint prompts.

The install -m 600 command is particularly important as it sets the strict file permissions required by the SSH client; without 600 permissions, the SSH client will reject the key for being too "open."

Deployment Orchestration and Command Execution

The actual deployment occurs within the script section of the GitLab CI/CD configuration. The pipeline uses the injected SSH identity to connect to the remote host and execute a sequence of commands. A common pattern for containerized deployments involves logging into a registry, pulling a new image, and restarting a container.

A typical deployment command structure looks like this:

bash ssh $SSH_USER@$SSH_HOST "cd $WORK_DIR && git checkout $PRELIVE_BRANCH && git pull && exit"

In this command, the pipeline navigates to the working directory, switches to the correct branch, and pulls the latest code. For Docker-based deployments, the following logic is often implemented on the remote server:

  1. docker login ...: Authenticates the remote Docker daemon with the container registry.
  2. docker pull ...: Fetches the latest version of the application image.
  3. docker container rm my-app || true: Removes the old container. The || true suffix is a vital piece of logic that ensures the pipeline does not fail if my-app does not yet exist (as is the case during the very first deployment).
  4. docker run -d -p 80:80 my-app: Starts the new container in detached mode (-d), binding the host's port 80 to the container's port 80.

To maintain environmental hygiene, an after_script can be used to remove the sensitive .ssh directory after the job finishes:

bash rm -rf ~/.ssh

Access Control: Deploy Keys vs. Deploy Tokens

When a pipeline needs to access a private GitLab repository to pull code, an authentication method must be provided to the Git client. GitLab offers two primary mechanisms for this: Deploy Keys and Deploy Tokens.

Attribute Deploy Key Deploy Token
Sharing Can be shared between multiple projects across different groups. Belongs strictly to a single project or group.
Source A public SSH key generated on an external host (like the Runner). Generated directly on the GitLab instance and provided once at creation.
Accessible Resources Provides access to the Git repository over SSH. Provides access to the Git repository via HTTP, the package registry, and the container registry.

Deploy keys are ideal when the Runner is an external entity that needs SSH access to the repository. However, if external authorization is enabled on the GitLab instance, deploy keys might be restricted from performing Git operations. In such cases, a Deploy Token—which uses HTTP-based authentication—might be the preferred alternative for accessing the container registry or package registry.

Troubleshooting Common Deployment Failures

Even with a perfectly constructed pipeline, several issues can trigger deployment failures. A frequent error encountered is the "Permission denied" message, often accompanied by errors in the underlying cryptographic libraries.

Example Error:
bash $ ssh $SSH_USER@$SSH_HOST "cd $WORK_DIR && git checkout $PRELIVE_BRANCH && git pull && exit" Load key "/root/.ssh/id_rsa": error in libcrypto Permission denied, please try again. deployer@XXX: Permission denied (publickey,password).

This error typically points to one of three issues:
1. Incorrect Key Permissions: The SSH client will ignore a private key if its permissions are too broad (e.g., 777 or 644). It must be 600.
2. Key Format Mismatch: If the SSH_PRIVATE_KEY variable was not correctly decoded (e.g., if it was not base64 encoded before being stored), the libcrypto library may fail to parse the key.
3. Missing Authorization: The public key might not have been correctly appended to the ~/.ssh/authorized_keys file on the target server, or the user might be attempting to log in as the wrong user.

Another common hurdle is the "Host Verification" prompt. If the known_hosts file is not properly populated using ssh-keyscan, the SSH process will attempt to interactively ask the user to confirm the host's authenticity. Since the CI/CD runner is non-interactive, the job will hang or fail.

Analytical Conclusion

Implementing SSH-based deployment within a GitLab CI/CD framework is a sophisticated balancing act between automation, security, and error handling. The transition from manual deployment to a fully automated pipeline requires a deep understanding of Linux user management, SSH cryptographic standards, and the specific requirements of different GitLab Runner executors.

The security of this entire ecosystem rests on the isolation of the "deployer" identity and the careful handling of the SSH_PRIVATE_KEY through CI/CD variables. By utilizing 4096-bit keys and enforcing strict file permissions (chmod 600), engineers can mitigate the risks of unauthorized access. Furthermore, the distinction between Deploy Keys and Deploy Tokens allows for granular control over how the pipeline interacts with the GitLab ecosystem, whether through SSH or HTTP. Ultimately, a successful deployment pipeline is not merely one that moves code, but one that does so through a hardened, predictable, and highly observable automated workflow that minimizes human error and maximizes deployment frequency.

Sources

  1. How to set up a continuous deployment pipeline with GitLab on Ubuntu
  2. GitLab CI/CD: Using SSH keys
  3. GitLab Forum: Deploying code from GitLab repository into remote server
  4. GitLab Documentation: Deploy keys

Related Posts