Infrastructure

Deploy with Dokploy

Deploy your Grit application using Dokploy — a self-hosted PaaS that gives you the convenience of Vercel or Railway on your own VPS. Web dashboard, auto-SSL, GitHub integration, and Docker Compose support — all for free.

1

What is Dokploy?

Dokploy is an open-source, self-hosted Platform-as-a-Service (PaaS). Think of it as your own private Vercel or Heroku running on a VPS you control. It provides:

FeatureDescription
Web DashboardVisual UI for managing deployments, logs, domains, and environment variables
Docker ComposeDeploy multi-service apps with a single Compose file — perfect for Grit monorepos
Auto SSLAutomatic Let's Encrypt certificates for all your domains via Traefik
GitHub IntegrationAuto-deploy on git push with webhook-based continuous deployment
Database BackupsScheduled backups to S3-compatible storage with one-click restore
MonitoringBuilt-in logs viewer, resource usage graphs, and deployment history

Why Dokploy for Grit? Grit's monorepo (Go API + 2 Next.js apps + PostgreSQL + Redis) maps naturally to a Docker Compose project. Dokploy deploys the entire stack from a single Compose file, handles SSL for all services, and gives you a dashboard to manage everything — no manual Caddy/Nginx config, no SSH for routine deploys.

2

Prerequisites

Before you begin, make sure you have:

  • A VPS with at least 2 vCPU / 4GB RAM (Hetzner CX22, DigitalOcean 4GB, or similar)
  • Ubuntu 22.04 or Debian 12 on the VPS (fresh install recommended)
  • A domain name (e.g., yourdomain.com) with access to DNS settings
  • Your Grit project pushed to a GitHub repository
  • SSH access to your VPS (root or sudo user)

Server sizing: A Grit monorepo (Go API + 2 Next.js apps + PostgreSQL + Redis + MinIO) uses around 2-3 GB of RAM at idle. We recommend 4 GB RAM minimum to leave room for builds and traffic spikes.

3

Install Dokploy

SSH into your VPS and run the one-line installer. This sets up Docker, Traefik (reverse proxy), and the Dokploy dashboard:

server
$ curl -sSL https://dokploy.com/install.sh | sh

The installation takes 2-5 minutes. Once complete, you can access the Dokploy dashboard at:

http://your-server-ip:3000

Create your admin account on first visit. Save these credentials — you'll need them to manage your deployments.

Important: If port 3000 is already in use on your server, Dokploy will fail to start. Make sure no other service is using port 3000 before installing. You can check with sudo lsof -i :3000.

4

Create a Project

In the Dokploy dashboard:

  1. 1Click "Projects" in the sidebar, then "Create Project"
  2. 2Give it a name (e.g., "my-grit-app")
  3. 3Inside the project, click "+ Create Service" and select "Compose"
  4. 4Choose "GitHub" as the provider (you'll need to connect your GitHub account on first use)
  5. 5Select your repository and branch (usually "main")
  6. 6Set the Compose file path to docker-compose.prod.yml (or wherever your production compose file lives)

Tip: Dokploy also supports deploying from a raw Docker Compose YAML pasted directly into the dashboard. This is useful for quick testing before connecting GitHub.

5

Docker Compose Configuration

Your docker-compose.prod.yml defines all services. Here's a production-ready Compose file optimized for Dokploy:

docker-compose.prod.yml
services:
# Go API
api:
build:
context: ./apps/api
dockerfile: Dockerfile
ports:
- "8080:8080"
env_file:
- .env
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
restart: unless-stopped
# Next.js Web App
web:
build:
context: .
dockerfile: apps/web/Dockerfile
ports:
- "3000:3000"
environment:
- NEXT_PUBLIC_API_URL=${API_URL}
restart: unless-stopped
# Next.js Admin Panel
admin:
build:
context: .
dockerfile: apps/admin/Dockerfile
ports:
- "3001:3000"
environment:
- NEXT_PUBLIC_API_URL=${API_URL}
restart: unless-stopped
# PostgreSQL
postgres:
image: postgres:16-alpine
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
POSTGRES_USER: ${DB_USER:-postgres}
POSTGRES_PASSWORD: ${DB_PASSWORD:-password}
POSTGRES_DB: ${DB_NAME:-gritapp}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
restart: unless-stopped
# Redis
redis:
image: redis:7-alpine
volumes:
- redis_data:/var/lib/redis/data
command: redis-server --appendonly yes
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
restart: unless-stopped
# MinIO (S3-compatible file storage)
minio:
image: minio/minio:latest
volumes:
- minio_data:/data
environment:
MINIO_ROOT_USER: ${STORAGE_ACCESS_KEY:-minioadmin}
MINIO_ROOT_PASSWORD: ${STORAGE_SECRET_KEY:-minioadmin}
command: server /data --console-address ":9001"
ports:
- "9000:9000"
- "9001:9001"
restart: unless-stopped
volumes:
postgres_data:
redis_data:
minio_data:

Note: Dokploy uses Traefik as a reverse proxy. You do not need Caddy or Nginx — Dokploy handles routing and SSL automatically. The ports are exposed so Dokploy can route traffic to each service.

Build Context & Monorepo

Notice that the API service uses context: ./apps/api because the Go module is self-contained. The Next.js services use context: . (repo root) because they need access to the shared packages/shared, the root pnpm-lock.yaml, and pnpm-workspace.yaml.

Dockerfiles

Grit scaffolds production-ready Dockerfiles with grit new. Here's what each one looks like:

Go API Dockerfile

Multi-stage build — compiles a static Go binary, then copies it into a minimal Alpine image (~15 MB final):

apps/api/Dockerfile
# Build stage
FROM golang:1.23-alpine AS builder
WORKDIR /app
# Copy go mod files
COPY go.mod go.sum ./
RUN go mod download
# Copy source code
COPY . .
# Build static binary
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/server ./cmd/server
# Run stage
FROM alpine:3.19
RUN apk --no-cache add ca-certificates tzdata
WORKDIR /app
COPY --from=builder /app/server .
EXPOSE 8080
CMD ["./server"]

Next.js Dockerfile (Web & Admin)

Three-stage build — installs deps, builds with standalone output, then creates a minimal runner (~120 MB final). The same pattern is used for both apps/web and apps/admin:

apps/web/Dockerfile
# Build stage
FROM node:20-alpine AS base
RUN corepack enable
# Install dependencies
FROM base AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/web/package.json ./apps/web/
COPY packages/shared/package.json ./packages/shared/
RUN pnpm install --frozen-lockfile
# Build
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules
COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules
COPY . .
RUN pnpm --filter web build
# Run
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/apps/web/.next/standalone ./
COPY --from=builder /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=builder /app/apps/web/public ./apps/web/public
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "apps/web/server.js"]

