Infrastructure

Deploy Command

One-command production deployment. Build, upload, and configure your server with grit deploy.

grit deployOn your serverrunsfails: back one releasegrit deployone commandBuildbinary + assets1Uploadto the server2Configuresystemd · Caddy3Switchcurrent -> release4Health checkor roll back5
One commandLive server
grit deploy builds, uploads, switches and checks: a release that does not answer is put back

How it works

01

Build

Cross-compiles your Go binary for Linux (GOOS=linux GOARCH=amd64 CGO_ENABLED=0). If a frontend/ directory exists, builds the frontend first.

Terminal
grit deploy --host user@server.com --domain myapp.com
02

Upload into a new release

Each deploy gets a timestamped directory of its own, and current is a symlink to one of them. Nothing is ever written over the binary that is running, which is what used to make the second deploy to a Linux box fail with ETXTBSY. The .env goes up with it, at 0600, owned by the service user.

On the server
/opt/my-app/releases/20261030-150405/
my-app
grit-migrate
.env (0600, www-data)
/opt/my-app/current -> releases/20261030-150405
03

Migrate, then switch

If the project has a cmd/migrate, it is built and uploaded alongside the server and run before the symlink moves: the old binary is still serving, and a migration that fails must not leave the new one running against a schema it does not expect. The switch itself is one symlink, so a request in flight sees the old release or the new one and never half of a copy.

On the server
cd /opt/my-app/releases/20261030-150405 && ./grit-migrate
ln -sfn /opt/my-app/releases/20261030-150405 /opt/my-app/current
systemctl restart my-app
04

Systemd service

Written once, and it names current rather than a release, so a deploy moves a symlink instead of rewriting and reloading the unit. The service restarts on failure and reads its configuration from the .env inside the release.

my-app.service
[Unit]
Description=my-app
After=network.target
[Service]
Type=simple
User=www-data
WorkingDirectory=/opt/my-app/current
ExecStart=/opt/my-app/current/my-app
Restart=on-failure
EnvironmentFile=/opt/my-app/current/.env
# Security hardening
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/opt/my-app
[Install]
WantedBy=multi-user.target
05

Caddy reverse proxy

With --domain, this app's site block is written to /etc/caddy/sites/my-app.caddy and the main Caddyfile gets one import sites/*.caddy line appended if it does not have one. Appended, never rewritten: that file is yours, and every other site in it stays. The configuration is validated before the reload, because a Caddyfile that does not parse takes every site on the box down. HTTPS is automatic, through Let's Encrypt.

/etc/caddy/sites/my-app.caddy
myapp.com {
reverse_proxy localhost:8080
encode gzip
header {
X-Frame-Options "DENY"
X-Content-Type-Options "nosniff"
-Server
}
}
06

Health check, or roll back

The health endpoint is fetched on the server itself, through the app's own port, for up to a minute. From the server deliberately: a deploy that works and a certificate that has not issued yet are different problems and should not look the same. A release that never answers is put back, and the failed release is left on disk so you can read its logs. Old releases are pruned only after the new one has answered.

Terminal
-> Checking the app answers on /api/health...
! the app answered 500 on /api/health for 60 seconds
-> Rolling back to /opt/my-app/releases/20261029-094512...
the new release did not come up, so the server was rolled back to the
one before it. The failed release is still at
/opt/my-app/releases/20261030-150405. Read why with:
ssh user@server.com 'journalctl -u my-app -n 50'

Flags

FlagEnv VarDescription
--hostDEPLOY_HOSTSSH host (e.g. user@server.com)
--port-SSH port (default: 22)
--keyDEPLOY_KEY_FILEPath to SSH private key
--domainDEPLOY_DOMAINDomain for Caddy reverse proxy
--app-port-Port the app runs on (default: 8080)
--env-file-Local .env uploaded into the release (default: the API's own)
--health-path-Probed after the restart (default: /api/health)
--keep-How many old releases to keep (default: 5)
--rollback-Put the previous release back, building and uploading nothing
--dry-run-Print every command that would run, and run none of them