This guide creates a production deployment workflow for a static site hosted on a DigitalOcean LAMP Droplet. GitHub Actions uses rsync over SSH to deploy files whenever changes reach the main branch.
A dedicated, non-sudo deploy user limits the workflow to the website directory. The workflow does not receive an administrator account or permission to modify server configuration.
Local integrations branch
↓
GitHub integrations branch
↓
GitHub Pages development site
↓
Pull request into main
↓
Push created by the merge
↓
GitHub Actions production deployment
↓
DigitalOcean /var/www/html
A pull request does not directly trigger this production workflow. Merging the pull request creates a push to main, and that push triggers the deployment.
- An existing DigitalOcean LAMP Droplet
- A static site already working through Apache
- A non-root administrative SSH user with sudo access
- A project hosted in a GitHub repository
- GitHub Actions enabled for the repository
rsyncinstalled on the Droplet- A known production target such as
/var/www/html
Complete the applicable server setup before creating the action:
- How to Deploy a Website to DigitalOcean 💧 LAMP + Rsync (MacOS/Linux) + DNS Setup
- How to Deploy a Website to DigitalOcean 💧 LAMP + SFTP (Windows) + DNS Setup
- LAMP Server Audit Checklist
SSH into the Droplet as your existing sudo user, then create a dedicated deployment account:
sudo adduser deployDo not add deploy to the sudo group. A static-site deployment only needs SSH access and permission to modify one document root. It should not be able to install software, restart services, create users, or edit Apache configuration.
Make deploy the owner of the production site while keeping Apache's www-data group:
sudo chown -R deploy:www-data /var/www/html
sudo find /var/www/html -type d -exec chmod 2755 {} \;
sudo find /var/www/html -type f -exec chmod 644 {} \;This intentionally changes the production deployment owner from your manual deployment user to deploy. Apache can continue reading the files through the www-data group and normal read permissions.
If an application needs writable runtime directories, configure those directories separately. Do not make the entire document root writable by Apache.
Create a key specifically for this GitHub Actions deployment on your local computer:
ssh-keygen -m PEM -t rsa -b 4096 \
-f ~/.ssh/practice-layouts-deployWhen prompted for a passphrase, leave it empty. The rsync action used in this guide cannot enter a passphrase interactively.
The command creates two files:
practice-layouts-deploy Private key
practice-layouts-deploy.pub Public key
- Use a dedicated deployment key, not your personal administrator key.
- Add the public key to the
deployaccount on the server. - Add the private key to GitHub Actions secrets.
- Never display the private key, secret value, or a command that prints the private key during a stream, screenshot, or recording.
On the Droplet, switch to the deployment user and prepare its SSH directory:
sudo -iu deploy
mkdir -p ~/.ssh
chmod 700 ~/.ssh
nano ~/.ssh/authorized_keysPaste the complete contents of practice-layouts-deploy.pub into authorized_keys. Do not paste the private key.
Save the file, then set its permissions:
chmod 600 ~/.ssh/authorized_keys
exitConfirm the deployment user's SSH ownership and permissions:
sudo chown -R deploy:deploy /home/deploy/.ssh
sudo chmod 700 /home/deploy/.ssh
sudo chmod 600 /home/deploy/.ssh/authorized_keysTest the dedicated key from your local computer:
ssh -i ~/.ssh/practice-layouts-deploy \
deploy@practicelayouts.comConfirm that deploy can create and delete a file in the exact production directory without sudo:
touch /var/www/html/deploy-test.txt
rm /var/www/html/deploy-test.txtDo not continue until the dedicated key can log in and the deploy user can create and delete the test file.
If the test fails, return to the sudo account and check the document-root ownership:
sudo chown -R deploy:www-data /var/www/htmlOpen the GitHub repository and navigate to:
Settings
→ Secrets and variables
→ Actions
→ New repository secret
Create these four repository secrets:
| Secret | Value |
|---|---|
DO_SSH_PRIVATE_KEY |
Complete contents of the private deployment key |
DO_HOST |
practicelayouts.com or the Droplet's IP address |
DO_USER |
deploy |
DO_TARGET_DIR |
/var/www/html |
For DO_SSH_PRIVATE_KEY, copy the entire private key, including its beginning and ending lines. Do not use the .pub file.
Do not put secret values directly in the workflow file. GitHub secrets are referenced using expressions such as:
${{ secrets.DO_SSH_PRIVATE_KEY }}For additional isolation, you can store production secrets in a GitHub production environment and restrict which branches can deploy to it. Environment features and protection-rule availability depend on the repository visibility and GitHub plan.
Create this file in the project:
.github/workflows/deploy-production.yml
.github and workflows are directories. deploy-production.yml is the file.
This version follows the workflow created for Practice Layouts and adds explicit read-only GitHub token permissions and deployment concurrency:
name: Deploy to DigitalOcean
on:
push:
branches:
- main
permissions:
contents: read
concurrency:
group: production-deployment
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Deploy files
uses: easingthemes/ssh-deploy@v6.0.3
with:
SSH_PRIVATE_KEY: ${{ secrets.DO_SSH_PRIVATE_KEY }}
REMOTE_HOST: ${{ secrets.DO_HOST }}
REMOTE_USER: ${{ secrets.DO_USER }}
TARGET: ${{ secrets.DO_TARGET_DIR }}
SOURCE: ./
EXCLUDE: ".git/, .gitignore, .github/, svg/, snippets.html, .DS_Store, README.md, LICENSE.md, QA.md"The original Practice Layouts workflow used easingthemes/ssh-deploy@v5.1.0. The example uses the current v6.0.3 release. Review release notes before upgrading an existing workflow.
For stronger supply-chain protection, GitHub recommends pinning third-party actions to a reviewed full commit SHA instead of a movable version tag. A SHA-pinned line looks like:
uses: easingthemes/ssh-deploy@<full-reviewed-commit-sha> # v6.0.3Do not substitute an abbreviated SHA. Resolve and review the full commit for the release you intend to use.
pushlimits the production trigger to changes reachingmain.permissions: contents: readgives the workflow's GitHub token only the repository access needed for checkout.concurrencyprevents two production deployments from running simultaneously.ubuntu-latestdescribes the temporary GitHub-hosted runner, not the DigitalOcean Droplet.actions/checkoutplaces the repository on the runner.ssh-deployruns rsync over SSH.SOURCEis relative to the checked-out repository.TARGETis the directory on the DigitalOcean server.EXCLUDEis a comma-separated list for this action.
The Practice Layouts project is a vanilla HTML, CSS, and JavaScript site without a build step, so it deploys the repository root and excludes development-only files.
Do not copy the exclusion list blindly. Confirm that every excluded path is unnecessary in production. For example, exclude svg/ only if it contains design references rather than images required by the site.
The checked-out repository includes .git, so it must be excluded along with .github and other development-only content.
For future projects, a dedicated deployable directory such as dist/ or public/ is safer than deploying the repository root and maintaining a growing exclusion list. A build-based project would usually use something like:
SOURCE: dist/The Practice Layouts workflow does not use rsync's --delete option. Without --delete, a file removed from Git can remain on the production server.
Adding --delete makes production mirror the deployment source, but an incorrect source path or exclusion can remove valid production files. Treat it as an intentional deployment decision, not a default.
Before adding it to the action, test the equivalent rsync command manually with --dry-run as described in RSYNC.md.
If you decide production should mirror the repository, add reviewed rsync arguments supported by the selected action version. Do not enable --delete until the dry-run output is correct.
The ssh-deploy action documents a default SSH option that disables strict host-key checking. That is convenient for initial setup, but it does not verify that the runner connected to the intended server.
For a hardened deployment:
- Obtain the Droplet's SSH host key fingerprint through a trusted channel, such as an existing verified administrator connection or the DigitalOcean console.
- Compare it with the server's public host key.
- Store the verified
known_hostsentry as a protected GitHub secret such asDO_KNOWN_HOSTS. - Populate the runner's
known_hostsfile before deployment. - Set strict host-key checking to
yesfor the deployment action.
Do not trust an unverified ssh-keyscan result by itself; it discovers a key but does not prove the server's identity.
Example hardening step:
- name: Configure known hosts
shell: bash
run: |
install -m 700 -d ~/.ssh
printf '%s\n' "${{ secrets.DO_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
chmod 600 ~/.ssh/known_hostsThen add this input to the deployment step if it is supported by the reviewed action version:
SSH_CMD_ARGS: "-o StrictHostKeyChecking=yes"For the initial production test:
- Confirm the dedicated deployment key works manually.
- Confirm
deploycan write to the target directory. - Add the four GitHub secrets.
- Commit
.github/workflows/deploy-production.yml. - Push the workflow to
main. - Open the repository's Actions tab.
- Open the Deploy to DigitalOcean workflow run.
- Confirm both checkout and deployment steps succeed.
- Open the production site.
- Confirm excluded files were not uploaded.
Useful server checks:
ls -la /var/www/html
sudo find /var/www/html -maxdepth 2 -printf '%M %u:%g %p\n'Create the development branch before beginning the normal deployment cycle:
git switch -c integrations
git push -u origin integrationsConfigure the project's GitHub Pages settings to deploy the integrations branch to the development site.
The normal workflow is:
- Work locally on
integrations. - Push
integrationsto GitHub. - Review the GitHub Pages development site.
- Open a pull request from
integrationsintomain. - Review and merge the pull request.
- Watch the production GitHub Actions workflow.
- Verify the production site.
GitHub does not reveal a secret after it has been saved. Store the deployment key in an approved secure credential backup, or document how to rotate it.
If the local private key was created only as a temporary transfer copy, delete it after confirming that the GitHub secret and deployment work. Remember that losing every recoverable copy means you must rotate the key before updating the secret.
To rotate the deployment key:
- Generate a new dedicated key pair.
- Add the new public key to
/home/deploy/.ssh/authorized_keys. - Replace
DO_SSH_PRIVATE_KEYin GitHub. - Run and verify a deployment.
- Remove the old public key from
authorized_keys. - Remove any temporary local copies according to your credential policy.
Never remove the old public key until the new key has completed a successful deployment.
Check:
DO_SSH_PRIVATE_KEYcontains the private key, not the.pubfile.DO_USERisdeploy.- The matching public key is in
/home/deploy/.ssh/authorized_keys. /home/deploy/.sshis700.authorized_keysis600.- The files are owned by
deploy:deploy.
sudo ls -la /home/deploy/.sshConfirm the target and ownership:
sudo find /var/www/html -maxdepth 2 -printf '%M %u:%g %p\n'
sudo chown -R deploy:www-data /var/www/htmlThen test as the deployment user:
sudo -u deploy touch /var/www/html/deploy-test.txt
sudo -u deploy rm /var/www/html/deploy-test.txtOpening or updating a pull request does not trigger this workflow. The workflow runs when the pull request is merged and GitHub creates a push to main.
Confirm that the merge reached main and inspect the repository's Actions tab.
This is expected when the workflow does not use --delete. Review the deletion section before changing rsync behavior.
Review SOURCE and the comma-separated EXCLUDE value. Consider moving production files into a dedicated deployment directory instead of expanding the exclusion list indefinitely.
Open the repository's Pages settings and confirm the development site uses integrations, not main.
- The
deployuser exists and is not a sudo user. -
deployowns only the intended website directory. - A dedicated deployment SSH key was created.
- The public key is in the deployment user's
authorized_keysfile. - SSH directory and key permissions are correct.
- Manual SSH and document-root write tests succeed.
- The four repository secrets are configured.
- The workflow is in
.github/workflows/deploy-production.yml. - Production deployment is restricted to pushes to
main. - The workflow token has read-only repository permissions.
- Concurrent production deployments are prevented.
-
SOURCEand every exclusion were reviewed for this project. - The first workflow run succeeds.
- The production site contains only required files.
- The
integrationsbranch powers the development site. - The key is securely stored or cleaned up according to a documented rotation process.