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.

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.
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.
First, generate a key pair on your local machine. The ed25519 key is recommended — shorter, faster, and modern:
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:
SSH_PRIVATE_KEY, choose Type: File, then paste the private key contents.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.
before_scriptThe 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:
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_hostsNotice 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.
With the setup above, the two most common execution patterns are:
Example of a complete deployment job combining both:
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.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).
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:
[production]
myapp ansible_host=myapp.example.com ansible_user=deploy- 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: reloadedAnsible's synchronize module leverages rsync under the hood, so the same sync pattern still applies. The CI job becomes very clean:
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.ymlAll 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.
| Mistake | Symptom | Solution |
|---|---|---|
No ssh-keyscan | Job hangs at the host key prompt | Seed known_hosts before connecting |
| Key with a passphrase | Job asks for interaction that can't be answered | Generate without a passphrase for CI |
| Sending the private key as a regular variable | Key contents visible in logs if echoed | Use a File type variable + Masked |
rsync without --delete | Stale files pile up on the server | Add --delete for full sync |
| Running SSH as root | Major security risk | Use a dedicated user with limited sudo |
| Ansible playbook without local testing | New errors appear only in production | Run 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.
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!