Sunday, October 12, 2025 · Updated September 24, 2026

Deploy to a VPS with GitHub Actions and QuickDeploy

deploy your web app using GitHub Actions (CI/CD) and QuickDeploy

Repeat this deployment from your terminal

QuickDeploy builds your app, runs it in Docker, and sets up HTTPS on your own Ubuntu VPS.

€15 once, with updates included. VPS and domain costs are separate.

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:

SecretValue
SERVER_IPThe server IP used for the SSH connection
SERVER_USERA deployment user able to run Docker without interactive sudo
SSH_PRIVATE_KEYA dedicated deployment private key
SSH_KNOWN_HOSTSThe complete known-hosts line(s) for that exact IP, verified through a trusted channel
QUICKDEPLOY_LICENSEYour QuickDeploy license key
APP_ENV_PRODUCTIONThe 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.

Make your next deployment repeatable

Keep your VPS and use QuickDeploy to upload and build your app, configure its container, and set up HTTPS. After configuration, deploy updates with quickdeploy push.

Bring an Ubuntu VPS with Docker, Compose, and SSH access. You manage the server, backups, and application health.

One-time license · Updates included · 14-day money-back guarantee. Hosting and domain fees are separate.

Simple pricing

Pay once. Keep shipping.

QuickDeploy license

A lifetime license for the QuickDeploy CLI, with updates, fixes, and improvements included.

Use your own VPS and domain. Hosting and domain fees are paid separately to your providers.

Have an Ubuntu VPS with Docker, Compose, and SSH access? Check compatibility before buying.

After purchase, install the CLI and run quickdeploy configure with your purchase email or license key, then deploy from your project directory. View installation instructions.

You can download the CLI and check its version before buying. Configuration and deployment require a license.

What's included

  • One-time payment, no QuickDeploy subscription
  • CLI for macOS, Linux, and Windows
  • Updates and improvements included
  • Deploy to your own server

One-time payment

€15EUR

For your own Ubuntu VPS with Docker and SSH. Hosting and domain fees are separate.

Buy QuickDeploy — €15

Opens Stripe checkout in a new tab.

14-day money-back guarantee.
No questions asked.

Built by Maximilian Kürschner. Unsure whether your app fits? Ask about your setup with your framework, server OS, and database.

Deploy to a VPS with GitHub Actions and QuickDeploy