Deploy an app to your VPS when a change reaches main, and fail the workflow if the expected release does not answer over HTTPS. This example waits for QuickDeploy, verifies the SSH host, and prevents two production deployments in this repository from running at the same time.
Verify one deployment first
Use the Next.js or SvelteKit sample, or an application that listens on port 3000 and serves a public /api/health response with ok: true and release equal to its runtime RELEASE_ID. The samples provide that endpoint. For your own app, implement an equivalent check before copying this workflow.
Complete a manual QuickDeploy deployment to the intended test server first. Check its runtime, domain and HTTPS. QuickDeploy uses Nixpacks; a successful local Docker build alone does not establish that the source deployment works. For database apps, also complete the PostgreSQL migration and persistence checks.
Configure secrets and variables
Create a GitHub environment named production. Add these environment secrets:
| Secret | Value |
|---|---|
SERVER_IP | The server IP used for the SSH connection |
SERVER_USER | A deployment user able to run Docker without interactive sudo |
SSH_PRIVATE_KEY | A dedicated deployment private key |
SSH_KNOWN_HOSTS | The complete known-hosts line(s) for that exact IP, verified through a trusted channel |
QUICKDEPLOY_LICENSE | Your QuickDeploy license key |
APP_ENV_PRODUCTION | The app's production environment-file contents, including SvelteKit ORIGIN or a database URL where needed |
Verify the server's SSH fingerprint through your provider console or an already trusted session. You can collect a candidate key with ssh-keyscan, but scanning alone does not establish its authenticity. Do not bypass host checking to make a failing connection pass.
Add environment variables DEPLOY_DOMAIN, DEPLOY_PROJECT and QUICKDEPLOY_SHA256. Use the same project and domain as the working manual deployment. QUICKDEPLOY_SHA256 is the SHA-256 of the Linux AMD64 CLI binary you have reviewed and tested; for example, compute sha256sum quickdeploy on that downloaded binary. The download endpoint serves the current release. If it changes, this workflow intentionally fails its checksum check until you review that release and update the checksum.
Set APP_DIRECTORY below to the app's path within the repository, such as . or apps/web. Port 3000 matches the downloadable samples; change it if your application listens elsewhere. Do not put secrets in quickdeploy.json or commit .env.production.
Create the workflow
Save this as .github/workflows/deploy.yml. Add your application's existing lint, test and build checks before the deployment steps. The example does not invent a test command for an arbitrary app.
name: Deploy to VPS
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: production-vps
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 30
environment: production
env:
APP_DIRECTORY: .
DEPLOY_DOMAIN: ${{ vars.DEPLOY_DOMAIN }}
DEPLOY_PROJECT: ${{ vars.DEPLOY_PROJECT }}
QUICKDEPLOY_IP: ${{ secrets.SERVER_IP }}
QUICKDEPLOY_USER: ${{ secrets.SERVER_USER }}
QUICKDEPLOY_LICENSE_KEY: ${{ secrets.QUICKDEPLOY_LICENSE }}
steps:
- uses: actions/checkout@v4
# Run your application's tests here before deploying.
- name: Configure verified SSH access
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
run: |
set -euo pipefail
install -d -m 700 ~/.ssh
printf '%s\n' "$SSH_PRIVATE_KEY" > "$RUNNER_TEMP/deploy-key"
chmod 600 "$RUNNER_TEMP/deploy-key"
printf '%s\n' "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
chmod 600 ~/.ssh/known_hosts
ssh-keygen -F "$QUICKDEPLOY_IP" -f ~/.ssh/known_hosts > /dev/null
cat > ~/.ssh/config <<EOF
Host *
BatchMode yes
StrictHostKeyChecking yes
IdentitiesOnly yes
IdentityFile $RUNNER_TEMP/deploy-key
UserKnownHostsFile ~/.ssh/known_hosts
EOF
chmod 600 ~/.ssh/config
echo "QUICKDEPLOY_SSH_KEY_PATH=$RUNNER_TEMP/deploy-key" >> "$GITHUB_ENV"
ssh -o ConnectTimeout=10 "$QUICKDEPLOY_USER@$QUICKDEPLOY_IP" 'docker info >/dev/null && docker compose version'
- name: Download and verify QuickDeploy
env:
QUICKDEPLOY_SHA256: ${{ vars.QUICKDEPLOY_SHA256 }}
run: |
set -euo pipefail
: "${QUICKDEPLOY_SHA256:?Set the checksum of the CLI release you reviewed}"
curl --fail --show-error --location https://quickdeploy.dev/api/download/linux-amd64 -o "$RUNNER_TEMP/quickdeploy"
printf '%s %s\n' "$QUICKDEPLOY_SHA256" "$RUNNER_TEMP/quickdeploy" | sha256sum --check
chmod 700 "$RUNNER_TEMP/quickdeploy"
"$RUNNER_TEMP/quickdeploy" --version
- name: Create runtime configuration
working-directory: ${{ env.APP_DIRECTORY }}
env:
APP_ENV_PRODUCTION: ${{ secrets.APP_ENV_PRODUCTION }}
run: |
set -euo pipefail
umask 077
printf '%s\n' "$APP_ENV_PRODUCTION" > .env.production
printf '\nRELEASE_ID=%s\n' "$GITHUB_SHA" >> .env.production
- name: Deploy and wait for the result
working-directory: ${{ env.APP_DIRECTORY }}
run: |
set -euo pipefail
: "${DEPLOY_DOMAIN:?Set DEPLOY_DOMAIN}"
: "${DEPLOY_PROJECT:?Set DEPLOY_PROJECT}"
"$RUNNER_TEMP/quickdeploy" push --domain "$DEPLOY_DOMAIN" --project "$DEPLOY_PROJECT" --port 3000
- name: Verify the new release over HTTPS
run: |
set -euo pipefail
for attempt in $(seq 1 12); do
if curl --fail --silent --show-error --connect-timeout 10 --max-time 20 \
"https://$DEPLOY_DOMAIN/api/health" -o "$RUNNER_TEMP/health.json"; then
if python3 -c 'import json,os; d=json.load(open(os.environ["RUNNER_TEMP"]+"/health.json")); assert d.get("ok") is True and d.get("release")==os.environ["GITHUB_SHA"]'; then
exit 0
fi
fi
sleep 5
done
echo 'The expected release did not pass its public health check.' >&2
exit 1
- name: Remove temporary credentials
if: always()
run: |
rm -f "$RUNNER_TEMP/deploy-key" "$RUNNER_TEMP/quickdeploy" "$RUNNER_TEMP/health.json"
rm -f "$APP_DIRECTORY/.env.production"
The workflow file is also included in the deployment workflow download. GitHub expressions are passed through step environment variables before the shell uses them, so secret values are not pasted directly into shell source.
Why these checks matter
cancel-in-progress: false lets an active deployment finish rather than interrupting a release midway. The concurrency group only serializes this repository's workflows; coordinate separately if several repositories deploy to the same project. GitHub concurrency documentation.
The push command deliberately omits --detach. An attached QuickDeploy command propagates a server-side failure; detached mode only confirms that the background command started. QuickDeploy's own readiness check looks for a running container, so this workflow also checks the public application.
An HTTP 200 alone can come from an older healthy release. Comparing release with GITHUB_SHA verifies that the expected configuration reached the responding app. Keep that endpoint small, do not include environment dumps or credentials, and use Cache-Control: no-store as in the samples.
Diagnose a failed release
- Host key check fails: verify the host identity and update the trusted key only after confirming a legitimate change.
- Download checksum fails: inspect the new release; do not silently remove the checksum step.
- QuickDeploy fails: inspect the build/start logs and confirm that the server user can reach Docker. The workflow stops before declaring success.
- The health check fails or shows an old release: inspect the deployed service, runtime configuration, proxy upstream and application logs. Restore the previous known working source and configuration if appropriate, then rerun and verify.
App recovery does not reverse a database migration. Use compatible, staged migrations, keep backups, and plan schema recovery separately. Do not make automatic destructive rollbacks part of this workflow.