CI/CD: Auto-Deploy to a DigitalOcean Droplet with GitHub Actions
Disclosure: Some links in this guide are affiliate links. If you sign up for DigitalOcean through them, ToolsHubKit earns a small commission — at no cost to you.
This guide builds a CI/CD pipeline that automatically deploys your application to a DigitalOcean Droplet whenever you push to main. It works with any stack — Spring Boot, Laravel, Next.js, Symfony, or a plain Node.js service. The result is a single git push that runs your tests, builds your artifacts, ships them, and restarts your service — with zero manual SSH sessions required.
Prerequisites:
- A DigitalOcean Droplet already provisioned (Spring Boot guide / Laravel guide / Next.js guide)
- A GitHub repository with your application code
- Basic familiarity with GitHub Actions YAML
1. Provision the Deploy Target
If you don't have a Droplet yet, create one. Get $200 in free credits for 60 days when you sign up.
Choose Ubuntu 24.04 LTS, 1 GB RAM ($6/mo) for lightweight apps, 2 GB ($12/mo) for Java or memory-intensive stacks. Add your own SSH key at creation.
2. Create a Dedicated Deploy User
Never SSH as root from CI. Create a deploy user with minimal privileges:
ssh root@YOUR_SERVER_IP
adduser deploy
usermod -aG sudo deploy
# If your app runs as www-data (PHP, static files), add deploy to that group
usermod -aG www-data deploy
3. Generate a Deploy SSH Key Pair
Generate a key pair on your local machine (not the server):
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/deploy_key -N ""
This creates:
~/.ssh/deploy_key— private key → goes into GitHub Secrets~/.ssh/deploy_key.pub— public key → goes onto the server
Add the public key to the server:
ssh root@YOUR_SERVER_IP \
"mkdir -p /home/deploy/.ssh && \
echo '$(cat ~/.ssh/deploy_key.pub)' >> /home/deploy/.ssh/authorized_keys && \
chown -R deploy:deploy /home/deploy/.ssh && \
chmod 700 /home/deploy/.ssh && \
chmod 600 /home/deploy/.ssh/authorized_keys"
Test that it works before wiring GitHub:
ssh -i ~/.ssh/deploy_key deploy@YOUR_SERVER_IP "echo connected"
4. Add Secrets to GitHub
In your repository: Settings → Secrets and variables → Actions → New repository secret
| Secret name | Value |
|---|---|
SSH_PRIVATE_KEY |
Contents of ~/.ssh/deploy_key (the private key, entire file) |
SSH_HOST |
Your Droplet's public IP address |
SSH_USER |
deploy |
SSH_PORT |
22 (or your custom SSH port) |
Optional (for notifications):
| Secret name | Value |
|---|---|
SLACK_WEBHOOK_URL |
Slack incoming webhook URL |
5. Set Up the Release Directory on the Server
Zero-downtime deploys use a symlinked release structure. Set it up once:
ssh deploy@YOUR_SERVER_IP "
mkdir -p /var/www/myapp/releases
mkdir -p /var/www/myapp/shared
# Shared files that persist across releases
mkdir -p /var/www/myapp/shared/.env
"
Your .env.local or secrets live in /var/www/myapp/shared/ and get symlinked into each release, so they survive deploys.
6. The GitHub Actions Workflow
Create .github/workflows/deploy.yml:
name: Deploy to DigitalOcean
on:
push:
branches: [main]
env:
APP_DIR: /var/www/myapp
jobs:
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# --- Spring Boot ---
# - name: Set up Java
# uses: actions/setup-java@v4
# with: { java-version: '21', distribution: 'temurin' }
# - run: ./mvnw test
# --- Laravel ---
# - name: Set up PHP
# uses: shivammathur/setup-php@v2
# with: { php-version: '8.3' }
# - run: composer install --no-dev && php artisan test
# --- Next.js / Node ---
# - uses: actions/setup-node@v4
# with: { node-version: '20' }
# - run: npm ci && npm test
- name: Placeholder test
run: echo "Add your test commands above"
build:
name: Build
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v4
# --- Spring Boot: build a fat JAR ---
# - uses: actions/setup-java@v4
# with: { java-version: '21', distribution: 'temurin' }
# - run: ./mvnw package -DskipTests
# - uses: actions/upload-artifact@v4
# with:
# name: app-jar
# path: target/*.jar
# --- Next.js: standalone build ---
# - uses: actions/setup-node@v4
# with: { node-version: '20' }
# - run: npm ci && npm run build
- name: Placeholder build
run: echo "Add your build commands above"
deploy:
name: Deploy
runs-on: ubuntu-latest
needs: build
environment: production
steps:
- uses: actions/checkout@v4
- name: Configure SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -p ${{ secrets.SSH_PORT }} ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts
- name: Deploy to server
env:
SSH_HOST: ${{ secrets.SSH_HOST }}
SSH_USER: ${{ secrets.SSH_USER }}
SSH_PORT: ${{ secrets.SSH_PORT }}
GITHUB_SHA: ${{ github.sha }}
run: |
ssh -i ~/.ssh/deploy_key -p $SSH_PORT $SSH_USER@$SSH_HOST bash << 'ENDSSH'
set -e
RELEASE_DIR=$APP_DIR/releases/$(date +%Y%m%d%H%M%S)
mkdir -p $RELEASE_DIR
# Pull the latest code into the new release dir
git clone --depth 1 https://github.com/${{ github.repository }}.git $RELEASE_DIR
# Link shared secrets
ln -sf $APP_DIR/shared/.env.local $RELEASE_DIR/.env.local
# --- Stack-specific install / build steps ---
# Spring Boot:
# cp $APP_DIR/shared/app.jar $RELEASE_DIR/app.jar
#
# Laravel:
# cd $RELEASE_DIR && composer install --no-dev --optimize-autoloader
# php artisan config:cache && php artisan route:cache && php artisan migrate --force
#
# Next.js:
# cd $RELEASE_DIR && npm ci && npm run build
# Atomic symlink switch (zero downtime)
ln -sfn $RELEASE_DIR $APP_DIR/current
# Restart the service
sudo systemctl restart myapp
# Keep only the 5 most recent releases
ls -dt $APP_DIR/releases/* | tail -n +6 | xargs rm -rf
ENDSSH
- name: Notify Slack on success
if: success() && vars.SLACK_NOTIFY == 'true'
run: |
curl -s -X POST "${{ secrets.SLACK_WEBHOOK_URL }}" \
-H 'Content-type: application/json' \
-d "{\"text\":\"✅ Deployed *${{ github.repository }}* @ \`${{ github.sha }}\` to production.\"}"
- name: Notify Slack on failure
if: failure()
run: |
curl -s -X POST "${{ secrets.SLACK_WEBHOOK_URL }}" \
-H 'Content-type: application/json' \
-d "{\"text\":\"🚨 Deploy failed for *${{ github.repository }}* @ \`${{ github.sha }}\`.\"}"
7. Grant Passwordless sudo for Service Restart
The deploy script calls sudo systemctl restart myapp. Grant that specific permission without a password:
echo "deploy ALL=(ALL) NOPASSWD: /bin/systemctl restart myapp, /bin/systemctl reload nginx, /bin/systemctl reload php8.3-fpm" \
| sudo tee /etc/sudoers.d/deploy-ci
8. Stack-Specific Deploy Steps
Replace the placeholder comment blocks in the workflow with the right steps for your stack:
Spring Boot
- name: Build JAR
run: ./mvnw package -DskipTests
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: app-jar
path: target/*.jar
On the server (inside ENDSSH):
# Copy the JAR from the GitHub artifact to the release dir
# (use scp or artifact download step before SSH)
cp /tmp/app.jar $RELEASE_DIR/app.jar
sudo systemctl restart myapp
Laravel
cd $RELEASE_DIR
composer install --no-dev --optimize-autoloader --no-interaction
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan migrate --force
sudo systemctl reload php8.3-fpm
Next.js (standalone)
cd $RELEASE_DIR
npm ci --production=false
npm run build
sudo systemctl restart myapp
9. Rollback in One Command
Because each deploy is a dated directory under /var/www/myapp/releases/, rollback is an atomic symlink swap:
ssh deploy@YOUR_SERVER_IP "
PREV=\$(ls -dt /var/www/myapp/releases/* | sed -n '2p')
ln -sfn \$PREV /var/www/myapp/current
sudo systemctl restart myapp
echo Rolled back to \$PREV
"
You can also add this as a separate GitHub Actions workflow triggered manually via workflow_dispatch.
10. Zero-Downtime for Long-Running Requests
The atomic symlink switch means new requests always land on the new code. But in-flight requests to the old process still need time to finish. For graceful shutdown:
systemd — add to your service unit:
[Service]
TimeoutStopSec=30
KillMode=mixed
This gives running requests 30 seconds to complete before the process is killed.
Nginx — already handles this with worker_shutdown_timeout.
11. Add a Deployment Environment in GitHub
Create a GitHub Environment (Settings → Environments → New environment) named production. This lets you:
- Require manual approval before deploy runs
- Restrict which branches can deploy
- See a deployment history in the GitHub UI
Reference it in the workflow with environment: production (already in the template above).
Verify Your Pipeline
- Push a commit to
main - Go to Actions tab — watch the
Test → Build → Deploychain - SSH in and confirm:
ls -la /var/www/myapp/currentpoints to the newest release - Check your service:
sudo systemctl status myapp
Related Guides
- Deploy Spring Boot to DigitalOcean Droplet — the deploy target this pipeline ships to
- Laravel + Docker on App Platform — App Platform has built-in git-driven deploys (no CI/CD needed)
- Self-host your entire developer stack on one Droplet — multiple apps, one pipeline
Resources
| Resource | Link |
|---|---|
| DigitalOcean Droplets (+ $200 free credit) | Sign up here |
| GitHub Actions documentation | https://docs.github.com/en/actions |
| actions/checkout | https://github.com/marketplace/actions/checkout |
| shivammathur/setup-php | https://github.com/marketplace/actions/setup-php-action |