Infrastructure
Deploy Command
One-command production deployment. Build, upload, and configure your server with grit deploy.
How it works
Build
Cross-compiles your Go binary for Linux (GOOS=linux GOARCH=amd64 CGO_ENABLED=0). If a frontend/ directory exists, builds the frontend first.
grit deploy --host user@server.com --domain myapp.com
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.
/opt/my-app/releases/20261030-150405/my-appgrit-migrate.env (0600, www-data)/opt/my-app/current -> releases/20261030-150405
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.
cd /opt/my-app/releases/20261030-150405 && ./grit-migrateln -sfn /opt/my-app/releases/20261030-150405 /opt/my-app/currentsystemctl restart my-app
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.
[Unit]Description=my-appAfter=network.target[Service]Type=simpleUser=www-dataWorkingDirectory=/opt/my-app/currentExecStart=/opt/my-app/current/my-appRestart=on-failureEnvironmentFile=/opt/my-app/current/.env# Security hardeningNoNewPrivileges=yesProtectSystem=strictProtectHome=yesReadWritePaths=/opt/my-app[Install]WantedBy=multi-user.target
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.
myapp.com {reverse_proxy localhost:8080encode gzipheader {X-Frame-Options "DENY"X-Content-Type-Options "nosniff"-Server}}
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.
-> 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 theone 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
| Flag | Env Var | Description |
|---|---|---|
| --host | DEPLOY_HOST | SSH host (e.g. user@server.com) |
| --port | - | SSH port (default: 22) |
| --key | DEPLOY_KEY_FILE | Path to SSH private key |
| --domain | DEPLOY_DOMAIN | Domain 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 |
