Automating Deployments on a Raspberry Pi with Go, Nginx, and Webhooks

Running personal projects on a Raspberry Pi is a fantastic way to host applications efficiently and affordably. For my own React/NodeJS project I wanted a way to deploy updates without manually SSH-ing into the Pi, pulling the latest git changes, and restarting services. I needed a simple, lightweight CI/CD pipeline that wouldn't bog down the Pi's limited resources.
The solution? A custom-built webhook listener written in Go, fronted by an Nginx reverse proxy, and managed by systemd. This setup automatically deploys the latest version of the application whenever I push a change to the main
branch on GitHub. Waiting for a push from Github, not continually polling for updates.
Here’s how it all works.
The Core: A Go Webhook Listener
The heart of the system is a small Go application that listens for incoming webhooks from GitHub. Go is a perfect choice for this task because it's compiled, incredibly fast, and has a low memory footprint.
The entire application is a single file, main.go
:
package main
import (
"fmt"
"net/http"
"os"
"os/exec"
"github.com/go-playground/webhooks/v6/github"
)
const (
path = "/webhook"
port = ":3030"
)
func main() {
secret := os.Getenv("GITHUB_WEBHOOK_SECRET")
hook, err := github.New(github.Options.Secret(secret))
if err != nil {
panic(err)
}
// The path to the git repository on the Pi.
repoPath := os.Getenv("GIT_REPO_PATH")
if repoPath == "" {
repoPath = "/path/to/source/code" // Fallback for development
}
http.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
payload, err := hook.Parse(r, github.PushEvent)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// If the event is a push to the main branch, deploy!
if push, ok := payload.(github.PushPayload); ok && push.Ref == "refs/heads/main" {
fmt.Println("Received push to main, starting deployment...")
// 1. Pull the latest code
if err := executeCommand("git", "-C", repoPath, "pull"); err != nil {
http.Error(w, "Pull failed", http.StatusInternalServerError)
return
}
// 2. Install dependencies
if err := executeCommand("npm", "--prefix", repoPath, "install"); err != nil {
http.Error(w, "npm install failed", http.StatusInternalServerError)
return
}
// 3. Build the application
if err := executeCommand("npm", "--prefix", repoPath, "run", "build"); err != nil {
http.Error(w, "npm run build failed", http.StatusInternalServerError)
return
}
// 4. Run tests
if err := executeCommand("npm", "--prefix", repoPath, "run", "test"); err != nil {
http.Error(w, "npm run test failed", http.StatusInternalServerError)
return
}
// 5. Restart the main application service
if err := executeCommand("sudo", "systemctl", "restart", "syncinity-server.service"); err != nil {
http.Error(w, "Service restart failed", http.StatusInternalServerError)
return
}
fmt.Println("Deployment successful!")
}
w.WriteHeader(http.StatusOK)
})
fmt.Printf("Starting webhook server on port %s
", port)
http.ListenAndServe(port, nil)
}
// Helper to execute shell commands and log the output
func executeCommand(name string, args ...string) error {
cmd := exec.Command(name, args...)
output, err := cmd.CombinedOutput()
if err != nil {
fmt.Printf("Command error: %v, %s
", err, output)
return err
}
fmt.Printf("Command success: %s
", output)
return nil
}
This accomplishes a few things:
- Listens on an internal port (
:3030
) at the/webhook
endpoint. - Uses
github.com/go-playground/webhooks
to securely parse incoming GitHub events. This library automatically validates the payload signature using the secret you provide, which is crucial for security. - Checks the event type: It ensures the deployment only triggers on a
PushPayload
to therefs/heads/main
branch. - Executes deployment steps: It runs a sequence of shell commands (
git pull
,npm install
, etc.) using theos/exec
package. - Restarts the application: The final step is to restart the main application's service using
systemctl
since that is what I'm using to control the process.
Exposing the Webhook with Nginx
The Go application should not be exposed directly to the internet. Instead, we use Nginx as a secure reverse proxy. This allows us to handle SSL/TLS encryption and route traffic correctly. I'm also leveraging DDNS for resolving the network the Pi is sitting on.
Here is the Nginx configuration from webhook.nginx.conf
:
server {
listen 8334 ssl http2;
listen [::]:8334 ssl http2;
server_name your.domain.com; # Replace with your domain
# SSL Certificate from Let's Encrypt
ssl_certificate /etc/letsencrypt/live/your.domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your.domain.com/privkey.pem;
# Security headers
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
location /webhook {
proxy_pass http://localhost:3030/webhook;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
Pretty basic Nginx configuration, nothing ground breaking. This configuration listens for HTTPS traffic on port 8334 and forwards any requests for /webhook
to our Go application running on localhost:3030
.
Keeping It All Running with systemd
To ensure both the main application and our new webhook listener run reliably, we manage them with systemd
.
First, here is the service for the main application (application-server.service
), which the webhook restarts:
[Unit]
Description=Application Server
After=network.target postgresql.service
[Service]
Type=simple
Restart=always
User=mdelash
WorkingDirectory=/path/to/source/code/server/side
Environment="NODE_ENV=production"
ExecStart=/usr/bin/npm run start:prod
[Install]
WantedBy=multi-user.target
Next, we need a service for the Go webhook listener itself. This ensures it starts on boot and restarts on failure (webhook.service
).
[Unit]
Description=Go GitHub Webhook Receiver
After=network.target
[Service]
Type=simple
User=mdelash
# The path to your compiled Go binary
ExecStart=/path/to/your/go/binary/webhook
WorkingDirectory=/path/to/your/go/binary
Restart=on-failure
# Set environment variables for the Go app
Environment="GITHUB_WEBHOOK_SECRET=your_super_secret_key"
Environment="GIT_REPO_PATH=/path/to/souce/code"
[Install]
WantedBy=multi-user.target
Enable both services and it's ready to receive commits.
The Final Workflow
With all the pieces in place, the deployment flow is beautifully simple:
- Code gets pushed to a commit to the
main
branch on GitHub. - GitHub sends a
push
event tohttps://your.domain.com:8334/webhook
. - Nginx receives the request and proxies it to the Go webhook listener.
- The Go application validates the request and runs the deployment commands.
- The final command,
sudo systemctl restart syncinity-server.service
, restarts the main application, which now runs the updated code.
This lightweight, event-driven pipeline is perfect for a Raspberry Pi, providing automated deployments with minimal overhead. It’s a simple and powerful way to streamline your development workflow and have an up-to-date testing environment.