Skip to content
Infotech

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 runs deploy.sh and nothing else.
  • restrict turns 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:

NameValue
VPS_HOSTThe server's public IP address
VPS_USERroot
VPS_SSH_KEYThe 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):

  1. Locks. Only one deploy runs at a time. A second one waits up to 15 minutes, then fails with "Another deploy is still running".
  2. Loads settings from /opt/infotech-pos/shared/.env.
  3. Pulls. git fetch of main (or the branch in DEPLOY_BRANCH), then git reset --hard origin/main. Any edits made directly in /opt/infotech-pos/app are discarded; keep settings in the shared .env.
  4. Finds what changed between the previous and the new commit with git diff --name-only.
  5. API, if spring-admin/ or deploy/compose.prod.yaml changed: docker compose … up -d --build api, then waits for /actuator/health to report UP. Database migrations run during this start.
  6. Web app, if infotech-admin-web/ changed: builds it in a node:22-alpine container with VITE_API_URL from .env and swaps the new files into the web root. Packages are reinstalled (npm ci) only when package-lock.json changed.
  7. 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.

MessageMeaning and fix
API did not become healthyThe 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 runningTwo 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 errorsThe web app did not build. The previous version stays online, because the files are only swapped after a successful build

Last updated 17 Oct 2026