Automating Your Deployment with GitHub Actions: The Complete Flow

Still building your backend by hand and SSH-ing into your EC2 or Azure VM to deploy it? Here is the full push-to-live pipeline with GitHub Actions: the workflow file, the SSH step, the deploy script and where your secrets belong.

If you're still building your backend by hand every time, then SSH-ing into your VM, EC2 or Azure VM to deploy it by hand, you're doing it wrong. Let me show you how to automate it.

Here's how most of us work with Git.

We have a main branch, and it always holds stable production code. For every new feature we create a new branch, push our changes and test them. Only when the feature is stable do we merge it into main.

That merge into main is where the magic happens.

Your deployment should start from that merge, not from you opening a terminal. Every manual deploy is a chance to forget a step, ship the wrong build or take production down on a Friday evening.


The complete flow

This is the whole pipeline, from git push to a new version running on your server:

1 · DEVELOPER git push merge to main 2 · GITHUB Repository push event fires 3 · ACTIONS Workflow runs build · test · package 4 · SSH Connect to VM key from Secrets 5 · VM Deploy stop old · start new · check 6 · LIVE New version no hands on the server
From a merge into main to a health-checked release, with nobody logging in to the server.
  1. Developer → git push. You write code and commit as usual.
  2. GitHub repository. Your code lands in the remote repository.
  3. GitHub Actions workflow. This is the core part. As soon as code is pushed or merged to main, GitHub triggers your workflow automatically.
  4. Connect to the VM over SSH. Once the build is ready, GitHub Actions opens a secure SSH connection to your deployment target: EC2, Azure VM or any VPS.
  5. Deploy on the VM. It pulls the new version, stops the old application, starts the new one and runs a health check.
  6. New version running. Deployment complete. Your service is live and you never touched the server.

How does GitHub know what to do?

You tell it in a YAML file. Create this path in your repository:

.github/workflows/deploy.yml

GitHub runs every .yml file in .github/workflows/ whenever one of the events it lists happens, such as a push, a pull request or a merge.

Inside that workflow you define the steps:

  • Checkout the code
  • Build the project (npm run build, mvn package, ./gradlew bootJar…)
  • Run tests
  • Create the artifact, a JAR file or a Docker image
  • Ship it to the server and restart the app

Here is a complete workflow for a Spring Boot backend. It builds a JAR, copies it to the VM, and runs a deploy script there over SSH:

name: Deploy to production

on:
  push:
    branches: [main]      # runs on every push or merge to main
  workflow_dispatch:       # plus a "Run workflow" button for manual redeploys

concurrency:
  group: production-deploy
  cancel-in-progress: false  # never kill a deploy halfway through

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Java
        uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: '21'
          cache: maven

      - name: Build and run tests
        run: mvn -B clean package   # the tests run here; a failing test stops the deploy

      - name: Copy the JAR to the VM
        uses: appleboy/scp-action@v0.1.7
        with:
          host: ${{ secrets.SSH_HOST }}
          username: ${{ secrets.SSH_USERNAME }}
          key: ${{ secrets.SSH_KEY }}
          source: target/app.jar
          target: /opt/app/releases/${{ github.sha }}
          strip_components: 1

      - name: Deploy on the VM
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.SSH_HOST }}
          username: ${{ secrets.SSH_USERNAME }}
          key: ${{ secrets.SSH_KEY }}
          script: /opt/app/deploy.sh ${{ github.sha }}
Why build on GitHub and not on the server?

Your production VM should run your app, not compile it. Building on the GitHub runner keeps the build tools, caches and CPU spikes off the server. It also means a build that fails never gets anywhere near production.


What happens on the VM

The last step calls a small script that lives on the server. It does the four things you used to do by hand: take the new version, stop the old one, start the new one and check that it's alive. If the health check fails, it puts the previous version back.

#!/usr/bin/env bash
# /opt/app/deploy.sh <release-id>
set -euo pipefail

RELEASE="/opt/app/releases/$1/app.jar"
CURRENT="/opt/app/current.jar"
PREVIOUS="/opt/app/previous.jar"

# 1. Pull / download the new version (the workflow already copied it here)
[ -f "$RELEASE" ] || { echo "Missing $RELEASE"; exit 1; }

# 2. Keep the running version so we can roll back
[ -f "$CURRENT" ] && cp "$CURRENT" "$PREVIOUS"
cp "$RELEASE" "$CURRENT"

# 3. Stop the old application and start the new one
sudo systemctl restart app

# 4. Health check: give it up to ~60 seconds to come up
for _ in $(seq 1 12); do
  if curl -fsS http://127.0.0.1:8080/health > /dev/null; then
    echo "Deployed $1"
    exit 0
  fi
  sleep 5
done

# Still down: roll back to the previous version and fail the workflow
echo "Health check failed, rolling back"
[ -f "$PREVIOUS" ] && cp "$PREVIOUS" "$CURRENT" && sudo systemctl restart app
exit 1

Because the script exits with an error when the health check fails, the GitHub Actions run turns red. You find out from a failed check on your commit, not from a user telling you the site is down.

Using Docker instead of a JAR?

The flow is the same. Build and push the image in the workflow (for example to GitHub Container Registry), then on the VM run docker compose pull followed by docker compose up -d. Tag every image with the commit SHA so that rolling back is just starting the previous tag.


Critical security note

Never hardcode secrets

When you write your .yml file, never put your SSH keys, passwords or access tokens in it. Anyone who can read the repository can read the workflow, and anything you commit stays in Git history even after you delete it.

Store them in GitHub instead:

Repo Settings → Secrets and variables → Actions → New repository secret

Then reference them in your workflow like this:

${{ secrets.SSH_HOST }}
${{ secrets.SSH_KEY }}
${{ secrets.SSH_USERNAME }}

GitHub encrypts them and hides them in the logs. It also never passes them to workflows triggered by pull requests from forks.

A few more habits worth building from day one:

  • Use a dedicated deploy key. Generate a fresh SSH key pair only for GitHub Actions and add its public key to the server's authorized_keys. Don't reuse your personal key.
  • Use a deploy user with limited rights. The deploy user only needs to write to /opt/app and restart one service. It doesn't need to be root.
  • Protect main. Require pull requests and passing checks before anything merges. Once merging deploys, merging is a production change.

Stop deploying manually

Once this is set up, a release is just a merge. The build is the same every time, a failed test blocks the release, a bad release rolls itself back, and nobody has to remember which commands to run on which server.

Stop deploying manually. Start shipping faster.

Have you automated your deployments yet? If not, what's stopping you?

Written by Rahul Thatipamula

Founder & Lead Developer, CAX

See all from CAX Blog