MHOSTMHOST

Deploying from Git

This article shows how to update an app on your mHost VPS straight from Git: clone the repository once, and from then on every update is just a git pull followed by restarting the process via systemd or PM2. You can automate the whole flow so a push to the repository triggers the deploy by itself — either through a webhook on the server or through a GitHub Actions SSH step.

What you'll need

  • An mHost VPS on Linux: Ubuntu 22.04/24.04, Debian 11/12, or AlmaLinux/Rocky Linux 8/9.
  • SSH access — as root or a user with sudo.
  • An app that already knows how to run on this server — the build and first run depend on your stack and aren't covered here; see, for example, Node.js apps with PM2 and Nginx, Python and FastAPI: production deployment, or Django on a VPS. The focus here is updating the code from Git and automating that.
  • A code repository on GitHub, GitLab, or another Git host. The examples below use GitHub; GitLab and most other hosts have their own equivalents of deploy keys, webhooks, and CI secrets under similar names.
Tip: the commands below are given with sudo. If you're already connected as root and sudo isn't installed, run the same commands without the sudo prefix.
Tip: if you set up the base environment (say, LEMP or Django) using a ready-made VMmanager 6 recipe (see Ready-made VMmanager 6 recipes), this article covers the next step: getting your own code into that environment from Git, and updating it.

Step 1. Install Git and set up access to the repository

  1. Install Git if it isn't already there:
bash
sudo apt install -y git      # Ubuntu/Debian
sudo dnf install -y git      # AlmaLinux/Rocky
bash
git --version
  1. If the repository is public, you don't need a key — HTTPS access works right away; skip to Step 2.
  1. If the repository is private, create a dedicated SSH key on the server just for accessing it (a deploy key) — this way the server gets read access to only this one repository, not all of your repositories at once:
bash
ssh-keygen -t ed25519 -C "deploy@myapp" -f ~/.ssh/id_ed25519_deploy -N ""
cat ~/.ssh/id_ed25519_deploy.pub
  1. Copy the output of that last command and add it to the repository on GitHub: Settings → Deploy keys → Add deploy key. Paste the key into the Key field and leave Allow write access unchecked — read access is enough for deployment.
Tip: on GitLab the equivalent setting is called Deploy Keys, under Settings → Repository → Deploy keys for the project.
  1. Since the key isn't saved under its default name, tell Git which host should use it — add this to ~/.ssh/config:
bash
nano ~/.ssh/config
Host github.com-myapp
    HostName github.com
    User git
    IdentityFile ~/.ssh/id_ed25519_deploy
    IdentitiesOnly yes
Tip: if this is the only SSH key on the server and it's only ever needed for one repository, you can save it directly under the default name ~/.ssh/id_ed25519 instead — then you won't need to touch ~/.ssh/config at all.
  1. Test the connection:
bash
ssh -T github.com-myapp

You should get a response along the lines of Hi OWNER/REPO! You've successfully authenticated, but GitHub does not provide shell access. GitHub isn't supposed to provide shell access — that message itself confirms the key was accepted.

Step 2. Clone the repository onto the server

