Webhooks — Production VM Setup, From Scratch
Target: example.com · Ubuntu 24.04 LTS · Nginx + PHP 8.4-FPM (no Apache/.htaccess anywhere in the stack) · systemd daemons · GitHub Actions over SSH, deploying via git pull + composer install directly on the box.
This reflects the current state of this repo — scripts/setup-vps.sh and .github/workflows/deploy.yml do almost everything below automatically. It assumes zero users on the VM, so this is the exact order of operations for a fresh box.
GitHub Actions secrets checklist
| Secret | Purpose |
|---|---|
SSH_HOST |
VM’s public IP or hostname |
SSH_PORT |
Usually 22, or a custom port if you hardened SSH |
SSH_USER |
Set to webhooks — GitHub Actions SSHes in directly as the app-owning user, no separate deploy account |
SSH_PRIVATE_KEY |
Private half of the CI → VM keypair (Part E) |
JOB_RUNNER_TOKEN |
Consumed by Src/lib/runner.php for HTTP-triggered worker runs — generate with openssl rand -hex 32 |
HEALTH_CHECK_URL |
Base URL used by the post-deploy Swagger check (e.g. https://example.com) — the workflow fails at that step if this is unset |
FTP_SERVER, FTP_USERNAME, FTP_PASSWORD, and INSTALLATION_ENDPOINT are leftovers from the old FTP-based deploy and referenced nowhere in the current workflow — delete them once you’re confident in the cutover.
Part A — Oracle Cloud: instance & network
- Instance: Compute → Instances. Reuse an existing Always-Free instance or create fresh: shape
VM.Standard.E2.1.Micro(Always Free x86) or a paid E4/E5 flex shape, image Canonical Ubuntu 24.04, attach your SSH public key at creation. - Reserve the public IP: Networking → IP Management → reserve as a Reserved Public IP so it survives stop/start and so the IP you whitelist on the DB host (Part C) doesn’t change later. Point your domain’s DNS A record at it now (low TTL, e.g. 300s).
- Open the cloud-level firewall (Security List / NSG — separate from anything configured inside the VM):
- Add stateful ingress: TCP/22 (ideally restricted to your own IP CIDR), TCP/80, TCP/443 from
0.0.0.0/0. - Leave egress open — outbound AMQPS (5671) to CloudAMQP, HTTPS to GitHub/OneSignal/HealthChecks.io, and the MariaDB port to your DB host.
- Add stateful ingress: TCP/22 (ideally restricted to your own IP CIDR), TCP/80, TCP/443 from
- Oracle’s default-iptables gotcha: the stock Ubuntu image ships
iptablesrules (vianetfilter-persistent) that allow only SSH + ICMP inbound, independently of the Security List and of UFW. Handle this in Part B before installing Nginx, or port 80/443 will be silently dropped even afterufw allow.
Part B — First login & OS hardening
ssh -i ~/.ssh/your_key ubuntu@<VM_PUBLIC_IP>
sudo apt-get update && sudo apt-get -y upgrade
The default ubuntu user is already sudo-enabled and key-only — use it directly for provisioning. No separate deploy user is needed: GitHub Actions connects as webhooks itself, set up in Part E.
SSH hardening
/etc/ssh/sshd_config.d/99-hardening.conf:
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
X11Forwarding no
MaxAuthTries 3
AllowUsers ubuntu webhooks
sudo systemctl restart ssh
Test the new connection in a second terminal before closing your current session.
Gotcha:
AllowUsersrejects a user before sshd even looks atauthorized_keys— so if this line ever lists a renamed or removed account, GitHub Actions getsPermission denied (publickey)even with a perfectly correct key installed, and it looks exactly like a bad-key problem. Confirm withsudo journalctl -u ssh -n 30— the real error readsUser webhooks ... not allowed because not listed in AllowUsers, not a pubkey rejection. Fix:sudo sed -i 's/AllowUsers .*/AllowUsers ubuntu webhooks/' /etc/ssh/sshd_config.d/99-hardening.conf && sudo sshd -t && sudo systemctl reload ssh.
UFW — resolve Oracle’s pre-installed iptables rules first
sudo apt-get install -y ufw
sudo systemctl disable --now netfilter-persistent 2>/dev/null || true
sudo iptables -F && sudo iptables -X
sudo ip6tables -F && sudo ip6tables -X
sudo iptables -P INPUT ACCEPT && sudo iptables -P FORWARD ACCEPT && sudo iptables -P OUTPUT ACCEPT
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw logging on
sudo ufw enable
Fail2ban
sudo apt-get install -y fail2ban
/etc/fail2ban/jail.local:
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
backend = systemd
[sshd]
enabled = true
[nginx-http-auth]
enabled = true
[nginx-botsearch]
enabled = true
sudo systemctl enable --now fail2ban
Housekeeping
sudo apt-get install -y unattended-upgrades
sudo dpkg-reconfigure --priority=low unattended-upgrades
sudo timedatectl set-timezone Etc/UTC
Part C — Reaching the MariaDB host by IP whitelist (no VPN)
- Firewall on the DB host: allow inbound TCP/3306 from this VM’s Reserved Public IP specifically.
- MySQL/MariaDB grants:
CREATE USER IF NOT EXISTS 'webhooks'@'<VM_PUBLIC_IP>' IDENTIFIED BY '<password>'; GRANT ALL PRIVILEGES ON <database>.* TO 'webhooks'@'<VM_PUBLIC_IP>'; FLUSH PRIVILEGES; - Confirm from the VM:
mysql -h <db-host> -P 3306 -u webhooks -p -e "SELECT 1" - Enforce TLS in transit (no VPN means plaintext otherwise):
ALTER USER 'webhooks'@'<VM_PUBLIC_IP>' REQUIRE SSL;on the DB host.
Src/secrets/mySql.secrets.php on this VM (written by CI, Part E) points $mySqlHost at the DB host’s real address, not 127.0.0.1.
Part D — PHP 8.4
sudo apt-get install -y software-properties-common
sudo add-apt-repository -y ppa:ondrej/php
sudo apt-get update
sudo apt-get install -y \
php8.4-cli php8.4-fpm php8.4-common \
php8.4-mbstring php8.4-curl php8.4-mysql php8.4-bcmath \
php8.4-sockets php8.4-xml php8.4-zip php8.4-opcache
php -v
php -m | grep -E 'pcntl|sockets' # confirm both are loaded
Both git and Composer are needed on this VM, not just in CI — setup-vps.sh installs them for you in the next step. deploy.yml runs git pull + composer install directly over SSH on every push; nothing is built in CI and shipped over.
Part E — Run setup-vps.sh (does almost everything)
This one script creates the webhooks user, both SSH keypairs’ plumbing, clones the repo, installs Composer dependencies, writes and enables the full Nginx site (HTTP-only for now), installs and enables all four systemd units, and installs a scoped sudoers policy — all idempotently. Because of the chicken-and-egg key registration below, you’ll run it twice.
scp scripts/setup-vps.sh ubuntu@<VM_PUBLIC_IP>:/tmp/setup-vps.sh
ssh ubuntu@<VM_PUBLIC_IP> 'sudo bash /tmp/setup-vps.sh'
First run creates the webhooks user and prints a new public key — this is the VM’s own outbound key so it can git pull the private repo. Add it read-only at your repo’s Settings → Deploy keys page.
It also creates an empty authorized_keys for the opposite direction (GitHub Actions connecting into this VM) and asks you to populate it with the public half of a separate keypair you generate yourself:
ssh-keygen -t ed25519 -C "github-actions-deploy" -f deploy_key -N ""
ssh ubuntu@<VM_PUBLIC_IP> "sudo tee -a /opt/webhooks/.ssh/authorized_keys" < deploy_key.pub
The matching private half (deploy_key) goes into the SSH_PRIVATE_KEY GitHub secret.
The script then aborts at the git fetch step, by design, until the first key is registered.
Second run (after registering the deploy key): completes the clone, Composer install, Nginx site (enabled and reloaded — plain HTTP), systemd units (installed + enabled, will crash-loop harmlessly every 10s until secrets exist), and the sudoers policy at /etc/sudoers.d/webhooks-deploy:
webhooks ALL=(webhooks) NOPASSWD: ALL
webhooks ALL=(root) NOPASSWD: /usr/bin/chown -R webhooks:webhooks /opt/webhooks, /usr/bin/systemctl restart webhooks-service, /usr/bin/systemctl restart webhooks-cleanup, /usr/bin/systemctl restart webhooks-database-service, /usr/bin/systemctl restart webhooks-maintenance, /usr/bin/systemctl reload php8.4-fpm
That’s the entire privilege footprint — nothing broader. Verify any time with ssh webhooks@<VM_PUBLIC_IP> sudo -l.
If you ever re-copy and re-run setup-vps.sh after pulling a newer version of the script, it’s safe — every step is idempotent, and re-running is exactly how you pick up fixes (e.g. a missing sudoers policy) without hand-editing the VM.
Part F — TLS
Nginx is already serving your domain over plain HTTP after Part E. Issue the cert and let certbot upgrade that same server block in place (adds the 443 listener + redirect, preserves every rewrite/deny rule already there — nothing to hand-edit):
sudo apt-get install -y certbot python3-certbot-nginx
sudo certbot --nginx -d example.com --redirect -m you@example.com --agree-tos
Part G — PHP-FPM pool (still manual)
Edit /etc/php/8.4/fpm/pool.d/www.conf:
user = webhooks
group = webhooks
listen = /run/php/php8.4-fpm.sock
listen.owner = www-data
listen.group = www-data
sudo systemctl restart php8.4-fpm
Cutover checklist
- Push to
main(orgh workflow run deploy.yml). Watch it SSH in aswebhooks,git pull,composer install, write secrets, restart the four daemons, reload PHP-FPM. sudo systemctl status webhooks-service webhooks-cleanup webhooks-database-service webhooks-maintenance— allactive (running).curl https://example.com/api/health→{"status":"Healthy", ...}.- Smoke-test
/api/v1/swaggerand/api/v1/broker(with yourAuthorizationheader). - Re-point external webhook sources (GitHub App URL, HealthChecks.io, etc.) to your domain.
journalctl -u webhooks-service -fandtail -f /var/log/nginx/webhooks.error.logwhile watching first real traffic.- Once stable for a few days: delete the old
FTP_*/INSTALLATION_ENDPOINTsecrets and decommission any previous host.
Appendix — quick reference
# Service status / logs
sudo systemctl status webhooks-service
sudo journalctl -u webhooks-cleanup -f
# Sudoers sanity check
ssh webhooks@<VM_PUBLIC_IP> sudo -l
# Firewall
sudo ufw status verbose
sudo fail2ban-client status sshd
# TLS renewal (automatic via systemd timer, manual test)
sudo certbot renew --dry-run
# DB reachability
mysql -h <db-host> -P 3306 -u webhooks -p -e "SELECT 1"
# Nginx
sudo nginx -t && sudo systemctl reload nginx
Known follow-ups, not covered here:
- Broker failover regression in
Src/lib/queue.php— a single dead CloudAMQP broker still stalls all three queues. - Local Docker dev (
docker-compose.yml) has noSrc/secrets/*.phpprovisioned, so/api/healthreports every dependency unhealthy when run locally — cosmetic for local dev, not a production concern.