Deploying from Git
This article shows how to update an app on your ForestsNet Cloud 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 ForestsNet Cloud 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.
ℹ️ 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
- 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.
ℹ️ Tip: on GitLab the equivalent setting is called Deploy Keys, under Settings → Repository → Deploy keys for the project.
- 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ℹ️ 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.
- 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 [email protected]: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 --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:
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.
⚠️ 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:
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.
⚠️ 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:
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ℹ️ 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.
- 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
}⚠️ 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.
- 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).
ℹ️ 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.
- 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ℹ️ 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.
- 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.
⚠️ 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
- 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 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
- 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.