Automatic deploys with GitHub Actions
Once a server is set up as in Deploy the API and web app to a VPS, every git push to main updates it. GitHub Actions connects to the server over SSH, and the server runs deploy/deploy.sh, which pulls the new code and rebuilds only what changed: the API, the web app, or both.
git push origin main
└─▶ GitHub Actions (workflow in .github/)
└─▶ ssh with VPS_SSH_KEY to VPS_USER@VPS_HOST
└─▶ the key may only run: bash /opt/infotech-pos/app/deploy/deploy.sh
├─ spring-admin/ changed → rebuild + restart the API container
└─ infotech-admin-web/ changed → rebuild the React app into /var/www/…
How the deploy key is restricted
At the end of its run, server-setup.sh creates a second SSH key pair, /root/.ssh/github_actions_infotech, just for GitHub Actions. It adds the public key to /root/.ssh/authorized_keys with these options:
restrict,command="bash /opt/infotech-pos/app/deploy/deploy.sh" ssh-ed25519 AAAA… github-actions-infotech
command=forces the deploy script. Whatever command the client asks for, the server runsdeploy.shand nothing else.restrictturns off port forwarding, agent forwarding and terminals.
So even if the private key leaked, it could only trigger a redeploy of the code already in your repository. Because the command is fixed, a deploy started from GitHub always runs deploy.sh without --all.
This key is separate from the read-only deploy key the server uses to pull from GitHub.
Add the GitHub secrets
server-setup.sh prints the values at the end. If you missed them, the private key is in /root/.ssh/github_actions_infotech on the server.
On GitHub, open the repository, then Settings › Secrets and variables › Actions › New repository secret, and add:
| Name | Value |
|---|---|
VPS_HOST | The server's public IP address |
VPS_USER | root |
VPS_SSH_KEY | The whole private key, including the -----BEGIN OPENSSH PRIVATE KEY----- and -----END OPENSSH PRIVATE KEY----- lines |
That is the last step. Push a commit to main and watch the run under the repository's Actions tab.
The workflow
The workflow file lives in .github/workflows/ at the repository root (next to spring-admin, infotech-admin-web and deploy). It only needs to open an SSH connection with the three secrets; the forced command does the rest. The repository ships this workflow (.github/workflows/deploy.yml):
name: Deploy to VPS
on:
push:
branches: [main]
paths:
- "spring-admin/**"
- "infotech-admin-web/**"
- "deploy/**"
workflow_dispatch:
# Never run two deploys at once; a newer push waits for the running one.
concurrency:
group: deploy-production
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Run deploy script on the server
env:
SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
HOST: ${{ secrets.VPS_HOST }}
USER: ${{ secrets.VPS_USER }}
run: |
mkdir -p ~/.ssh
printf '%s\n' "$SSH_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -H "$HOST" >> ~/.ssh/known_hosts 2>/dev/null
# The server key only allows running deploy/deploy.sh, so no command is sent.
ssh -i ~/.ssh/deploy_key -o ServerAliveInterval=30 "$USER@$HOST"
The paths filter means a push that only changes documentation or other folders does not start a deploy. Run workflow on the Actions tab (workflow_dispatch) deploys by hand.
The command word at the end (deploy) is ignored because of the forced command.
What a deploy does
deploy.sh (run as root):
- Locks. Only one deploy runs at a time. A second one waits up to 15 minutes, then fails with "Another deploy is still running".
- Loads settings from
/opt/infotech-pos/shared/.env. - Pulls.
git fetchofmain(or the branch inDEPLOY_BRANCH), thengit reset --hard origin/main. Any edits made directly in/opt/infotech-pos/appare discarded; keep settings in the shared.env. - Finds what changed between the previous and the new commit with
git diff --name-only. - API, if
spring-admin/ordeploy/compose.prod.yamlchanged:docker compose … up -d --build api, then waits for/actuator/healthto reportUP. Database migrations run during this start. - Web app, if
infotech-admin-web/changed: builds it in anode:22-alpinecontainer withVITE_API_URLfrom.envand swaps the new files into the web root. Packages are reinstalled (npm ci) only whenpackage-lock.jsonchanged. - Prunes unused Docker images and prints
==> Done.
A commit that changes neither folder (documentation, for example) prints "No app changes (use --all to rebuild everything)".
Note: The web app's service worker caches the app in browsers. A new build replaces that cache, so tills pick up the new version on their next load.
Run a deploy by hand
On the server:
sudo /opt/infotech-pos/app/deploy/deploy.sh # same as a push: only what changed
sudo /opt/infotech-pos/app/deploy/deploy.sh --all # rebuild the API and the web app
Use --all after editing /opt/infotech-pos/shared/.env. A change to VITE_API_URL only reaches the browser after the web app is rebuilt, and the API reads its settings when its container starts.
To deploy another branch by hand, set DEPLOY_BRANCH:
sudo DEPLOY_BRANCH=staging /opt/infotech-pos/app/deploy/deploy.sh --all
When a deploy fails
The run turns red under Actions, and its log shows the script's output.
| Message | Meaning and fix |
|---|---|
API did not become healthy | The new API did not start within about 3 minutes. The log shows its last 80 lines: often a failed migration or a missing setting. Fix and push again, or check with docker compose -p infotech-pos -f /opt/infotech-pos/app/deploy/compose.prod.yaml logs api |
Another deploy is still running | Two pushes overlapped and the first took more than 15 minutes. Run the deploy again when it has finished |
Permission denied (publickey) | VPS_SSH_KEY is incomplete (missing BEGIN/END lines) or the line in authorized_keys was removed |
| npm or Vite errors | The web app did not build. The previous version stays online, because the files are only swapped after a successful build |
Related pages
Last updated 17 Oct 2026