GitHub Actions Auto Deploy to VPS: The Workflow I Use for Every Backend

Frontends auto-deploy through Cloudflare Pages — push to GitHub, live in 90 seconds. Backends don't get that luxury. They run on a VPS, and updating them used to mean SSH in, pull the latest code, restart the service, and hope nothing broke in the process.

GitHub Actions fixes this. Push to main, an automated workflow SSHs into the server, pulls the code, and restarts the service. No manual steps. The deploy happens whether I'm at my desk or on my phone. I set this up once for my first project and now every backend uses the same pattern.

What GitHub Actions Does

GitHub Actions is a free automation system built into GitHub. It runs tasks when specific events happen in your repository — a push, a pull request, a scheduled time. Each task runs on a GitHub-provided virtual machine that exists for the duration of the task and then disappears.

For backend deployment, the task is simple: connect to your VPS via SSH, pull the latest code, restart the service. The whole thing runs in under 30 seconds.

Free tier limits: 2,000 minutes per month for private repos, unlimited for public repos. A deploy job takes about 20-30 seconds. At 10 deploys per day, you'd use roughly 150 minutes per month — well within the free tier.

The Complete Workflow File

This is the exact workflow I run for my x402 Protocol API backend. Create this file at .github/workflows/deploy.yml in your repo:

name: Deploy to Oracle VPS on: push: branches: [main] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Deploy via SSH uses: appleboy/ssh-action@v1 with: host: ${{ secrets.VPS_HOST }} username: ${{ secrets.VPS_USER }} key: ${{ secrets.VPS_SSH_KEY }} script: | cd /home/ubuntu/x402-protocol git pull origin main pip install -r requirements.txt --break-system-packages -q sudo systemctl restart x402-api

Four lines in the deploy script. Navigate to the project directory, pull latest code, install any new dependencies, restart the service. That's the entire deployment.

Setting Up GitHub Secrets

The workflow references three secrets: VPS_HOST, VPS_USER, and VPS_SSH_KEY. These live in GitHub's encrypted secret store, never exposed in logs or code.

To set them up: go to your repo on GitHub → Settings → Secrets and variables → Actions → New repository secret.

# Three secrets you need VPS_HOST → Your server's public IP (e.g., 140.238.xxx.xxx) VPS_USER → SSH username (usually "ubuntu" for Oracle Cloud) VPS_SSH_KEY → Your private SSH key (the entire contents of ~/.ssh/oracle_key)
Important: Paste the entire private key including the -----BEGIN OPENSSH PRIVATE KEY----- and -----END OPENSSH PRIVATE KEY----- lines. A common mistake is copying only the key body without the header and footer, which causes authentication to fail silently. I spent 20 minutes on this the first time because the error message just said "permission denied" without mentioning the key format.

Once the secrets are saved, GitHub encrypts them at rest. They're injected into the workflow at runtime and masked in logs. The API key security practices from an earlier guide apply here too — secrets in the platform, never in the code.

What Happens When You Push

After the workflow file is committed and the secrets are configured, the deploy flow is automatic:

# On your laptop git add . git commit -m "Fix kimchi premium calculation edge case" git push origin main # On GitHub (automatic, takes ~25 seconds) 1. GitHub detects push to main 2. Spins up a fresh Ubuntu VM 3. Checks out the repo 4. SSHs into your Oracle VPS using the stored key 5. Runs: git pull, pip install, systemctl restart 6. VM shuts down # On your VPS Service restarts with the new code. Total downtime: ~2 seconds (systemctl restart).

I push, look at the Actions tab on GitHub to confirm it passed (green check), and move on. If something fails, the Actions tab shows the error output from the SSH session. Usually it's a syntax error in the new code or a missing dependency — fixable in minutes.

Handling Multiple Services

My Oracle instance runs six services. Not all of them live in the same repo. For projects with separate repos, each repo has its own workflow file pointing to the same VPS but restarting a different systemd service:

# In x402-protocol repo → restarts x402-api sudo systemctl restart x402-api # In speedtap repo → restarts speedtap-app AND speedtap-bot sudo systemctl restart speedtap-app sudo systemctl restart speedtap-bot # In x402watch repo → restarts x402watch-api sudo systemctl restart x402watch-api

Same pattern, same VPS, different service names. Adding a new project to the auto-deploy pipeline takes about five minutes: copy the workflow file, update the directory path and service name, add the secrets if they're in a different GitHub account.

When the Deploy Fails

Deploys fail. It happens. The important thing is knowing quickly and being able to recover.

SSH connection refused. Usually means the VPS is down or the IP changed. Check if the instance is running in the Oracle dashboard. If the IP changed (rare with Oracle's free tier), update the VPS_HOST secret.

Git pull fails with merge conflict. This happens if someone (or something) edited files directly on the server. Fix: SSH in manually, resolve the conflict or reset to the remote state with git reset --hard origin/main, then re-run the workflow.

Service won't restart. Usually a code error — syntax mistake, missing import, wrong config. The systemctl status output appears in the Actions log. Read the error, fix locally, push again. The next push triggers a fresh deploy automatically.

Dependencies install but break something. A new version of a library isn't backward compatible. Pin your dependencies in requirements.txt with exact versions (fastapi==0.115.0 instead of fastapi>=0.100) to prevent surprise upgrades.

This vs Cloudflare Pages: Different Tools, Same Philosophy

Cloudflare Pages handles frontend deployment perfectly — push to main, site deploys to the edge globally, with preview URLs and instant rollback. But Pages can't run a Python backend, a Telegram bot, or a database.

GitHub Actions + SSH handles backend deployment on your own VPS. Less polished — no preview URLs, no one-click rollback, restart causes ~2 seconds of downtime. But it runs anything Linux can run.

I use both on every project. Frontend through Cloudflare Pages. Backend through GitHub Actions. The full stack costs $0 and deploys automatically end to end.

The combined result: I push code and walk away. Frontend or backend, the right pipeline picks it up. No SSH, no manual restarts, no crossed fingers. Deploys happen multiple times per day across projects because the friction is zero.


Related guides:

Disclaimer: This blog documents practical workflows based on personal experience. Nothing here is financial, legal, or professional advice.

Comments