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.jsonhas atestscript and astartentry point (this guide assumesserver.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
sudoprivileges and SSH access. This guide usesyour_server_ipfor its address. - Node.js 22 installed on the server, for example from the NodeSource repository, so that
/usr/bin/nodeexists, andrsync(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:
| Name | Value |
|---|---|
SSH_PRIVATE_KEY | Full contents of gha-deploy-key, including the BEGIN and END lines |
SSH_KNOWN_HOSTS | The line printed by ssh-keyscan |
SSH_HOST | your_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 tomain, and manually from the Actions tab thanks toworkflow_dispatch.permissions: contents: read: the automaticGITHUB_TOKENcan only read the repository, which is all this workflow needs.testjob: checks out the code, installs Node.js 22 with the npm cache enabled (keyed onpackage-lock.json) and runs the tests. It runs for pull requests too.deployjob:needs: testmeans it only starts if tests pass, and theifcondition limits it to pushes and manual runs onmain.environment: productiongives it access to the environment secrets and applies the environment's protection rules.concurrencyqueues 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, runnpm ci --omit=devon 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 --deletemakes/srv/your_appmatch the checkout, excluding Git metadata. - Restart service: runs the single command allowed by the sudoers rule, then prints the service state.
systemctl is-activereturns 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_HOSTSdoes not match the host inSSH_HOST. Runssh-keyscanagainst exactly the same address or hostname and update the secret.Load key ... error in libcryptoorinvalid 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
ifcondition 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 indevDependenciesbut needed at runtime. Move it todependencies.
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.