To deploy a Docker Compose AI app from GitHub Actions, you need four moving parts: build and push the image, SSH into the server, run docker compose pull and up, then check health and roll back if it fails. This post gives you a workflow file and a server script for that, with secrets kept out of the repo and the server login short-lived.
Disclosure: the Hostinger link here is an affiliate link. What I ran on 2026-10-23: the server-side deploy.sh (with a fake docker command and a fake health endpoint, so the logic and rollback path are real but Docker was not) and actionlint on the workflow file. What I did not run: the workflow on GitHub, a real GHCR push, or SSH to any server, including a Hostinger one.
What does the pipeline look like?
Push to main triggers two jobs. build logs in to GitHub Container Registry, builds the image and pushes it tagged sha-<first 7 chars of the commit> plus latest. deploy waits for build, SSHes to your VPS and runs /opt/aiapp/deploy.sh sha-xxxxxxx. The script pulls that tag, brings the stack up, curls the health endpoint and either records the tag as current or restores the previous one.
This is deliberately the boring version. If you need no downtime between versions, read zero-downtime deploys with Docker Compose after this and swap the script's up for its rolling logic. If you are deploying a FastAPI model service, this guide covers the Dockerfile and proxy side.
What do you set up on the server first?
You need a server with Docker and a project directory. Hostinger KVM 2 (2 vCPU, 8 GB, 100 GB, $8.79 on a term and $14.99 on renewal as of 2026-10-23) is a fair fit for a small AI app that calls hosted models, and Hetzner or DigitalOcean are non-affiliate options. I have not tested a Hostinger server for this. First harden it with the VPS hardening checklist.
Then:
- Create a deploy user and add it to the
dockergroup. Be aware that thedockergroup is effectively root on the box. That is why the key below is single-purpose and you should restrict it. - Create
/opt/aiapp/and put yourdocker-compose.yml,.env(app secrets such as model API keys, never committed) anddeploy.shthere. - Make the compose file take the image tag from the environment:
services:
app:
image: ghcr.io/your-user/your-repo:${IMAGE_TAG:?set IMAGE_TAG}
env_file: .env
ports:
- "127.0.0.1:8000:8000"
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8000/health"]
interval: 10s
timeout: 3s
retries: 5
start_period: 40s
restart: unless-stopped
The ${IMAGE_TAG:?...} syntax stops Compose when the variable is missing, so a manual docker compose up without a tag fails loudly instead of deploying a wrong image. The container needs curl for that healthcheck. If your image does not have it, use a Python one-liner instead. Put a reverse proxy such as Caddy in front for HTTPS.
How do you create the SSH key and host key secrets?
On your own machine, make a key used only for this:
ssh-keygen -t ed25519 -f deploy_key -N "" -C "github-actions-deploy"
Append deploy_key.pub to the deploy user's ~/.ssh/authorized_keys on the VPS. Then capture the server's host key from your own machine, and compare the fingerprint with the one your provider's console or an earlier SSH session showed:
ssh-keyscan -t ed25519 your.server.ip
In your GitHub repo, create an environment named production and add these as environment secrets: VPS_SSH_KEY (contents of deploy_key), VPS_KNOWN_HOSTS (the ssh-keyscan line), VPS_HOST and VPS_USER. Delete deploy_key from your laptop when done. Pinning the host key and using StrictHostKeyChecking=yes means a hijacked or redirected IP cannot receive your deploy.
GitHub's secrets guidance (read 2026-10-23) matches this: secrets are not passed to workflows triggered from forks (except GITHUB_TOKEN), environment secrets can be tied to specific deployment environments so they are only available where needed, and secrets should stay off the command line, passed through environment variables or STDIN. The workflow below does that.
What is the workflow file?
Save as .github/workflows/deploy.yml. The action versions are the major tags shown in each project's README on 2026-10-23: actions/checkout@v7, docker/login-action@v4, docker/setup-buildx-action@v4 and docker/build-push-action@v7. Docs pin only major versions, so check each project's releases page before you copy.
name: deploy
on:
push:
branches: [main]
concurrency:
group: deploy-production
cancel-in-progress: false
permissions:
contents: read
env:
IMAGE: ghcr.io/${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
tag: ${{ steps.vars.outputs.tag }}
steps:
- uses: actions/checkout@v7
- id: vars
run: echo "tag=sha-${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/setup-buildx-action@v4
- uses: docker/build-push-action@v7
with:
context: .
push: true
tags: |
${{ env.IMAGE }}:${{ steps.vars.outputs.tag }}
${{ env.IMAGE }}:latest
deploy:
needs: build
runs-on: ubuntu-latest
environment: production
steps:
- name: Install SSH key and pin host key
env:
SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
KNOWN_HOSTS: ${{ secrets.VPS_KNOWN_HOSTS }}
run: |
install -m 700 -d ~/.ssh
printf '%s\n' "$SSH_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts
- name: Deploy over SSH
env:
HOST: ${{ secrets.VPS_HOST }}
DEPLOY_USER: ${{ secrets.VPS_USER }}
TAG: ${{ needs.build.outputs.tag }}
GHCR_USER: ${{ github.actor }}
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
printf '%s' "$GHCR_TOKEN" | ssh -i ~/.ssh/deploy_key \
-o StrictHostKeyChecking=yes \
"$DEPLOY_USER@$HOST" \
"docker login ghcr.io -u '$GHCR_USER' --password-stdin && /opt/aiapp/deploy.sh '$TAG'"
Choices worth knowing about:
- Secrets go through
env:, not inline${{ }}in the script. That keeps values out of the shell command text. concurrencywithcancel-in-progress: falsequeues deploys instead of killing one halfway.- The server logs in with the job's
GITHUB_TOKEN, piped over SSH stdin, so no personal access token lives on the VPS. The GHCR login docs showGITHUB_TOKENas the way to authenticate, and the packages guide says the workflow needspackages: write. That the same token can then read the package from the server is my expectation, not something I verified. - Image names must be lowercase. If your GitHub username has capitals, hard-code a lowercase image name instead of
github.repository. actionlintpassed on this file (via theactionlint-pypackage, no output means no findings). That is a syntax and expression check, not a run.
What does deploy.sh do, and does the rollback work?
Save as /opt/aiapp/deploy.sh on the server and chmod +x it.
#!/usr/bin/env bash
set -euo pipefail
cd "${APP_DIR:-/opt/aiapp}"
NEW_TAG="${1:?usage: deploy.sh <image-tag>}"
HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:8000/health}"
PREV_TAG="$(cat .current_tag 2>/dev/null || true)"
echo "deploying $NEW_TAG (previous: ${PREV_TAG:-none})"
export IMAGE_TAG="$NEW_TAG"
docker compose pull
docker compose up -d --wait --wait-timeout 120 || true
if curl -fsS --retry 10 --retry-delay 3 --retry-connrefused "$HEALTH_URL" > /dev/null; then
echo "$NEW_TAG" > .current_tag
docker image prune -f > /dev/null
echo "deploy ok: $NEW_TAG"
exit 0
fi
echo "health check failed for $NEW_TAG" >&2
if [ -n "$PREV_TAG" ]; then
echo "rolling back to $PREV_TAG" >&2
export IMAGE_TAG="$PREV_TAG"
docker compose up -d --wait --wait-timeout 120
echo "rolled back to $PREV_TAG" >&2
fi
exit 1
Docker's reference says --wait waits for services to be running or healthy and --wait-timeout sets the maximum seconds. The || true after up is intentional: if --wait times out, the script continues to its own curl check and the rollback branch instead of dying early. Note this means the curl check is the final judge.
I tested it on 2026-10-23 with a shell script named docker on the PATH that only printed its arguments and wrote the tag, and a small Python server on port 8000 that returns 503 when the deployed tag is sha-bad. Real output, trimmed of the repeated curl lines:
=== deploy 1: good
deploying sha-aaa1111 (previous: none)
docker compose pull (IMAGE_TAG=sha-aaa1111)
docker compose up -d --wait --wait-timeout 120 (IMAGE_TAG=sha-aaa1111)
deploy ok: sha-aaa1111
exit=0
=== deploy 2: bad
deploying sha-bad (previous: sha-aaa1111)
docker compose pull (IMAGE_TAG=sha-bad)
docker compose up -d --wait --wait-timeout 120 (IMAGE_TAG=sha-bad)
curl: (22) The requested URL returned error: 503
health check failed for sha-bad
rolling back to sha-aaa1111
docker compose up -d --wait --wait-timeout 120 (IMAGE_TAG=sha-aaa1111)
rolled back to sha-aaa1111
exit=1
.current_tag now: sha-aaa1111
The failing deploy exits 1, so the GitHub job goes red even though the old version is serving again. That is what you want. This proves the control flow only. It does not prove how real Compose behaves, for example whether the previous image is still on disk after image prune. The prune runs only after a successful deploy and keeps images in use, but check on your server before relying on it.
What can go wrong?
- Rollback cannot undo data changes. If the bad release ran a database migration, going back to the old image does not reverse it. Make migrations backward compatible, or take a backup first (restic backup guide).
- Slow model loading. If startup takes longer than the curl retries (about 30 seconds here), raise
--retryor fix the health endpoint to report ready only when the model is loaded. - Quoting. The remote command is one string. Tags and usernames here are safe characters; do not feed untrusted values into it.
latestdrift. The server pulls by the SHA tag from compose, solatestis only a convenience for humans.- Secrets in the image. Build args and
COPY . .can bake.envfiles into layers. Add a.dockerignore.
If your app calls a hosted model and you want to pay by UPI in INR, AICredits (affiliate link) exposes an OpenAI-compatible base URL, https://api.aicredits.in/v1, with a Rs 50 minimum top-up; check their site for terms. Paying the model vendor directly is the non-affiliate option. Either way the key belongs in the server's .env, not in GitHub.
What I did not verify
I did not run the workflow on GitHub, push to GHCR, SSH to a real server or use real Docker Compose. Verified by running: the deploy.sh control flow with shims and a lint pass on the workflow. Verified by reading docs on 2026-10-23: the major version tags in the README examples, the GHCR login pattern, the --wait flags and GitHub's secrets guidance.



