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
rootor a user withsudo. - 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.
Step 1. Install Git and set up access to the repository
- Install Git if it isn't already there:
sudo apt install -y git # Ubuntu/Debian
sudo dnf install -y git # AlmaLinux/Rockygit --version- If the repository is public, you don't need a key — HTTPS access works right away; skip to Step 2.
- 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:
ssh-keygen -t ed25519 -C "deploy@myapp" -f ~/.ssh/id_ed25519_deploy -N ""
cat ~/.ssh/id_ed25519_deploy.pub- 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.
- Since the key isn't saved under its default name, tell Git which host should use it — add this to
~/.ssh/config:
nano ~/.ssh/configHost github.com-myapp
HostName github.com
User git
IdentityFile ~/.ssh/id_ed25519_deploy
IdentitiesOnly yes- Test the connection:
ssh -T github.com-myappYou 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).
- Create the app directory:
sudo mkdir -p /var/www/myapp
sudo chown "$USER":"$USER" /var/www/myapp- Clone the repository — over SSH (using the host alias from Step 1, for a private repository):
git clone git@github.com-myapp:owner/repo.git /var/www/myappor over HTTPS (for a public repository):
git clone https://github.com/owner/repo.git /var/www/myapp- Check it:
cd /var/www/myapp
git remote -v
git log -1 --onelineStep 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:
sudo nano /etc/systemd/system/myapp.service[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.targetReplace 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:
sudo systemctl daemon-reload
sudo systemctl enable --now myapp
sudo systemctl status myappLogs, via journalctl:
sudo journalctl -u myapp -fOption B: PM2 (for Node.js)
sudo npm install -g pm2
cd /var/www/myapp
pm2 start app.js --name myapp
pm2 save
pm2 startuppm2 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.
Step 4. Write the deploy script: git pull + restart
Create deploy.sh in the app's directory:
nano /var/www/myapp/deploy.shFor option A (systemd):
#!/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):
#!/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:
chmod +x /var/www/myapp/deploy.sh
/var/www/myapp/deploy.shThe --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.
sudo visudoAdd 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 myappStep 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.
- Download the binary (there isn't a
webhookpackage consistently available across distros, so installing it this way works the same on both Ubuntu/Debian and AlmaLinux/Rocky):
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- Define the hook in
/etc/webhook/hooks.json:
sudo mkdir -p /etc/webhook
sudo nano /etc/webhook/hooks.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.
- Run
webhookas a systemd service on localhost — a reverse proxy will expose it externally:
sudo nano /etc/systemd/system/webhook.service[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.targetsudo systemctl daemon-reload
sudo systemctl enable --now webhook
sudo systemctl status webhook- Publish
/hooks/deployexternally through a reverse proxy with HTTPS — for example, add this to your Caddyfile (see Caddy: automatic HTTPS):
example.com {
reverse_proxy /hooks/* 127.0.0.1:9000
reverse_proxy 127.0.0.1:3000
}- Add the webhook to the repository on GitHub: Settings → Webhooks → Add webhook. Fill in: Payload URL —
https://example.com/hooks/deploy, Content type —application/json, Secret — the same secret as inhooks.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).
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.
- 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):
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ./gh-deploy-key -N ""- Add the public key to
authorized_keysfor the user on the server that CI will log in as:
ssh-copy-id -i ./gh-deploy-key.pub deploy@your-server-ip- 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 (usually22);SSH_PRIVATE_KEY— the full contents of the private key filegh-deploy-key, including the-----BEGIN...-----and-----END...-----lines.
- Create the file
.github/workflows/deploy.ymlin the repository:
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.shCommit and push this file — starting with the next push to main, GitHub Actions will connect to the server itself and run deploy.sh.
Step 6. Check that the deploy works
- Make a small change to the project locally, commit it, and push to
main. - Depending on which automation option you set up: for a webhook — watch
sudo journalctl -u webhook -fon 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 thedeployjob finished successfully. - On the server, confirm the code updated and the process restarted:
cd /var/www/myapp && git log -1 --oneline
sudo systemctl status myapp # for option A
pm2 status # for option BWhat's next
- For a full walkthrough of installing Node.js, PM2, and Nginx, see Node.js apps with PM2 and Nginx.
- If packaging the app into a container is a better fit than systemd/PM2, see Docker and Docker Compose.
- HTTPS for the site and for the webhook itself — Caddy: automatic HTTPS.
- Only open the firewall ports you actually need reachable from outside — Setting up the UFW firewall.
- If you added an SSH key for GitHub Actions, give it its own restricted, non-
sudouser instead of using root.