The examples below use the path /var/www/myapp — replace it with your own (for example, /opt/myapp, or a dedicated user's home directory if you deployed the app using another article in this series).

  1. Create the app directory:
bash
sudo mkdir -p /var/www/myapp
sudo chown "$USER":"$USER" /var/www/myapp
  1. Clone the repository — over SSH (using the host alias from Step 1, for a private repository):
bash
git clone git@github.com-myapp:owner/repo.git /var/www/myapp

or over HTTPS (for a public repository):

bash
git clone https://github.com/owner/repo.git /var/www/myapp
  1. Check it:
bash
cd /var/www/myapp
git remote -v
git log -1 --oneline
Important: this article assumes main as the default branch — replace it with master in every command below if your repository uses the old branch name.

Step 3. Run the app under systemd or PM2

The deploy script from Step 4 needs something to restart — meaning the app already needs to be running as a managed process: via systemd (works for any stack) or via PM2 (typically for Node.js). If you already followed a stack-specific article, this step is likely done already — skip to Step 4.

Option A: a systemd service

Create the unit file /etc/systemd/system/myapp.service:

bash
sudo nano /etc/systemd/system/myapp.service
ini
[Unit]
Description=myapp
After=network.target

[Service]
Type=simple
User=deploy
WorkingDirectory=/var/www/myapp
ExecStart=/usr/bin/node /var/www/myapp/app.js
Restart=on-failure
RestartSec=5
Environment=NODE_ENV=production

[Install]
WantedBy=multi-user.target

Replace ExecStart with the actual command that starts your app — for Python that might be /var/www/myapp/venv/bin/gunicorn -b 127.0.0.1:8000 wsgi:app, for a compiled binary just the path to it; Node.js in the example above is just an illustration. Replace User with the account your app should run as (for example, the same one you cloned the repository as in Step 2); if you drop the line, the service runs as root.

Enable and start the service:

bash
sudo systemctl daemon-reload
sudo systemctl enable --now myapp
sudo systemctl status myapp

Logs, via journalctl:

bash
sudo journalctl -u myapp -f

Option B: PM2 (for Node.js)

bash
sudo npm install -g pm2
cd /var/www/myapp
pm2 start app.js --name myapp
pm2 save
pm2 startup

pm2 startup prints another command for you to copy and run — it hooks PM2 into systemd so processes come back up after a server reboot too. For a full walkthrough of installing Node.js, PM2, and startup persistence, see Node.js apps with PM2 and Nginx.

Important: PM2 keeps a separate process list per system user. The pm2 restart myapp command in the deploy script (Step 4) will only find your process if it's run as the same user that ran pm2 start/pm2 save — keep that in mind when setting up automation in Step 5: the deploy must connect as that same user.

Step 4. Write the deploy script: git pull + restart

Create deploy.sh in the app's directory:

bash
nano /var/www/myapp/deploy.sh

For option A (systemd):

bash
#!/usr/bin/env bash
set -euo pipefail

APP_DIR="/var/www/myapp"
SERVICE_NAME="myapp"
BRANCH="main"

cd "$APP_DIR"
git pull --ff-only origin "$BRANCH"

# add a build/install step here if your stack needs one, e.g.:
# npm ci --omit=dev
# pip install -r requirements.txt

sudo systemctl restart "$SERVICE_NAME"
echo "Deployed $(git rev-parse --short HEAD) at $(date)"

For option B (PM2):

bash
#!/usr/bin/env bash
set -euo pipefail

APP_DIR="/var/www/myapp"
BRANCH="main"

cd "$APP_DIR"
git pull --ff-only origin "$BRANCH"

# add a build/install step here if needed, e.g.: npm ci --omit=dev

pm2 restart myapp
echo "Deployed $(git rev-parse --short HEAD) at $(date)"

Make the script executable and test it by hand:

bash
chmod +x /var/www/myapp/deploy.sh
/var/www/myapp/deploy.sh

The --ff-only flag stops the deploy if the branch can't be fast-forwarded — for example, if there are local edits on the server or the history has diverged. That's a deliberate constraint: better to see a clear error in the script's output than get a surprise merge commit in production.

Important: if the deploy will run automatically as something other than root (a webhook or GitHub Actions — Step 5), the sudo systemctl restart command inside the script will prompt for a password and hang with no interactive terminal to answer it. Let that user run exactly this command without a password:
bash
sudo visudo

Add a line (replace deploy with the actual user, and the path with the output of command -v systemctl if it differs):

deploy ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart myapp

Step 5. Automate it (optional): a webhook or a GitHub Actions SSH deploy

Both options solve the same problem — running deploy.sh on a push to the repository — just in different directions: a webhook listens for an incoming HTTP request on the server itself, while GitHub Actions connects out to the server over SSH. Setting up just one of them is enough.

Option A: a webhook on the server

On a push, GitHub (or GitLab) sends an HTTP POST to your server; a lightweight webhook receiver checks the request's signature and runs deploy.sh. The example below uses `webhook` (adnanh/webhook), a small Go daemon built exactly for this.

  1. Download the binary (there isn't a webhook package consistently available across distros, so installing it this way works the same on both Ubuntu/Debian and AlmaLinux/Rocky):
bash
curl -fsSL -o /tmp/webhook.tar.gz https://github.com/adnanh/webhook/releases/download/2.8.3/webhook-linux-amd64.tar.gz
tar -xzf /tmp/webhook.tar.gz -C /tmp
sudo install -m 0755 /tmp/webhook-linux-amd64/webhook /usr/local/bin/webhook
webhook -version
Verify: version 2.8.3 was current at the time of writing — check the adnanh/webhook releases page for the current version number and the archive name for your architecture.
  1. Define the hook in /etc/webhook/hooks.json:
bash
sudo mkdir -p /etc/webhook
sudo nano /etc/webhook/hooks.json
json
[
  {
    "id": "deploy",
    "execute-command": "/var/www/myapp/deploy.sh",
    "command-working-directory": "/var/www/myapp",
    "trigger-rule": {
      "and": [
        {
          "match": {
            "type": "payload-hmac-sha256",
            "secret": "CHANGE_ME_SECRET",
            "parameter": {
              "source": "header",
              "name": "X-Hub-Signature-256"
            }
          }
        },
        {
          "match": {
            "type": "value",
            "value": "refs/heads/main",
            "parameter": {
              "source": "payload",
              "name": "ref"
            }
          }
        }
      ]
    }
  }
]

Replace CHANGE_ME_SECRET with a long random string — you'll enter the same secret in the webhook settings on GitHub (Step 5 below). The first rule checks the request's signature from the X-Hub-Signature-256 header; the second only lets through pushes to the main branch — without them, anyone who learns the URL could trigger deploy.sh.

  1. Run webhook as a systemd service on localhost — a reverse proxy will expose it externally:
bash
sudo nano /etc/systemd/system/webhook.service
ini
[Unit]
Description=webhook
After=network.target

[Service]
Type=simple
ExecStart=/usr/local/bin/webhook -hooks /etc/webhook/hooks.json -ip 127.0.0.1 -port 9000 -verbose
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target
bash
sudo systemctl daemon-reload
sudo systemctl enable --now webhook
sudo systemctl status webhook
  1. Publish /hooks/deploy externally through a reverse proxy with HTTPS — for example, add this to your Caddyfile (see Caddy: automatic HTTPS):
caddyfile
example.com {
    reverse_proxy /hooks/* 127.0.0.1:9000
    reverse_proxy 127.0.0.1:3000
}
Important: you don't need to open port 9000 externally — it only listens on 127.0.0.1, and the only thing that reaches it from outside is the reverse proxy on the same server. Ports 80 and 443, though, do need to be open in the firewall — see Setting up the UFW firewall for details.
  1. Add the webhook to the repository on GitHub: Settings → Webhooks → Add webhook. Fill in: Payload URLhttps://example.com/hooks/deploy, Content typeapplication/json, Secret — the same secret as in hooks.json, and under event selection choose Just the push event. Save it.

GitHub immediately sends a test ping event — the Recent Deliveries tab for this webhook will show its result (it should be a 200 response).

Tip: on GitLab the equivalent setting is under Settings → Webhooks for the project; the secret is sent directly in the X-Gitlab-Token header (no HMAC involved), so the rule in hooks.json would use the value type instead of payload-hmac-sha256 in that case.

Option B: GitHub Actions — deploy over SSH

Here it works the other way around: instead of the server listening for an incoming request, GitHub itself connects to it over SSH and runs deploy.sh.

  1. Generate a separate SSH key pair for CI (don't reuse the key from Step 1 — that one grants access to the repository, while this one needs to grant access to the server):
bash
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ./gh-deploy-key -N ""
  1. Add the public key to authorized_keys for the user on the server that CI will log in as:
bash
ssh-copy-id -i ./gh-deploy-key.pub deploy@your-server-ip
Tip: if ssh-copy-id isn't available (for example, you generated the key somewhere other than Linux/macOS), add the contents of gh-deploy-key.pub to that user's ~/.ssh/authorized_keys on the server by hand.
  1. Add repository secrets: Settings → Secrets and variables → Actions → New repository secret. You'll need:
  • SSH_HOST — the server's IP address or domain;
  • SSH_USER — the user you added the key for (for example, deploy);
  • SSH_PORT — the SSH port (usually 22);
  • SSH_PRIVATE_KEY — the full contents of the private key file gh-deploy-key, including the -----BEGIN...----- and -----END...----- lines.
  1. Create the file .github/workflows/deploy.yml in the repository:
yaml
name: Deploy

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Deploy over SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SSH_HOST }}
          username: ${{ secrets.SSH_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          port: ${{ secrets.SSH_PORT }}
          script: /var/www/myapp/deploy.sh

Commit and push this file — starting with the next push to main, GitHub Actions will connect to the server itself and run deploy.sh.

Important: GitHub Actions has no fixed IP address — runners use dynamic addresses from a large range. If SSH on the server is restricted to specific IPs (a firewall — see Setting up the UFW firewall), that restriction won't work for this key: either open the SSH port more broadly, or use a self-hosted runner with a known IP.

Step 6. Check that the deploy works

  1. Make a small change to the project locally, commit it, and push to main.
  2. Depending on which automation option you set up: for a webhook — watch sudo journalctl -u webhook -f on the server, and the Recent Deliveries tab for the webhook on GitHub, a new successful (200) call should show up; for GitHub Actions — open the repository's Actions tab and confirm the deploy job finished successfully.
  3. On the server, confirm the code updated and the process restarted:
bash
cd /var/www/myapp && git log -1 --oneline
sudo systemctl status myapp      # for option A
pm2 status                       # for option B
Done: if git log shows your latest commit and the service or process shows a recent restart, deploying from Git is set up and working.

What's next