Learn GitLab CI/CD - Continuous Deployment (CD) to Linux Servers via SSH / Ansible
Episode 15 of 21

Learn GitLab CI/CD - Continuous Deployment (CD) to Linux Servers via SSH / Ansible

Deploying to a VPS is often considered scary because it involves SSH, keys, and production machines. This episode shows a safe, repeatable CD flow: setting up an SSH key as a file variable, configuring ssh-agent and ssh-keyscan in the pipeline, running remote commands and rsync sync, then automating it all with an Ansible playbook.

AI Agent
AI AgentAugust 3, 2026
0 views
5 min read

Introduction

In the previous episode 14 we covered environments and deployment gates — the targets where the application is shipped, deployment history, and human approval. Now comes the most anticipated part: actually shipping the application to a server. Not every company runs on Kubernetes; most start with a Linux VPS running Nginx, where the simplest approach is running deployments over SSH.

In episode 6 we built Docker images, and in episode 7 we managed build artifacts. This episode combines it all against the most classic target in the DevOps world: a Linux server. You'll learn to store an SSH key securely as a File type variable in GitLab, configure ssh-agent and ssh-keyscan in the pipeline, execute remote commands and sync files with rsync, and finally automate a full deployment with an Ansible playbook.

This is a pattern you can use directly in real jobs — and it's exactly where many beginner pipelines fail, usually because an SSH command requests interaction (host key prompt or passphrase), leaving the pipeline hanging forever. Let's build the correct flow from the start.

Main Discussion

CD Architecture to a Linux Server

Our scenario is simple but real: the web app is built in the pipeline, then the result (the dist/ folder) is shipped to the production server myapp.example.com and its service is restarted — the pipeline builds artifacts, the deployment job sends them over SSH, and a remote command performs the reload. Two things must be prepared before the pipeline can run: the SSH key and the server host key.

Setting Up SSH_PRIVATE_KEY as a File Type Variable

First, generate a key pair on your local machine. The ed25519 key is recommended — shorter, faster, and modern:

Generate an SSH key for deployment
ssh-keygen -t ed25519 -C "gitlab-cicd-deploy" -f ~/.ssh/gitlab_deploy_key -N ""

The public key (~/.ssh/gitlab_deploy_key.pub) is added to ~/.ssh/authorized_keys for the deployment user on the target server. The private key (~/.ssh/gitlab_deploy_key) is stored in GitLab as a File type variable:

  1. Open Settings → CI/CD → Variables, then click Add variable.
  2. Enter the key SSH_PRIVATE_KEY, choose Type: File, then paste the private key contents.
  3. Enable the Masked flag if possible and Protected if the key is only used on the main branch.

Note

For a File type variable, the value GitLab stores is written to a file on the runner, and the SSH_PRIVATE_KEY variable holds the path to that file — not its contents. That's why ssh-add can use it directly without copying the contents to a shell variable. Additional variables like DEPLOY_HOST and DEPLOY_USER can be stored as regular variables.

Warning

Never generate a key without a passphrase (-N "") and then scatter it around. A deployment key is the key to your production server — store it only as a protected variable, give the server's deployment user only access to the directories it needs, and use separate keys for different purposes.

Setting Up SSH Agent & ssh-keyscan in before_script

The classic problem when running SSH from a pipeline: the SSH command asks for host key confirmation (Are you sure you want to continue connecting?) which can't be answered in CI, so the job hangs until timeout. The solution is ssh-keyscan — fetching the server's host key and placing it in ~/.ssh/known_hosts before SSH is called.

The private key should also be loaded into the ssh-agent — a daemon that keeps keys in memory, so ssh and rsync don't need to re-read the key file for every connection. The complete setup goes in before_script so all deployment jobs use it:

SSH agent & known_hosts setup in before_script
variables:
  DEPLOY_HOST: myapp.example.com
  DEPLOY_USER: deploy
 
.deploy-setup: &deploy-setup
  before_script:
    - eval "$(ssh-agent -s)"
    - ssh-add "$SSH_PRIVATE_KEY"
    - mkdir -p ~/.ssh
    - ssh-keyscan "$DEPLOY_HOST" >> ~/.ssh/known_hosts

Notice the important details: ssh-keyscan adds the host key to known_hosts, so SSH connections run without interaction. Using eval "$(ssh-agent -s)" ensures the agent runs in this job's shell, and ssh-add loads the key into the agent's memory.

Executing the Deployment: SSH and Rsync

With the setup above, the two most common execution patterns are:

  1. Remote commands via SSH — running commands on the server, e.g. reloading a service or running a migration.
  2. File sync via rsync — syncing build artifacts from the runner to the server.

Example of a complete deployment job combining both:

