Code, Explained

Secure GitHub Actions Deployment to Ubuntu Using a Dedicated Deploy User

Automating deployment from GitHub to an Ubuntu server doesn’t need to mean giving GitHub access to root.

A better approach is to create a dedicated deploy user, allow GitHub Actions to SSH into the server as that user, and then give deploy permission to execute only one specific deployment script as another user.

This post walks through that setup.


Architecture

The deployment flow will look like this:

Developer
    │
    │ git push
    ▼
GitHub Repository
    │
    │ GitHub Actions
    ▼
GitHub Actions Runner
    │
    │ SSH
    ▼
deploy user
    │
    │ sudo -u dockeruser
    │ NOPASSWD
    ▼
dockeruser
    │
    │ execute deployment script
    ▼
/var/www/example.com
    │
    │ git pull
    ▼
Latest Code

The important security principle is:

GitHub should not need root access to the production server.


1. Add GitHub Actions

GitHub Actions workflows are stored inside:

.github/workflows/

Create a workflow file in your repository:

.github/workflows/deploy.yml

For example:

name: Deploy

on:
  push:
    branches:
      - main

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Deploy to server
        uses: appleboy/ssh-action@YOUR_PINNED_COMMIT_SHA
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: deploy
          key: ${{ secrets.SERVER_SSH_KEY }}
          script: |
            sudo -u dockeruser /usr/local/bin/git-pull-by-dockeruser

Every time code is pushed to the main branch, GitHub Actions connects to the server and executes:

sudo -u dockeruser /usr/local/bin/git-pull-by-dockeruser

GitHub Secrets

Go to:

Repository
→ Settings
→ Secrets and variables
→ Actions

Create these secrets:

SERVER_HOST
SERVER_SSH_KEY
SERVER_FINGERPRINT

SERVER_HOST

Your server hostname or IP address.

SERVER_SSH_KEY

Store the complete private SSH key, including:

-----BEGIN OPENSSH PRIVATE KEY-----
...
-----END OPENSSH PRIVATE KEY-----

Do not remove the line breaks.

The corresponding public key will be installed on the server for the deploy user.

SERVER_FINGERPRINT

This is the SSH server’s host-key fingerprint.

On Ubuntu, you can obtain it with:

ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub -E sha256

It will look similar to:

256 SHA256:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx server (ED25519)

Store the SHA256:... portion in:

SERVER_FINGERPRINT

Using the fingerprint prevents the deployment process from blindly trusting an unknown SSH server.


2. Create a Dedicated Deploy User on Ubuntu

Instead of allowing GitHub Actions to connect as root, create a dedicated user:

sudo adduser deploy

If you don’t need an interactive shell, you can still use /bin/bash for troubleshooting and later restrict access further.

Create the SSH directory:

sudo mkdir -p /home/deploy/.ssh
sudo chmod 700 /home/deploy/.ssh

Add the public key:

sudo nano /home/deploy/.ssh/authorized_keys

Paste the public key corresponding to the private key stored in:

SERVER_SSH_KEY

Then set ownership and permissions:

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

Test the connection:

ssh deploy@your-server

If everything is configured correctly, GitHub Actions can authenticate as:

deploy

without using a root account.


3. Create the Deployment Script

Now create a script that performs the actual Git operation.

Create:

sudo nano /usr/local/bin/git-pull-by-dockeruser

Example:

#!/bin/bash

set -e

PROJECT="/var/www/example.com"

cd "$PROJECT"

echo "Running Git pull as: $(whoami)"

git pull origin main

echo "Deployment completed."

Make the script executable:

sudo chmod 755 /usr/local/bin/git-pull-by-dockeruser

Most importantly, make it owned by root:

sudo chown root:root /usr/local/bin/git-pull-by-dockeruser

This prevents the deploy user from modifying the script.


4. Allow Deploy to Run Only This Script as dockeruser

The Git operation needs to run as:

dockeruser

rather than:

deploy

We can use sudo for this.

Create a dedicated sudoers configuration:

sudo visudo -f /etc/sudoers.d/deploy-git

Add:

deploy ALL=(dockeruser) NOPASSWD: /usr/local/bin/git-pull-by-dockeruser

This means:

The deploy user may execute /usr/local/bin/git-pull-by-dockeruser as dockeruser without entering a password.

It does not give deploy unrestricted sudo access.

