Cloud

GitHub Actions Deploy: Linux Servers in 20 Min

GitHub Actions Deploy: Linux Servers in 20 Min

GitHub Actions automates Linux server deployments via SSH using encrypted secrets for credentials. You configure a YAML workflow triggered by a push to the main branch, execute remote commands, and manage automatic rollback if the operation fails, eliminating manual errors. Deploying manually to 12 servers every week means copying, pasting, and hoping everything works. One Friday afternoon, I uploaded the wrong configuration file to 4 production servers before I realized the mistake. The downtime lasted 40 minutes. GitHub Actions with an approval gate would have stopped the deployment before the file reached the servers. According to the Puppet State of DevOps Report 2024, 45% of production incidents stem from manual configuration errors. I configured my first automated workflow in 20 minutes and never did a manual deployment on those servers again. You will learn to set up secure SSH deployments, configure environment protection rules, and implement instant rollbacks.

Prerequisites for GitHub Actions Deploy

Before you start, you need a Linux server accessible via SSH and a GitHub repository with the code to deploy. On the server, install git and configure the application. I use Node.js for this guide, but the workflow applies to any stack. On your local machine, install git and generate an SSH key dedicated to the deployment.

Workflow Structure and Secrets Management

A workflow is a YAML file inside the .github/workflows/ directory of your repository. GitHub executes it automatically when the event specified in the on block occurs. The jobs block contains the steps executed on a temporary runner, an Ubuntu container that GitHub creates, uses, and destroys in minutes. Each step inside the job executes an action or a shell command.

name: Deploy Production
on:
  push:
    branches: [main]
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
      - name: Deploy via SSH
        uses: appleboy/ssh-action@master

The runner downloads the code with actions/checkout, then uses appleboy/ssh-action to connect to your Linux server and execute the deployment commands.

Never write passwords or private keys in code. The Verizon DBIR 2025 reports that 73% of breaches start from stolen credentials, often hardcoded in configuration files. GitHub solves this problem with secrets. Go to Settings > Secrets and variables > Actions in your repository and create three secrets:

  1. SERVER_IP: the public IP address of your Linux server.
  2. SERVER_USER: the SSH user for the deployment, for example deployuser.
  3. SSH_KEY: the dedicated SSH private key.

Generate the key on your local machine:

ssh-keygen -t ed25519 -C "github-deploy" -f ~/.ssh/github_deploy_key

Copy the content of the github_deploy_key.pub file into the ~/.ssh/authorized_keys file on the Linux server. Copy the content of the github_deploy_key file, which is the private key, into the SSH_KEY secret on GitHub. The runner can now authenticate without a password.

Deployment Strategies and Rollback Mechanisms

The basic workflow connects the runner to the server and executes remote commands. When you push to the main branch, GitHub creates the runner, reads the secrets, connects to the server, and executes the commands defined in script. If a command fails, the workflow stops and GitHub marks the job as an error.

# .github/workflows/deploy.yml
on:
  push:
    branches: [main]
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Deploy Node.js via SSH
        uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets.SERVER_IP }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_KEY }}
          script: |
            cd /var/www/nodeapp
            git pull origin main
            npm install --production
            npm run build
            pm2 restart nodeapp --update-env
            pm2 save

The runner pulls the code, installs dependencies without development packages, compiles the code, and restarts the process with pm2. The --update-env flag refreshes the environment variables. Organizations that adopt CI/CD reduce deployment times by 60% and production failures by 30%, according to the DORA State of DevOps 2025 report.

For static sites, you do not need git pull on the server. Compile locally in the runner and transfer the files with scp.

jobs:
  deploy-static:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Build static site
        run: npm install && npm run build
      - name: Transfer files via SSH
        uses: appleboy/scp-action@master
        with:
          host: ${{ secrets.SERVER_IP }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_KEY }}
          source: "dist/*"
          target: "/var/www/html"

The appleboy/scp-action copies the compiled files from the dist folder directly into the webroot of the server, replacing the old files.

