grit deploy

The deploy command, end to end.

8 minmedium

Time to ship. grit deploy takes your local project and runs it on a VPS — Docker, Traefik for HTTPS, your domain, a functioning Postgres. One command. This lesson covers what it does and the prerequisites.

What the command does

Terminal
$grit deploy --host root@1.2.3.4 --domain api.acme.com

It runs (in this order):

  1. SSH's to the host, installs Docker + Docker Compose if missing
  2. Builds your project locally + ships the image to the host
  3. Copies docker-compose.prod.yml + your .env.production
  4. Provisions Traefik with Let's Encrypt for HTTPS
  5. Starts Postgres + Redis + your API behind Traefik with the domain you specified
  6. Health-checks the deployment
  7. Prints the URL — typically https://api.acme.com/api/health

Prerequisites

  • A VPS (Hetzner / DigitalOcean / Linode / Vultr — anywhere with SSH + a public IP). $4/month tier works.
  • SSH key access (no password auth)
  • A domain you control + DNS access
  • An .env.production in your project with real secrets

Setting up DNS

Point an A record at your VPS IP before running deploy. Otherwise Let's Encrypt can't verify domain ownership and HTTPS provisioning fails.

api.acme.com → A → 1.2.3.4 (your VPS IP)

.env.production — what changes from .env

Your dev .env uses localhost services + Mailhog + MinIO. Production uses real services:

.env.production
APP_ENV=production
APP_PORT=8080
APP_URL=https://api.acme.com
DATABASE_URL=postgres://grit:STRONG_PASSWORD@postgres:5432/myapp?sslmode=disable
REDIS_URL=redis://redis:6379
JWT_SECRET=<openssl rand -hex 32>
# Real email
RESEND_API_KEY=re_live_...
MAIL_FROM=noreply@acme.com
# Real storage
STORAGE_DRIVER=r2
R2_ACCOUNT_ID=...
R2_ACCESS_KEY=...
R2_SECRET_KEY=...
R2_BUCKET=acme-prod
# Sentinel + Pulse — strong passwords required in production
SENTINEL_PASSWORD=<openssl rand -hex 16>
SENTINEL_SECRET_KEY=<openssl rand -hex 32>
PULSE_PASSWORD=<openssl rand -hex 16>
# CORS
CORS_ORIGINS=https://app.acme.com,https://acme.com
Never commit .env.production to git. It contains every secret. Add it to .gitignore (Grit's scaffold already does). Store the file somewhere safe (1Password, Bitwarden, vault).

What gets deployed

on the VPS:
/opt/myapp/
ā”œā”€ā”€ docker-compose.prod.yml ← Traefik + Postgres + Redis + API
ā”œā”€ā”€ .env ← your .env.production renamed
ā”œā”€ā”€ traefik/ ← cert storage, config
└── postgres-data/ ← Postgres volume

Docker Compose handles all process management. systemd is optional; the compose stack starts on boot via restart: unless-stopped.

Subsequent deploys

Terminal
# After the first deploy, subsequent ones are this:
$git push # commit your changes
$grit deploy --host root@1.2.3.4 # ship them

Grit rebuilds the image, ships it, restarts the API container with a rolling update. Zero downtime if you wire health checks (see next lesson).

Alternatives

  • Dokploy — UI for the same docker-compose deploys. Worth a look if you want a dashboard.
  • Fly.io / Railway / Render — managed PaaS. More expensive than a VPS but zero config.
  • Kubernetes — for products at scale. Grit ships example manifests; for <10K users you don't need it.

Quick check

You ran `grit deploy` but the API container restart-loops with `DATABASE_URL is required`. What's the most likely cause?

Try it

Spin up a $4 VPS (Hetzner / DigitalOcean / etc) and deploy your bench-api to it.

  1. Create the VPS, note its IP.
  2. Point an A record at it. Wait ~5 minutes for DNS to propagate.
  3. Create .env.production with your real secrets.
  4. Run grit deploy --host root@<IP> --domain api.<your-domain>
  5. Hit https://api.<your-domain>/api/health from your phone (not localhost!) to confirm.

Paste the response (with the real domain) in notes.md.

What's next

Last lesson — the env vars checklist. What MUST be set in production before you ship to real users.

Spot a typo? Have an idea?

Help us improve this lesson. One click opens a GitHub issue with the lesson URL pre-filled — suggest clearer wording, report a bug, or request more depth. The course keeps improving thanks to learners like you.

Suggest an improvement on GitHub