--- title: "How to Deploy to a Linux Server with GitHub Actions and SSH" description: "Build a GitHub Actions workflow that tests a Node.js app, then deploys it to an Ubuntu 24.04 server over SSH with rsync and restarts its systemd service safely." category: "CI/CD DevOps" language: en source: https://cubepath.com/docs/ci-cd-devops/github-actions-for-server-deployment updated: 2026-09-25 --- # How to Deploy to a Linux Server with GitHub Actions and SSH 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: ```bash 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: ```bash sudo nano /etc/systemd/system/your_app.service ``` ```ini [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: ```bash 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: ```bash sudo visudo -f /etc/sudoers.d/deploy-your_app ``` ```text deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart your_app ``` `visudo` checks the syntax before saving. Confirm the rule: ```bash sudo -l -U deploy ``` ```output 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: ```bash 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: ```bash 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: ```bash 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: ```bash 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: ```bash ssh-keyscan -t ed25519 your_server_ip ``` ```output 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: ```bash rm gha-deploy-key gha-deploy-key.pub ``` ## Step 4 - Writing the workflow In your repository, create the workflow file: ```bash mkdir -p .github/workflows nano .github/workflows/deploy.yml ``` Add the following content: ```yaml 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: ```bash 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: ```output 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: ```bash sudo systemctl status your_app ``` ```output ● 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) ``` ```bash curl -I http://localhost:3000 ``` ```output 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.