DevOps

CI/CD: Auto-Deploy to a DigitalOcean Droplet with GitHub Actions

July 26, 202614 min read

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:


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:

bash
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):

bash
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/deploy_key -N ""

This creates:

  • ~/.ssh/deploy_keyprivate key → goes into GitHub Secrets
  • ~/.ssh/deploy_key.pubpublic key → goes onto the server

Add the public key to the server:

bash
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:

bash
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:

bash
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:

yaml
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:

bash
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

yaml
- 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):

bash
# 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

bash
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)

bash
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:

bash
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:

ini
[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

  1. Push a commit to main
  2. Go to Actions tab — watch the Test → Build → Deploy chain
  3. SSH in and confirm: ls -la /var/www/myapp/current points to the newest release
  4. Check your service: sudo systemctl status myapp

Related Guides


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