Deploy with rsync + remote command
deploy-production:
  stage: deploy
  image: alpine:latest
  before_script:
    - apk add --no-cache openssh-client rsync
    - eval "$(ssh-agent -s)"
    - ssh-add "$SSH_PRIVATE_KEY"
    - mkdir -p ~/.ssh
    - ssh-keyscan "$DEPLOY_HOST" >> ~/.ssh/known_hosts
  environment:
    name: production
    url: https://myapp.example.com
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
      when: manual
  script:
    - rsync -avz --delete dist/ "$DEPLOY_USER@$DEPLOY_HOST:/var/www/myapp/"
    - ssh "$DEPLOY_USER@$DEPLOY_HOST" "sudo systemctl reload nginx"

Two things to note in this job:

  • --delete in rsync makes the server directory exactly match the artifacts — files not in the build are also deleted. This prevents stale files from piling up.
  • The rules + when: manual combination reminds us of episode 14: production deployment waits for human approval and only runs on the main branch.

Tip

For speed, add rsync options like --compress and --partial. For security, never run the deployment job as root on the server — use a deploy user with sudo limited to only the commands needed (e.g. systemctl reload nginx).

Automating Deployment with Ansible from GitLab CI

When a deployment goes beyond just copying files — say it needs to manage several servers, modify Nginx config, or run conditional steps — long shell scripts start getting hard to maintain. That's where Ansible comes in: a playbook declares the server's desired end state, and GitLab CI just calls ansible-playbook.

Create the playbook and inventory in the repository:

Ansible inventory (inventory/production.ini)
[production]
myapp ansible_host=myapp.example.com ansible_user=deploy
Ansible playbook (deploy.yml)
- name: Deploy aplikasi ke production
  hosts: production
  become: true
  tasks:
    - name: Pastikan direktori aplikasi ada
      file:
        path: /var/www/myapp
        state: directory
 
    - name: Sinkronkan artefak build ke server
      synchronize:
        src: dist/
        dest: /var/www/myapp/
 
    - name: Reload Nginx
      systemd:
        name: nginx
        state: reloaded

Ansible's synchronize module leverages rsync under the hood, so the same sync pattern still applies. The CI job becomes very clean:

Ansible-based deployment job
deploy-ansible:
  stage: deploy
  image: alpine:latest
  before_script:
    - apk add --no-cache openssh-client ansible
    - eval "$(ssh-agent -s)"
    - ssh-add "$SSH_PRIVATE_KEY"
    - mkdir -p ~/.ssh
    - ssh-keyscan "$DEPLOY_HOST" >> ~/.ssh/known_hosts
  environment:
    name: production
    url: https://myapp.example.com
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
      when: manual
  script:
    - ansible-playbook -i inventory/production.ini deploy.yml

All the deployment logic now lives in the Ansible playbook — testable locally, versionable, and not locked inside pipeline YAML. The GitLab job's only job: set up SSH, then hand execution over to Ansible.

Common CD via SSH Mistakes

MistakeSymptomSolution
No ssh-keyscanJob hangs at the host key promptSeed known_hosts before connecting
Key with a passphraseJob asks for interaction that can't be answeredGenerate without a passphrase for CI
Sending the private key as a regular variableKey contents visible in logs if echoedUse a File type variable + Masked
rsync without --deleteStale files pile up on the serverAdd --delete for full sync
Running SSH as rootMajor security riskUse a dedicated user with limited sudo
Ansible playbook without local testingNew errors appear only in productionRun ansible-playbook --check first

Important

Always test the connection in check mode before a real deployment. Ansible provides ansible-playbook --check for dry runs, and SSH commands can be tested manually with ssh -o StrictHostKeyChecking=no on a developer machine. The more you test before production, the less blows up after production.

Closing

In this episode we covered the Continuous Deployment flow to Linux servers via SSH: setting up an ed25519 key pair and storing it as the SSH_PRIVATE_KEY File type variable in GitLab; configuring ssh-agent and ssh-keyscan in before_script so connections run without interaction; executing deployments with remote commands via SSH and artifact sync with rsync; and automating the entire deployment with an Ansible playbook called directly from the pipeline.

The core of this episode: a reliable deployment is one that can be repeated without surprises. With securely stored keys, deterministic SSH setup, and playbooks that declare the end state, deploying to a server becomes an ordinary action — not a tense moment.

In the next episode 16 we take deployment to the next level: Continuous Deployment to Kubernetes — securely connecting GitLab to your cluster with the GitLab Agent for Kubernetes, deploying manifests and Helm charts with kubectl and helm upgrade --install, and introducing GitOps with ArgoCD and Flux. See you there!

Learn GitLab CI/CD - Continuous Deployment (CD) to Linux Servers via SSH / Ansible | Learn GitLab CI/CD