GitHub Actions runs workflows defined as YAML files in the .github/workflows directory of a repository, triggered by events such as a push or a pull request. In this tutorial you will create a workflow that installs and tests a Node.js application on every push and pull request, and on pushes to main copies it to an Ubuntu 24.04 server over SSH with rsync and restarts its systemd service. The deploy user on the server gets only the permissions it needs, and the SSH host key is verified instead of trusted blindly.

Prerequisites

To follow this guide you need:

  • A GitHub repository with a Node.js application whose package.json has a test script and a start entry point (this guide assumes server.js). Adapt the commands if your project differs.
  • A server running Ubuntu 24.04, for example a CubePath VPS, with a non-root user with sudo privileges and SSH access. This guide uses your_server_ip for its address.
  • Node.js 22 installed on the server, for example from the NodeSource repository, so that /usr/bin/node exists, and rsync (sudo apt install rsync).
  • Admin access to the repository to create secrets and environments.

Step 1 - Preparing the server

Create a system user that will own and run the application, and the directory it is deployed to:

sudo adduser --disabled-password --gecos "" deploy
sudo mkdir -p /srv/your_app
sudo chown deploy:deploy /srv/your_app

Create a systemd unit so the application starts at boot and restarts if it crashes:

sudo nano /etc/systemd/system/your_app.service
[Unit]
Description=your_app Node.js application
After=network.target

[Service]
Type=simple
User=deploy
WorkingDirectory=/srv/your_app
Environment=NODE_ENV=production
Environment=PORT=3000
ExecStart=/usr/bin/node server.js
Restart=on-failure

[Install]
WantedBy=multi-user.target

Reload systemd and enable the unit. Do not start it yet, the code is not there:

sudo systemctl daemon-reload
sudo systemctl enable your_app

The workflow must restart the service after each deploy, but the deploy user should not get full sudo. Create a sudoers rule that allows exactly one command:

sudo visudo -f /etc/sudoers.d/deploy-your_app
deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart your_app

visudo checks the syntax before saving. Confirm the rule:

sudo -l -U deploy
User deploy may run the following commands on server:
    (root) NOPASSWD: /usr/bin/systemctl restart your_app

Step 2 - Creating the deploy SSH key

On your local machine, generate a key pair used only by GitHub Actions. Leave the passphrase empty:

ssh-keygen -t ed25519 -C "github-actions" -f ./gha-deploy-key -N ""
cat gha-deploy-key.pub

Copy the printed line. On the server, install it as the deploy user's only authorized key:

sudo install -d -m 700 -o deploy -g deploy /home/deploy/.ssh
sudo nano /home/deploy/.ssh/authorized_keys

Paste the public key, save, and fix ownership and permissions:

sudo chown deploy:deploy /home/deploy/.ssh/authorized_keys
sudo chmod 600 /home/deploy/.ssh/authorized_keys

From your local machine, test the key and the sudo rule in one go:

ssh -i ./gha-deploy-key deploy@your_server_ip 'sudo -n /usr/bin/systemctl restart your_app; echo "exit code: $?"'

The command prints a non-zero exit code because there is no code to start yet, but it does not ask for a password, which proves the rule works. Then record the server's host key:

ssh-keyscan -t ed25519 your_server_ip
203.0.113.10 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...

Step 3 - Storing secrets in a GitHub environment

Environments let you scope secrets to deployment jobs and add protection rules such as required reviewers. In your repository go to Settings > Environments, click New environment and name it production. Under Deployment branches and tags, choose Selected branches and tags and add main, so no other branch can use these secrets.

In the same environment, add these Environment secrets:

NameValue
SSH_PRIVATE_KEYFull contents of gha-deploy-key, including the BEGIN and END lines
SSH_KNOWN_HOSTSThe line printed by ssh-keyscan
SSH_HOSTyour_server_ip

GitHub masks secret values in logs and does not pass them to workflows triggered from forks. Delete the local key files afterwards:

rm gha-deploy-key gha-deploy-key.pub

Step 4 - Writing the workflow

In your repository, create the workflow file:

mkdir -p .github/workflows
nano .github/workflows/deploy.yml

Add the following content:

