← All posts

9 min read

How to deploy code from GitHub to server

Key takeaways

  • Automate deploys with a GitHub Actions workflow that connects over SSH, rsyncs your code, restarts the process, and health-checks the result.
  • Store the deploy user's private key, server host, and ssh-keyscan output as GitHub secrets, and never disable host key checking.
  • Use a timestamped releases directory with a current symlink so rollback is a one-line ln -sfn or a workflow_dispatch redeploy of a previous tag.
  • A health check after restart is essential: without it, Actions reports success even if your app crashed seconds later.
  • A $5 VPS plus Actions is cheapest in dollars, but a push-to-deploy PaaS buys back maintenance time and, on Omeganodes, accepts MTN or Airtel mobile money instead of a USD card.

To deploy code from GitHub to a server, push your app over SSH from a GitHub Actions workflow: generate a key pair, store the private key as a repo secret, pin the host's fingerprint in known_hosts, and rsync or git pull onto the server, then restart the process and health-check it. This guide gives you one complete, copy-paste workflow file for that, plus a rollback step.

How do you deploy code from GitHub to a server?

There are two main paths. The manual one: you SSH in, run git pull, restart your process, and repeat every time you push. The automated one: a GitHub Actions workflow connects to your server over SSH and runs those same commands for you on every push to main.

This article builds the automated path step by step: server setup, storing credentials as encrypted secrets in GitHub Actions, a complete workflow file, rollback, and the real cost versus a managed PaaS. If you would rather skip the pipeline entirely, push-to-deploy hosting like Omeganodes or Render handles the same job. We also cover a git push deploy setup in more detail in how to deploy a Node.js app with a single git push.

What do you set up on the server before the first deploy?

Create a deploy user and SSH keys

Log into your VPS as root and create a non-root user for deploys: adduser deploy, then usermod -aG sudo deploy. Running your app as root means one compromised workflow file owns your whole server, so this step is worth the two minutes.

On your machine, generate a key pair with ssh-keygen -t ed25519 -f deploy_key. Put the public key in /home/deploy/.ssh/authorized_keys on the server and add the private key to your GitHub repository secrets later. Set PasswordAuthentication no in /etc/ssh/sshd_config and restart sshd.

Directory layout and a process manager

Keep it boring: /home/deploy/app for releases, /home/deploy/app/releases/<timestamp> for each deploy, and a current symlink pointing at the latest. That symlink is what makes rollback a one-line ln -sfn later.

Install a process manager so your app survives reboots and crashes. systemd works out of the box on Ubuntu: write a small myapp.service file with ExecStart pointing at current, then systemctl enable --now myapp. If you would rather skip all of this, a managed PaaS like Omeganodes handles users, HTTPS, and process supervision for you, and you can pay with MTN or Airtel mobile money instead of a USD card.

How do you store server credentials as GitHub secrets?

Add three repository secrets: SSH_PRIVATE_KEY (the private key you generated for the deploy user), HOST (your server's IP or hostname), and USER (the deploy user, ideally not root). GitHub encrypts secrets at rest and masks their values in workflow logs, so echo $SSH_PRIVATE_KEY prints *** instead of the key. You can trigger a deploy manually with workflow_dispatch while you test the setup, per GitHub's documentation for the checkout action.

Capturing the server fingerprint with ssh-keyscan

Never disable host key checking to make SSH "just work" — that opens you to man-in-the-middle attacks. Instead, run ssh-keyscan your-server-ip from your own machine, verify the fingerprint matches what you expect, and store the full output in a KNOWN_HOSTS secret. Your workflow writes it to ~/.ssh/known_hosts before connecting, so SSH verifies the server on every deploy without prompting. One honest caveat: secrets are only as safe as the workflows that use them. Anyone with write access to the repo can exfiltrate them through a modified workflow file, so restrict who can push to your deploy branch. If maintaining this pipeline sounds like more side-project overhead than you want, a managed push-to-deploy PaaS such as Omeganodes handles keys and host verification for you — see the deploy overview for what that looks like in practice.

What does a complete GitHub Actions deploy workflow look like?

The workflow file, line by line

Everything from the previous sections comes together in one file at .github/workflows/deploy.yml. It checks out your code, loads the SSH key and known_hosts from your secrets, syncs the files to the server, builds there, restarts the service, and verifies the app is actually serving traffic. If any step fails, the workflow stops and your old release keeps running.

name: Deploy
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up SSH
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.SSH_KEY }}" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519
          echo "${{ secrets.KNOWN_HOSTS }}" > ~/.ssh/known_hosts

      - name: Sync code
        run: rsync -az --delete --exclude .git ./ deployer@${{ secrets.HOST }}:/srv/app/current/

      - name: Build and restart
        run: |
          ssh deployer@${{ secrets.HOST }} << 'EOF'
            cd /srv/app/current
            npm ci --omit=dev
            pm2 restart app || pm2 start app.js --name app
          EOF

      - name: Health check
        run: |
          for i in 1 2 3 4 5; do
            curl -fsS https://yourdomain.com/health && exit 0
            sleep 10
          done
          exit 1

The two details people get wrong are the ones covered earlier. First, pin the host key: the KNOWN_HOSTS secret you built from ssh-keyscan is what stops the SSH step from hanging on an interactive fingerprint prompt, which is the single most common reason a first deploy silently times out. Second, the health check is your safety net. Without it, GitHub Actions reports success the moment pm2 restart returns, even if your app crashed two seconds later. With it, a broken build shows as a red X on the commit, and you know to fix forward or roll back before anyone else notices.