Critical: The Next.js Dockerfiles depend on output: "standalone" being set in your next.config.ts. Grit sets this automatically for both web and admin apps. Without it, .next/standalone won't be generated and the Docker build will fail. If you removed it, add it back:

next.config.ts
const nextConfig: NextConfig = {
output: "standalone", // Required for Docker builds
reactStrictMode: true,
};

How standalone works: With output: "standalone", Next.js creates a self-contained server.js that includes only the required node_modules files — reducing the Docker image from ~1 GB to ~120 MB. The runner stage only needs the standalone, static, and public folders — no full node_modules.

6

Environment Variables

In the Dokploy dashboard, navigate to your Compose service and click the "Environment" tab. Copy the template below, paste it in, and fill in your values. Every variable has a descriptive placeholder — replace anything that says CHANGE_THIS or YOUR_.

Dokploy → Environment tab (paste this)
# ═══════════════════════════════════════════════════════════════
# GRIT CLOUD — Production Environment (Dokploy)
# ═══════════════════════════════════════════════════════════════
# App
APP_NAME=gritcloud
APP_ENV=production
APP_PORT=8080
APP_URL=https://api.yourdomain.com
# ─── Database (Docker Postgres service in Dokploy) ───────────
# Create a Postgres service in Dokploy, then use the internal hostname.
# Format: postgres://USER:PASSWORD@SERVICE_NAME:5432/DB_NAME?sslmode=disable
DATABASE_URL=postgres://gritcloud:CHANGE_THIS_STRONG_PASSWORD@gritcloud-postgres:5432/gritcloud?sslmode=disable
# ─── Redis (Docker Redis service in Dokploy) ─────────────────
# Create a Redis service in Dokploy, then use the internal hostname.
REDIS_URL=redis://gritcloud-redis:6379
# ─── JWT Authentication ──────────────────────────────────────
# Generate with: openssl rand -hex 32
JWT_SECRET=CHANGE_THIS_GENERATE_WITH_OPENSSL_RAND_HEX_32
JWT_ACCESS_EXPIRY=15m
JWT_REFRESH_EXPIRY=168h
# ─── Storage (Cloudflare R2) ─────────────────────────────────
STORAGE_DRIVER=r2
R2_ENDPOINT=https://YOUR_CF_ACCOUNT_ID.r2.cloudflarestorage.com
R2_ACCESS_KEY=YOUR_R2_ACCESS_KEY_ID
R2_SECRET_KEY=YOUR_R2_SECRET_ACCESS_KEY
R2_BUCKET=gritcloud-uploads
R2_REGION=auto
R2_PUBLIC_URL=https://pub-XXXX.r2.dev
# ─── OAuth2 (Google + GitHub) ────────────────────────────────
GOOGLE_CLIENT_ID=YOUR_GOOGLE_CLIENT_ID
GOOGLE_CLIENT_SECRET=YOUR_GOOGLE_CLIENT_SECRET
GITHUB_CLIENT_ID=YOUR_GITHUB_CLIENT_ID
GITHUB_CLIENT_SECRET=YOUR_GITHUB_CLIENT_SECRET
GITHUB_WEBHOOK_SECRET=YOUR_GITHUB_WEBHOOK_SECRET
OAUTH_FRONTEND_URL=https://cloud.yourdomain.com
# ─── Email (Resend) ──────────────────────────────────────────
RESEND_API_KEY=re_YOUR_RESEND_API_KEY
MAIL_FROM=noreply@yourdomain.com
# ─── CORS (all frontend origins) ─────────────────────────────
CORS_ORIGINS=https://yourdomain.com,https://cloud.yourdomain.com,https://ai.yourdomain.com
# ─── AI Provider ─────────────────────────────────────────────
AI_PROVIDER=claude
AI_API_KEY=sk-ant-YOUR_CLAUDE_API_KEY
AI_MODEL=claude-sonnet-4-5-20250929
# ─── Stripe Billing ──────────────────────────────────────────
STRIPE_SECRET_KEY=sk_live_YOUR_STRIPE_SECRET_KEY
STRIPE_PUBLISHABLE_KEY=pk_live_YOUR_STRIPE_PUBLISHABLE_KEY
STRIPE_WEBHOOK_SECRET=whsec_YOUR_STRIPE_WEBHOOK_SECRET
STRIPE_PRICE_STARTER=price_YOUR_STARTER_PRICE_ID
STRIPE_PRICE_PRO=price_YOUR_PRO_PRICE_ID
STRIPE_PRICE_BUSINESS=price_YOUR_BUSINESS_PRICE_ID
# ─── GORM Studio (DB browser — disable or secure in prod) ───
GORM_STUDIO_ENABLED=false
GORM_STUDIO_USERNAME=admin
GORM_STUDIO_PASSWORD=CHANGE_THIS_STRONG_PASSWORD
# ─── Pulse (Performance monitoring) ──────────────────────────
PULSE_ENABLED=true
PULSE_USERNAME=admin
PULSE_PASSWORD=CHANGE_THIS_STRONG_PASSWORD
# ─── Sentinel (WAF / Rate limiting) ──────────────────────────
SENTINEL_ENABLED=true
SENTINEL_USERNAME=admin
SENTINEL_PASSWORD=CHANGE_THIS_STRONG_PASSWORD
SENTINEL_SECRET_KEY=CHANGE_THIS_GENERATE_WITH_OPENSSL_RAND_HEX_32
# ─── Next.js Frontend Build-time Vars ────────────────────────
# These are needed when building the Next.js apps (cloud, ai, web)
NEXT_PUBLIC_API_URL=https://api.yourdomain.com
NEXT_PUBLIC_APP_URL=https://yourdomain.com
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_YOUR_STRIPE_PUBLISHABLE_KEY

