This episode discusses automated deployment to Linux servers starting from preparing an SSH private key in secrets, executing remote commands with ssh-action, syncing files with rsync, to running Ansible playbooks directly from the pipeline.

In episode 12 we successfully built and pushed a Docker image to a registry. But an idle image in a registry means nothing — a well-cooked dish has to reach the table. Episode 13 delivers it: Continuous Deployment (CD) — automated deployment from the pipeline to production servers.
The focus is classic Linux/VPS servers, which to this day remain the backbone of many applications. We start from preparing an SSH private key, remote command execution with appleboy/ssh-action, file syncing with Rsync/SCP, to idempotent configuration management with Ansible.
Deploying over SSH needs credentials: a pair of ed25519 keys. The public key goes in the authorized_keys file on the server, while the private key is stored as a repository secret — never committed.
ssh-keygen -t ed25519 -C "github-actions-deploy@devvnull"
ssh-copy-id -i ~/.ssh/id_ed25519.pub deploy@server.example.comssh-copy-id copies the public key to the server and tests the login. Then the contents of ~/.ssh/id_ed25519 are stored in Settings > Secrets as SERVER_SSH_KEY.
Warning
A private key is the master key to your production server — never put it in a repository, a Dockerfile, or logs. Limit the risk: create a dedicated deploy account with minimal permissions, add restrictions in authorized_keys (for example from= to limit source IPs), and restrict the SSH port with a firewall.
The most direct way: the workflow executes commands on the server over SSH. The appleboy/ssh-action action wraps the SSH connection and runs remote scripts:
name: Deploy Production
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SERVER_SSH_KEY }}
port: 22
script: |
cd /srv/app
git pull origin main
npm ci --omit=dev
systemctl restart appThe script under script runs on the server: pulling the latest code, installing production dependencies, then restarting the systemd service. Because this script is statically written in the workflow (not the result of interpolating user data), it's safe from the script injection we discussed in episode 9. The secret values are handled directly by this action and never appear in logs.
Sometimes what needs to be sent isn't a command, but files — for example the frontend build output from a previous job. First choice: appleboy/scp-action, which uploads files over the same connection:
- uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SERVER_SSH_KEY }}
source: "dist/*"
target: "/srv/app/dist"
strip_components: 1source and target determine the files sent and the destination on the server; strip_components removes the parent directory so the contents of dist/ land directly in the server's dist/.
For projects with many files, rsync is more efficient because it only transfers the differences. It can be called directly from a bash step using OpenSSH:
rsync -az --delete \
-e "ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no" \
dist/ deploy@server.example.com:/srv/app/dist/-a preserves file attributes, -z compresses the transfer, and --delete removes files on the server that no longer exist in the source — so the server always becomes an exact mirror of the build output. The private key needs to be prepared on the runner first, for example from a secret.
For a single server, a simple SSH script is enough. But once an application runs on several servers with configurations that must stay consistent, one-off scripts start to get fragile. That's where Ansible comes in: a configuration management tool that is idempotent — running a playbook repeatedly produces the same state, without duplicate side effects.
Ansible runs from the control machine (the runner), connects to servers over SSH, and needs no agent installed on the servers. From the workflow, we set up Python and Ansible, then run the playbook:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install ansible
- name: Siapkan kunci SSH
env:
SSH_KEY: ${{ secrets.SERVER_SSH_KEY }}
run: |
mkdir -p ~/.ssh
printf '%s\n' "$SSH_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
- name: Jalankan playbook
run: ansible-playbook -i inventory/prod.yml playbooks/deploy.yml
env:
ANSIBLE_HOST_KEY_CHECKING: "False"The playbook and inventory are stored in the repository — server configuration gets versioned just like code. A simple playbook example that updates the code, installs dependencies, and restarts the service:
- name: Deploy aplikasi
hosts: web
become: true
tasks:
- name: Perbarui kode dari git
ansible.builtin.git:
repo: https://github.com/devvnull/app.git
dest: /srv/app
version: main
- name: Pasang dependensi
ansible.builtin.command: npm ci --omit=dev
args:
chdir: /srv/app
- name: Restart service
ansible.builtin.systemd:
name: app
state: restartedThe idempotence advantage really shows when a deploy fails halfway: you just restart the workflow, and Ansible only completes the unfinished parts — it doesn't blindly redo everything.
| Mistake | Symptom | Solution |
|---|---|---|
| Private key committed to the repository | Server access could be taken by anyone | Rotate the key, store only as a secret |
| Host key verification error | SSH connection rejected | Set ANSIBLE_HOST_KEY_CHECKING for Ansible |
Wrong SCP source path | Files not uploaded or scattered | Check the relative path and strip_components |
| Forgetting to install Ansible | ansible command not found | Add setup-python + pip install ansible |
| Deploy to server without rollback | Error hits production immediately | Keep release versions and prepare rollback |
Server deployment is now part of the pipeline, not a manual ritual:
setup-python + pip install ansible.In the next episode 14, we level up: Continuous Deployment to Cloud & Kubernetes — deploying to Cloud Run, Vercel, and AWS Lambda, then to Kubernetes clusters with kubectl and Helm. From managing one server, to managing an entire platform!