How I Deploy Go Web Services With systemd + Nginx (Complete Guide)
I deploy Go web services the same way every time now: a Linux VPS, a systemd unit to keep the process alive, and nginx in front. This is the exact recipe — including the mistakes I made the first few times.
1. Build for the server
Cross-compile from any machine:
GOOS=linux GOARCH=amd64 go build -o myapp-linux ./cmd/myapp
If the binary touches files, keep the working directory predictable. I run services from their own directory with a relative data path, never from a random cwd.
2. Move it and create a dedicated user
Do not run a web service as root.
scp myapp-linux user@server:/opt/myapp/myapp
sudo useradd --system --home /opt/myapp --shell /usr/sbin/nologin myapp
3. The systemd unit
/etc/systemd/system/myapp.service:
[Unit]
Description=myapp
After=network.target
[Service]
Type=simple
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp
ExecStart=/opt/myapp/myapp
EnvironmentFile=/opt/myapp/.env
Restart=on-failure
RestartSec=3
# hardening basics
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ReadWritePaths=/opt/myapp/data
[Install]
WantedBy=multi-user.target
Then systemctl daemon-reload && systemctl enable --now myapp.
EnvironmentFile keeps secrets out of the unit file. ProtectSystem=full + ReadWritePaths means the app can only write where it should. Restart=on-failure turns most crashes into a non-event.
4. nginx in front
/etc/nginx/sites-available/myapp:
server {
listen 80;
server_name myapp.example.com;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
The service listens on 127.0.0.1:8080 — localhost only, never exposed directly. nginx handles TLS (via certbot), static assets, and client headers. I also front everything with Cloudflare for DNS + CDN, so the origin IP is never the point of attack.
5. Zero-downtime restart
Static binaries make deploys clean:
sudo systemctl stop myapp # or better: systemctl restart
For truly zero-downtime with a static binary, run two instances behind nginx and restart one at a time. For a solo service, systemctl restart with a sub-second binary start is usually fine — the process boots in milliseconds.
6. Logging and watching
journalctl -u myapp -ffor logs; addSystemMaxUseinjournald.confso logs cannot fill the disk.- A
GET /healthzendpoint + a cron thatcurls it and alerts on failure. - Back up the data directory (for SQLite: the whole WAL trio) — deploy and backup are separate concerns, and both matter.
The mistakes I made
- Wrong working directory: the app could not find its data files because the unit had no
WorkingDirectory. - Running as root: fixed with a dedicated user and
ProtectSystem=full. - Logs filling the disk: a busy service without
journaldlimits will eventually take down the box. - Forgetting
X-Forwarded-Proto: the app saw plain HTTP and served wrong URLs behind TLS.
None of these are clever. They are the kind of mistakes that only appear after the first deploy, which is exactly why the recipe above is now a checklist. If you are deciding whether a VPS setup is worth it versus managed hosting, run the numbers first — the calculator below compares the two honestly.
Comments (0)
No comments yet.