Important: Notice that database and Redis hostnames use Docker service names (e.g., postgres, redis, minio) instead of localhost. Docker Compose networking resolves service names automatically.

7

Configure Domains

Dokploy uses Traefik to route domains to services and automatically provisions SSL certificates via Let's Encrypt. For each service, go to the "Domains" tab and add a domain:

ServiceDomainContainer Port
apiapi.yourdomain.com8080
webyourdomain.com3000
adminadmin.yourdomain.com3000
minio (optional)files.yourdomain.com9001

For each domain in Dokploy:

  1. 1Click "Add Domain" on the service
  2. 2Enter the domain (e.g., api.yourdomain.com)
  3. 3Set HTTPS to "Let's Encrypt" for automatic SSL
  4. 4Set the container port (the port your service listens on inside the container)
  5. 5Save and deploy

SSL note: Let's Encrypt certificates take 1-2 minutes to provision. If you see a "Not Secure" warning immediately after deploying, wait a moment and refresh. The certificate will be issued automatically once DNS is propagated.

8

DNS Configuration

In your domain registrar or DNS provider (Cloudflare, Namecheap, etc.), create A records pointing to your VPS IP address:

TypeNameValuePurpose
A@YOUR_VPS_IPWeb app (yourdomain.com)
AapiYOUR_VPS_IPGo API (api.yourdomain.com)
AadminYOUR_VPS_IPAdmin panel (admin.yourdomain.com)
AfilesYOUR_VPS_IPMinIO console (optional)

