I was tired of SSH-ing into my server, running git pull, pnpm install, pnpm build, and systemctl restart. Every update meant the same five commands, typed manually, with no guarantee I’d remember them in the right order.
Turns out, you can automate this completely with systemd, a git post-merge hook, and 15 lines of bash. No Docker, no CI/CD platform, no complexity. Just simple Unix tools doing what they do best.
The Setup: Systemd Service
First, you need a systemd service to run your app. Here’s mine for the todo.txt app:
[Unit]
Description=Todo.txt Production Server
After=network.target
[Service]
Type=simple
User=kyran
WorkingDirectory=/home/kyran/Sync/workspace/projects/2025/LLMs/claude_code/todo.txt
Environment="NODE_ENV=production"
Environment="PORT=3000"
Environment="HOST=0.0.0.0"
ExecStart=/home/kyran/.local/share/pnpm/pnpm start
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
SyslogIdentifier=todo-txt-prod
[Install]
WantedBy=multi-user.target
The key parts: WorkingDirectory points to the project, ExecStart runs the start command, Restart=always means it comes back up if it crashes, and StandardOutput=journal sends logs to journald.
Save this as todo-txt-prod.service, copy it to /etc/systemd/system/, and enable it:
sudo cp todo-txt-prod.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable todo-txt-prod
sudo systemctl start todo-txt-prod
Now your app runs on boot. Check status with systemctl status todo-txt-prod, view logs with journalctl -u todo-txt-prod -f.
The Deployment Script
Next, write a script that pulls code, installs deps, builds, and restarts:
#!/bin/bash
set -e
echo "🚀 Starting deployment..."
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$PROJECT_DIR"
# Pull latest changes
echo "📥 Pulling latest code..."
git pull
# Install/update dependencies if package.json changed
if git diff --name-only HEAD@{1} HEAD | grep -q "package.json\|pnpm-lock.yaml"; then
echo "📦 Installing dependencies..."
pnpm install
else
echo "✓ No dependency changes detected"
fi
# Build the application
echo "🔨 Building application..."
pnpm build
# Restart the systemd service
echo "🔄 Restarting service..."
sudo systemctl restart todo-txt-prod
echo "✓ Deployment complete!"
Save this as scripts/deploy.sh and make it executable (chmod +x scripts/deploy.sh).
The clever bit: it only runs pnpm install if package.json or pnpm-lock.yaml changed. This saves time on most deployments where you’re just changing code, not dependencies.
The Git Hook: Automatic Deployment
Now the magic. Git has a post-merge hook that runs after git pull succeeds. We can trigger the deployment script automatically:
#!/bin/bash
# Post-merge git hook - triggers automatic deployment after pulling changes
PROJECT_DIR="$(git rev-parse --show-toplevel)"
echo "🔗 Post-merge hook triggered - deploying updates..."
"$PROJECT_DIR/scripts/deploy.sh"
Save this as .git/hooks/post-merge and make it executable (chmod +x .git/hooks/post-merge).
Now, every time you run git pull on the server, it automatically deploys. Pull code, install deps, build, restart. Done.
The Workflow
Development workflow is now:
- Write code on your laptop
- Commit and push to origin
- SSH into the server
- Run
git pull - Watch the hook trigger deployment
You can even automate step 3-4 with a local alias:
alias deploy-todo="ssh my-server 'cd /path/to/todo.txt && git pull'"
Now deploying is one command: deploy-todo. The server pulls, the hook fires, the script runs, the service restarts. Total time: about 30 seconds, depending on build.
Tailscale for HTTPS Without the Pain
One more piece: how do you serve this over HTTPS without messing with certificates? Tailscale has a built-in HTTPS proxy.
#!/bin/bash
set -e
echo "🔐 Setting up Tailscale HTTPS proxy..."
# Check if tailscale is installed
if ! command -v tailscale &> /dev/null; then
echo "❌ Error: Tailscale is not installed"
exit 1
fi
# Configure Tailscale to serve HTTPS
sudo tailscale serve --bg --https=443 3000
echo "✅ Tailscale HTTPS proxy configured!"
echo "🌐 Access your app at: https://$(hostname).tailnet.ts.net"
Run this once, and Tailscale routes HTTPS traffic on port 443 to your app on port 3000. Automatic cert management, no nginx config, no certbot renewals. Just works.
Why This Works for Personal Projects
This setup is perfect for personal apps because:
- No Docker overhead. Node runs directly, starts fast, uses less memory.
- No CI/CD complexity. No YAML config, no build pipelines, no waiting for runners.
- Logs in journald. Standard Linux logging, searchable with
journalctl, rotated automatically. - Service runs on boot. Server restarts? App comes back up.
- Deploys are instant. Pull, build, restart. 30 seconds.
If this were a team project, you’d want more safeguards. Rollback mechanisms, health checks, blue-green deployments, etc. But for a personal todo app? This is exactly the right amount of automation.
Lessons Learned
Systemd is underrated. It’s already on every Linux server, it’s battle-tested, and it does exactly what you need for running services. Git hooks are criminally underused. You can trigger all kinds of automation—linting, testing, deployment—with a few lines of bash. Tailscale’s HTTPS proxy is a cheat code. Skip nginx, skip certbot, skip the entire reverse proxy stack. Just route traffic.
The best deployment setup is the one you’ll actually maintain. If Docker feels like overkill, it probably is. Use the tools you already have.