This is the entire maintenance surface you are signing up for: one YAML file, one server user, and the discipline to keep the health check honest. If that feels like the part of the job you would rather skip, push-to-deploy platforms do this same loop for you. That is the trade-off we weigh honestly in the cost comparison later, and it is why we built Omeganodes to run exactly this flow, git push to deploy with automatic HTTPS and no YAML of your own (introducing Omeganodes).

How do you roll back a bad deploy from GitHub?

Add a second job in the same workflow file, triggered by workflow_dispatch, that checks out the previous git tag and reruns the deploy steps against it. Tag every release (git tag v1.4 && git push --tags), then the rollback job takes a tag input, defaults to the last-but-one tag, and pushes that build to the server the same way your normal deploy does. Because both paths run the identical deploy script, a rollback is just a redeploy of a known-good commit.

Rollback workflow with workflow_dispatch

on:
  workflow_dispatch:
    inputs:
      tag:
        description: "Tag to redeploy"
        required: true

Gate promotion with a health check before the rollback matters: after each deploy, curl your /healthz endpoint and fail the job on a non-200. If you promote a broken build to production, the fastest fix is rerunning the workflow with the previous tag. If you use StrictHostKeyChecking yes with pre-populated known_hosts from ssh-keyscan, a rollback hits the same trusted host, so nothing new is accepted mid-incident. If maintaining this pipeline sounds like more than a side project deserves, a managed push-to-deploy PaaS like Omeganodes keeps rollback to a redeploy button, and its terms cover the same EU servers your Actions workflow would target.

How much does self-managed deploy cost versus a PaaS?

A $5 VPS plus GitHub Actions is the cheapest path. Your costs are the server, your time maintaining the workflow, and the hours you lose when a deploy fails at 11pm and nobody is on call but you. The pipeline you built above is also single-server: no zero-downtime deploys, no automatic health checks, no built-in rollback beyond reverting and re-running.

When the DIY pipeline is worth it

You want full control, you already own the server, and your side project can tolerate 30 seconds of downtime during a deploy. Copying files with rsync and restarting a service is simple to reason about and costs almost nothing.

When a push-to-deploy PaaS wins

You charge for your app, you sleep, or you need zero-downtime releases. Platforms take the pipeline, HTTPS renewal, and on-call burden off you. Running everyone through the same criteria:

PlatformPush-to-deployPayment without USD cardYou own on-call
DIY VPS + ActionsYes (you build it)VPS often needs a cardYes, fully
RenderYesCard requiredNo
RailwayYesCard requiredNo
Fly.ioYesCard requiredNo
OmeganodesYesMobile money (MTN, Airtel)Shared, but fewer knobs than DIY

Omeganodes trades DIY flexibility for less control; if you need custom server software outside its runtime list, the VPS route still wins.

How do you pay for hosting from Kenya or Uganda without a USD card?

Most Heroku-style platforms bill in USD through a card most Kenyan and Ugandan banks either don't issue or make expensive to maintain. That single billing detail blocks more solo developers than pricing does: you can afford $7 a month, but you can't complete the checkout.

You have three practical routes. First, virtual USD cards from local fintechs, which work but add fees and can fail mid-subscription. Second, a friend or partner abroad with a card, which works until it doesn't. Third, pick a host that bills in local currency through mobile money.

Omeganodes takes the third route: you pay with MTN Mobile Money or Airtel Money, at regional prices rather than converted US rates, so a side project deploy from GitHub to an EU server with automatic HTTPS costs what it should locally. The trade-off is honest: a smaller platform means fewer regions, fewer add-ons, and a smaller community than Render or Railway. If you outgrow it, the GitHub Actions workflow from earlier sections ports over unchanged.

FAQ

Can I deploy to a server automatically every time I push to main?

Yes. A GitHub Actions workflow triggered on push to main connects to your server over SSH, rsyncs the code, restarts the process, and runs a health check. Set it up once with your deploy user, secrets, and pinned host key, and every push to main deploys without you touching the server.

How do I fix an SSH host key verification failure in GitHub Actions?

Never disable host key checking. Run ssh-keyscan your-server-ip from your own machine, verify the fingerprint, and store the output in a KNOWN_HOSTS secret. Your workflow writes it to ~/.ssh/known_hosts before connecting, so SSH verifies the server silently. A hanging first deploy is usually a missing known_hosts entry, not a broken key.

Do GitHub Actions minutes cost money for private repos, and how many does a deploy use?

Private repos get a free monthly minutes allowance; public repos are free. A single deploy workflow (checkout, SSH setup, rsync, build, restart, health check) typically uses a small fraction of that allowance, so a side project deploying a few times a day stays well within the free tier on most plans.

Can I use mobile money like MTN or Airtel to pay for hosting instead of a card?

Yes, but only hosts that bill locally. Most Heroku-style platforms require a USD card, which many Kenyan and Ugandan banks make expensive or impossible to maintain. Omeganodes accepts MTN Mobile Money and Airtel Money at regional prices, so you can pay for a server with automatic HTTPS without a card or virtual card workarounds.

Is a PaaS like Omeganodes cheaper than running my own VPS for a side project?

A $5 VPS plus GitHub Actions is the cheapest option in raw dollars, but it costs your time: maintaining the workflow, handling failed deploys at night, and living without zero-downtime releases or one-click rollback. A PaaS costs more per month and buys back that time, which matters once your app charges users or you need sleep.

Deploy at the speed of Africa

World-class hosting, open to every developer, wherever you build and however you pay. Start free in a couple of minutes — no credit card, no lock-in.