Cloudflare users: If you're using Cloudflare, set the proxy status to "DNS only" (grey cloud) for Let's Encrypt to work. If you want Cloudflare's proxy (orange cloud), set the SSL mode to "Full (Strict)" in Cloudflare and configure Dokploy to use Cloudflare-issued certificates instead of Let's Encrypt.

9

Deploy

Once your environment variables and domains are configured, hit the "Deploy" button in the Dokploy dashboard. Dokploy will:

  1. 1Pull your code from GitHub
  2. 2Build all Docker images (Go API + Next.js apps)
  3. 3Start all services defined in your Compose file
  4. 4Configure Traefik routes for your domains
  5. 5Provision SSL certificates via Let's Encrypt

The first build takes longer (5-10 minutes) because Docker needs to download base images and build from scratch. Subsequent deploys are faster thanks to Docker layer caching.

Auto-Deploy on Git Push

To enable automatic deployments when you push to GitHub:

  1. 1Go to your Compose service in Dokploy
  2. 2Click the "General" tab
  3. 3Enable "Auto Deploy" and select your branch (e.g., main)
  4. 4Dokploy will set up a GitHub webhook automatically

Now every git push origin main will trigger a new deployment automatically.

10

Database Backups

Dokploy has built-in backup support for database services. Alternatively, you can run manual backups using pg_dump:

Manual Backup

server
# Find the postgres container name
$ docker ps --filter name=postgres --format {{.Names}}
# Create a backup
$ docker exec -t CONTAINER_NAME pg_dumpall -c -U postgres > backup_$(date +%Y%m%d).sql
# Restore from backup
$ cat backup_20260223.sql | docker exec -i CONTAINER_NAME psql -U postgres

Automated Backups with Cron

Set up a cron job on your server to back up daily:

crontab -e
# Daily backup at 3 AM — keeps 7 days of backups
0 3 * * * docker exec -t $(docker ps --filter name=postgres -q) pg_dumpall -c -U postgres | gzip > /backups/grit_$(date +\%Y\%m\%d).sql.gz && find /backups -name "grit_*.sql.gz" -mtime +7 -delete
11

Monitoring

Dokploy provides built-in monitoring through its dashboard. On top of that, Grit includes additional observability tools:

ToolAccessWhat It Shows
Dokploy Dashboardhttp://your-ip:3000Container logs, CPU/memory usage, deployment history
Pulseapi.yourdomain.com/pulseRequest tracing, DB metrics, error tracking, alerting
Sentinelapi.yourdomain.com/sentinel/uiSecurity threats, blocked IPs, rate limiting
GORM Studioapi.yourdomain.com/studioDatabase browser, SQL editor, data export
API Docsapi.yourdomain.com/docsAuto-generated OpenAPI spec + interactive UI

Viewing Logs

Click any service in the Dokploy dashboard to see real-time logs. You can also view logs from the command line:

server
# Follow API logs
$ docker compose -f docker-compose.prod.yml logs -f api
# View all service logs
$ docker compose -f docker-compose.prod.yml logs --tail 100
12

Tips & Troubleshooting

Build fails with "out of memory"

Next.js builds are memory-intensive. Make sure your VPS has at least 4GB RAM. You can also add swap space: sudo fallocate -l 2G /swapfile && sudo chmod 600 /swapfile && sudo mkswap /swapfile && sudo swapon /swapfile

SSL certificate not provisioning

Make sure your DNS A records are pointing to the correct VPS IP and have propagated (use dig yourdomain.com to verify). If using Cloudflare, set the proxy to "DNS only" (grey cloud). Let's Encrypt needs to reach your server directly.

Port 3000 already in use

Dokploy's dashboard uses port 3000 by default. If your web app also uses port 3000, they won't conflict because the web app runs inside a Docker container with its own network. Dokploy routes traffic based on domain, not port.

Database connection refused

Make sure DATABASE_URL uses the Docker service name (postgres) instead of localhost. Inside Docker Compose, services communicate using service names as hostnames.

How do I SSH into a running container?

Use the Dokploy dashboard's "Terminal" tab, or from the command line: docker exec -it CONTAINER_NAME /bin/sh

How do I redeploy without code changes?

Click the "Redeploy" button in the Dokploy dashboard, or use the Dokploy API webhook URL to trigger a deployment programmatically.

MinIO bucket not found

MinIO doesn't auto-create buckets. After deployment, access the MinIO console (port 9001), sign in with your credentials, and create the bucket manually. Or add a startup script that uses the mc client.

Production checklist: Before going live, make sure you've changed all default passwords (JWT secrets, database password, MinIO keys, Sentinel/Pulse passwords), enabled HTTPS for all domains, and set up automated database backups. See the full production checklist.