If the deployment fails, you must return to the previous version in seconds. Use a strategy based on symlinks. Maintain two folders: current, which is a symlink to the active version, and releases, containing numbered folders for each version.

      - name: Rollback on failure
        if: failure()
        uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets.SERVER_IP }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_KEY }}
          script: |
            ln -sfn /var/www/releases/previous /var/www/current
            pm2 restart nodeapp
            echo "Rollback executed"

The if: failure() block activates this step only if one of the previous steps fails. The symlink points to the last stable version. The service restarts with the working code.

You can also receive an alert when the deployment finishes. Configure a Telegram bot and use curl in the workflow.

      - name: Notify Telegram on success
        if: success()
        run: |
          curl -s -X POST https://api.telegram.org/bot${{ secrets.TELEGRAM_TOKEN }}/sendMessage \n          -d chat_id=${{ secrets.TELEGRAM_CHAT_ID }} \n          -d text="Deploy completed on ${{ secrets.SERVER_IP }}"

Add TELEGRAM_TOKEN and TELEGRAM_CHAT_ID to your repository secrets. For failures, duplicate the step with if: failure() and change the alert text.

To add an extra layer of control, use environment protection rules. The on: push: branches: [main] block limits the trigger to the main branch. Create an environment called production in Settings > Environments. Add a required reviewer, a colleague who must approve the deployment before the runner starts.

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4

When you push to main, GitHub stops the workflow and sends a notification to the reviewer. The reviewer clicks Approve. Only then does the runner connect to the server. This approval gate would have stopped that Friday afternoon mistake, preventing the faulty configuration from reaching the 4 servers. You can read more about these protection rules in the official GitHub Actions documentation.

Common Errors and Troubleshooting

Permission denied (publickey): The runner cannot access the server. Verify that the public key is in the authorized_keys file of the remote user. Ensure the private key in the SSH_KEY secret includes the entire content of the file, including the final line.

Host key verification failed: The runner does not recognize the server fingerprint. Add script_stop: true and configure debug: true in appleboy/ssh-action to view the complete SSH connection output.

Secrets not found: The workflow returns errors about variables. Verify that the names of the secrets in Settings match exactly those in the YAML. A different character makes the secret invisible to the runner.

Long commands hanging: SSH closes the connection if a command produces no output for a long time. Add timeout options or divide the commands into separate steps.

Generating SSH keys without a password: Run ssh-keygen -t ed25519 -C "github-deploy" and press Enter when prompted for a password. A key without a passphrase allows the runner to authenticate automatically. Limit this key only to deployment on a user with reduced permissions.

Deploying to multiple servers simultaneously: Use the GitHub Actions matrix strategy to define a list of servers. The runner creates parallel jobs, one for each IP, and executes the deployment on all servers at the same moment.

GitHub Actions pricing for private projects: GitHub offers 2,000 free minutes per month for free accounts and 3,000 for Pro accounts. An SSH deployment workflow takes about 2 minutes. You can run approximately 1,000 deployments per month without additional costs.

Conclusions with Operational Takeaways

  1. Replace manual deployment with GitHub Actions. The YAML workflow eliminates copy and paste, reducing human errors by 45%.
  2. Use secrets for SSH credentials. No private keys in the repository, no plaintext passwords in logs.
  3. Add automatic rollback and approval gates. If the deployment fails, you return to the stable version. If the code is wrong, a colleague stops it before it reaches production.

Read also: Docker Deployment: First Production Container 2026

Read also: Setting Up a Linux VPS from Scratch: The 2026 Checklist

Read also: WireGuard VPN Linux: Configure in 15 Minutes 2026

Share this article:

Written by

Rosario Giordano

Rosario Giordano is a system administrator and IT consultant specializing in cybersecurity and cloud, with over 20 years of experience managing enterprise Linux infrastructures. His areas of expertise include SSH hardening, Kubernetes platforms, PostgreSQL databases, VMware/ Proxmox virtualization, and compliance with NIS2 and ISO 27001 security frameworks