For example, this is intentionally avoided:

deploy ALL=(ALL) NOPASSWD: ALL

That would give the deployment account far more privileges than necessary.


5. Test the Sudo Permission

Switch to the deployment user:

sudo -iu deploy

Then execute:

sudo -u dockeruser /usr/local/bin/git-pull-by-dockeruser

It should run without asking for a password.

You can also check the allowed sudo commands:

sudo -l

You should see something similar to:

(dockeruser) NOPASSWD: /usr/local/bin/git-pull-by-dockeruser

6. Make Sure dockeruser Can Write to the Git Repository

Because the Git command runs as dockeruser, the repository must be writable by that user.

For example:

sudo chown -R dockeruser:www-data /var/www/example.com

The important part is that dockeruser must be able to write to:

/var/www/example.com/.git/

especially:

.git/objects
.git/refs

Otherwise git pull can fail with an error such as:

error: insufficient permission for adding an object to repository database .git/objects
fatal: failed to write object
fatal: unpack-objects failed

Test directly:

sudo -u dockeruser git -C /var/www/example.com pull origin main

If that works, the GitHub Actions deployment should also work.


7. GitHub Actions Deployment

Once everything is configured, the GitHub Actions workflow only needs to execute:

script: |
  sudo -u dockeruser /usr/local/bin/git-pull-by-dockeruser

The complete flow is:

git push
   │
   ▼
GitHub
   │
   ▼
GitHub Actions
   │
   │ SSH using SERVER_SSH_KEY
   ▼
deploy
   │
   │ sudo -u dockeruser
   │ NOPASSWD
   ▼
dockeruser
   │
   ▼
git-pull-by-dockeruser
   │
   ▼
git pull origin main

8. Why Use Two Different Users?

Using separate users provides a useful security boundary.

deploy

The deploy user exists primarily for:

GitHub Actions → Server

It doesn’t need to be the owner of the application or have unrestricted server privileges.

dockeruser

The dockeruser account is responsible for:

Git repository operations

and can own the application files if that fits the server architecture.

root

Root owns the deployment script and controls the sudo permission.

This gives you:

GitHub
  ↓
deploy
  ↓
specific sudo command
  ↓
dockeruser

instead of:

GitHub
  ↓
root

The second architecture should generally be avoided.


9. Additional Security Recommendations

Don’t use root for GitHub SSH

Avoid:

username: root

Use:

username: deploy

instead.


Don’t give deploy unrestricted sudo

Avoid:

deploy ALL=(ALL) NOPASSWD: ALL

Use:

deploy ALL=(dockeruser) NOPASSWD: /usr/local/bin/git-pull-by-dockeruser

Protect the deployment script

The deployment script should be:

root:root
755

For example:

sudo chown root:root /usr/local/bin/git-pull-by-dockeruser
sudo chmod 755 /usr/local/bin/git-pull-by-dockeruser

The deploy user should not be able to modify it.


Don’t disable SSH host verification

Avoid configurations that effectively disable host verification, such as:

StrictHostKeyChecking=no

Use the server fingerprint instead:

fingerprint: ${{ secrets.SERVER_FINGERPRINT }}

Pin third-party GitHub Actions

If using:

uses: appleboy/ssh-action

don’t blindly use a moving branch such as:

uses: appleboy/ssh-action@master

For production, pin the Action to a specific commit SHA. This reduces the risk of a future change to the Action unexpectedly changing what runs in your deployment pipeline.


10. Final Recommended Setup

For a simple Ubuntu production server, this is a good structure:

GitHub Repository
       │
       │ push to main
       ▼
GitHub Actions
       │
       │ SSH
       ▼
    deploy
       │
       │ sudo
       │ NOPASSWD
       ▼
  dockeruser
       │
       ▼
git-pull-by-dockeruser
       │
       ▼
/var/www/example.com

The key principle is least privilege:

  • GitHub gets an SSH key only for deploy.
  • deploy does not get root access.
  • deploy can sudo only to dockeruser.
  • deploy can run only one specific script through sudo.
  • The deployment script is owned by root.
  • dockeruser owns/writes the Git repository.
  • SSH host verification uses a known server fingerprint.

This provides a relatively simple deployment system while keeping the privileges of the GitHub Actions credential tightly constrained.

Posted in git, linux, ubuntuTagged , , , , , ,