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
deployuser may execute/usr/local/bin/git-pull-by-dockeruserasdockeruserwithout 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. deploydoes not get root access.deploycan sudo only todockeruser.deploycan run only one specific script through sudo.- The deployment script is owned by root.
dockeruserowns/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.