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:
- Developer →
git push. You write code and commit as usual. - GitHub repository. Your code lands in the remote repository.
- GitHub Actions workflow. This is the core part. As soon as code is pushed or merged to
main, GitHub triggers your workflow automatically. - 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.
- Deploy on the VM. It pulls the new version, stops the old application, starts the new one and runs a health check.
- 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 }}
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.
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
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/appand restart one service. It doesn't need to beroot. - 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?
