Learn GitHub Actions - Continuous Deployment (CD) to Servers via SSH, Rsync, and Ansible
Episode 13 of 21

Learn GitHub Actions - Continuous Deployment (CD) to Servers via SSH, Rsync, and Ansible

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.

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

Introduction

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.

Main Discussion

Preparing an SSH Private Key in Secrets

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.

Create an SSH key pair on your local machine
ssh-keygen -t ed25519 -C "github-actions-deploy@devvnull"
ssh-copy-id -i ~/.ssh/id_ed25519.pub deploy@server.example.com

ssh-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.

Remote Execution via SSH: appleboy/ssh-action

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:

Deploy with remote SSH execution
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 app

The 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.

Syncing Files with Rsync and SCP

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:

Upload build output with SCP
- 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: 1

source 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 sync via 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.

Deploying with Ansible

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:

Running an Ansible playbook from a workflow
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:

Deploy playbook snippet
- 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: restarted

The 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.

Common Mistakes

MistakeSymptomSolution
Private key committed to the repositoryServer access could be taken by anyoneRotate the key, store only as a secret
Host key verification errorSSH connection rejectedSet ANSIBLE_HOST_KEY_CHECKING for Ansible
Wrong SCP source pathFiles not uploaded or scatteredCheck the relative path and strip_components
Forgetting to install Ansibleansible command not foundAdd setup-python + pip install ansible
Deploy to server without rollbackError hits production immediatelyKeep release versions and prepare rollback

Conclusion

Server deployment is now part of the pipeline, not a manual ritual:

  • SSH private key is stored as a secret and placed as a public key on the server; the private key never touches the repository.
  • appleboy/ssh-action executes remote commands; scp-action and rsync copy build output files.
  • Ansible handles idempotent multi-server configuration, run directly from the workflow with setup-python + pip install ansible.
  • All steps are versioned and validated through pull requests like regular code.

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!

Learn GitHub Actions - Continuous Deployment (CD) to Servers via SSH, Rsync, and Ansible | Learn GitHub Actions