name: Test and deploy

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  workflow_dispatch:

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm

      - run: npm ci
      - run: npm test

  deploy:
    needs: test
    if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
    runs-on: ubuntu-24.04
    environment: production
    concurrency:
      group: production
      cancel-in-progress: false
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm

      - name: Install production dependencies
        run: npm ci --omit=dev

      - name: Configure SSH
        env:
          SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
          SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
        run: |
          install -m 700 -d ~/.ssh
          printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key
          chmod 600 ~/.ssh/deploy_key
          printf '%s\n' "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts

      - name: Copy files
        env:
          SSH_HOST: ${{ secrets.SSH_HOST }}
        run: |
          rsync -az --delete \
            --exclude='.git/' --exclude='.github/' \
            -e "ssh -i $HOME/.ssh/deploy_key -o StrictHostKeyChecking=yes" \
            ./ "deploy@${SSH_HOST}:/srv/your_app/"

      - name: Restart service
        env:
          SSH_HOST: ${{ secrets.SSH_HOST }}
        run: |
          ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=yes "deploy@${SSH_HOST}" \
            'sudo -n /usr/bin/systemctl restart your_app && systemctl is-active your_app'

Here is what the workflow does:

  • on: runs on pushes and pull requests to main, and manually from the Actions tab thanks to workflow_dispatch.
  • permissions: contents: read: the automatic GITHUB_TOKEN can only read the repository, which is all this workflow needs.
  • test job: checks out the code, installs Node.js 22 with the npm cache enabled (keyed on package-lock.json) and runs the tests. It runs for pull requests too.
  • deploy job: needs: test means it only starts if tests pass, and the if condition limits it to pushes and manual runs on main. environment: production gives it access to the environment secrets and applies the environment's protection rules. concurrency queues deployments so two never overlap.
  • Install production dependencies: dependencies are installed on the runner with npm ci --omit=dev, so the server does not need build tools or access to the npm registry. This works for pure JavaScript dependencies; if your app uses native modules, run npm ci --omit=dev on the server over SSH instead.
  • Configure SSH: secrets are passed as environment variables rather than interpolated into the script, which avoids shell injection. The known hosts file makes SSH refuse to connect if the server's key changes.
  • Copy files: rsync --delete makes /srv/your_app match the checkout, excluding Git metadata.
  • Restart service: runs the single command allowed by the sudoers rule, then prints the service state. systemctl is-active returns a non-zero code if the service did not start, which fails the job.

Step 5 - Running the workflow

Commit and push the workflow:

git add .github/workflows/deploy.yml
git commit -m "Add test and deploy workflow"
git push origin main

Open the Actions tab of the repository and select the Test and deploy run. Both jobs should finish with a green check. Expand the Restart service step of the deploy job:

Run ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=yes "deploy@${SSH_HOST}" \
active

On the server, confirm that the service is running the new code:

sudo systemctl status your_app
● your_app.service - your_app Node.js application
     Loaded: loaded (/etc/systemd/system/your_app.service; enabled; preset: enabled)
     Active: active (running) since Thu 2026-09-24 12:04:51 UTC; 30s ago
   Main PID: 5120 (node)
curl -I http://localhost:3000
HTTP/1.1 200 OK
...

Finally, open a pull request against main: its run shows the test job and a skipped deploy job.

Step 6 - Requiring approval for production (optional)

To stop every push to main from reaching production immediately, go to Settings > Environments > production, enable Required reviewers and add yourself or a team. The deploy job then pauses with Waiting for review until someone approves it in the Actions tab, while tests still run automatically.

Troubleshooting

  • Host key verification failed: SSH_KNOWN_HOSTS does not match the host in SSH_HOST. Run ssh-keyscan against exactly the same address or hostname and update the secret.
  • Load key ... error in libcrypto or invalid format: the private key secret was pasted incompletely. Paste the whole file again, including the -----BEGIN OPENSSH PRIVATE KEY----- and -----END OPENSSH PRIVATE KEY----- lines.
  • sudo: a password is required: the command in the workflow does not exactly match the sudoers rule. Both must use the full path /usr/bin/systemctl restart your_app.
  • The deploy job never runs: check the if condition and that the branch is allowed in the environment's Deployment branches and tags setting.
  • Service fails after deploy with Cannot find module: a dependency is listed in devDependencies but needed at runtime. Move it to dependencies.

Conclusion

Every push to main is now tested and, if the tests pass, deployed to your server over SSH with a verified host key and a deploy user that can only restart one service. As next steps, add a staging environment deployed from another branch, put Nginx in front of the application on port 3000 with a Let's Encrypt certificate, and add a health check step that fails the job if the application does not answer after the restart.