Skip to content

Production Deployment

This guide covers deploying the Inklet backend and portal to a production server using Docker Compose, GHCR container images, and Caddy as a reverse proxy.

Server Structure

The production deployment uses a simple directory layout:

~/deploy/
├── docker-compose.yml
└── .env

All application configuration is managed through the .env file. Docker images are pulled from GitHub Container Registry (GHCR).

Docker Compose

The docker-compose.yml file defines two services using pre-built images from GHCR:

services:
  backend:
    image: ghcr.io/inklet-2026/backend:latest
    restart: unless-stopped
    ports:
      - "4000:4000"
    env_file:
      - .env
    depends_on:
      - db

  portal-web:
    image: ghcr.io/inklet-2026/portal-web:latest
    restart: unless-stopped
    ports:
      - "3000:3000"
    env_file:
      - .env

  db:
    image: postgres:16-alpine
    restart: unless-stopped
    volumes:
      - pgdata:/var/lib/postgresql/data
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    ports:
      - "127.0.0.1:5432:5432"

volumes:
  pgdata:

Database Backups

The PostgreSQL data is stored in a Docker volume. Ensure you have a backup strategy in place. Consider using pg_dump on a cron schedule or a managed database service for production workloads.

Caddy Reverse Proxy

Caddy serves as the reverse proxy and handles automatic HTTPS certificate provisioning via Let's Encrypt.

/etc/caddy/Caddyfile:

portal.iminklet.com {
    reverse_proxy localhost:3000
}

auth.iminklet.com {
    reverse_proxy localhost:4000
}

Caddy automatically:

  • Obtains and renews TLS certificates from Let's Encrypt
  • Redirects HTTP to HTTPS
  • Serves HTTP/2 and HTTP/3

Installing Caddy

# Debian/Ubuntu
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install caddy

After editing the Caddyfile, reload Caddy:

sudo systemctl reload caddy

CI/CD Pipeline

Docker images are built and published automatically by GitHub Actions.

Build Trigger

The workflow triggers on tag pushes matching the v* pattern:

on:
  push:
    tags:
      - 'v*'

Build Process

  1. GitHub Actions checks out the repository at the tagged commit
  2. Builds the Docker image using the repository's Dockerfile
  3. Tags the image with both the version tag and latest:
    • ghcr.io/inklet-2026/backend:v1.2.0
    • ghcr.io/inklet-2026/backend:latest
  4. Pushes both tags to GHCR

Creating a Release

# Tag the commit
git tag v1.2.0
git push origin v1.2.0

The CI pipeline builds and pushes the images automatically. Then deploy on the server:

sudo docker compose -f ~/deploy/docker-compose.yml pull
sudo docker compose -f ~/deploy/docker-compose.yml up -d

Deploy Commands

Pull Latest Images

sudo docker compose -f ~/deploy/docker-compose.yml pull

Start / Restart Services

sudo docker compose -f ~/deploy/docker-compose.yml up -d

View Logs

# All services
sudo docker compose -f ~/deploy/docker-compose.yml logs -f

# Specific service
sudo docker compose -f ~/deploy/docker-compose.yml logs -f backend

Stop Services

sudo docker compose -f ~/deploy/docker-compose.yml down

Rollback to a Specific Version

If a deployment causes issues, pin the image to a specific version tag:

# Edit docker-compose.yml to use a specific version
# image: ghcr.io/inklet-2026/backend:v1.1.0

sudo docker compose -f ~/deploy/docker-compose.yml pull
sudo docker compose -f ~/deploy/docker-compose.yml up -d

Environment Variables

The .env file contains all configuration for the backend and portal. Create it at ~/deploy/.env:

Backend Configuration

Variable Description Example
DATABASE_URL PostgreSQL connection string postgres://user:pass@db:5432/inklet?sslmode=disable
POSTGRES_USER PostgreSQL username (for db container) inklet
POSTGRES_PASSWORD PostgreSQL password (for db container) strong-random-password
POSTGRES_DB PostgreSQL database name (for db container) inklet
JWT_SECRET Secret key for signing JWT tokens 64-char-random-hex-string
PORT Backend HTTP port 4000

AWS IoT Core

Variable Description Example
AWS_REGION AWS region us-east-1
AWS_ACCESS_KEY_ID IAM access key for IoT Core AKIA...
AWS_SECRET_ACCESS_KEY IAM secret key wJal...
IOT_ENDPOINT AWS IoT Core data endpoint xxxx-ats.iot.us-east-1.amazonaws.com
IOT_CERT_PATH Path to backend MQTT certificate /certs/backend.cert.pem
IOT_KEY_PATH Path to backend MQTT private key /certs/backend.private.key
IOT_ROOT_CA_PATH Path to AWS IoT Root CA /certs/root.pem
FACTORY_SECRET HMAC secret for NFC signature verification 32-byte-hex-string

OAuth

Variable Description Example
GOOGLE_CLIENT_ID Google OAuth client ID 123456.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET Google OAuth client secret GOCSPX-...
APPLE_CLIENT_ID Apple OAuth service ID com.iminklet.auth
APPLE_TEAM_ID Apple Developer Team ID ABC123DEF4
APPLE_KEY_ID Apple Sign In private key ID XYZ789
APPLE_PRIVATE_KEY Apple Sign In private key (PEM) -----BEGIN PRIVATE KEY-----\n...

Stripe Billing

Variable Description Example
STRIPE_SECRET_KEY Stripe API secret key sk_live_...
STRIPE_WEBHOOK_SECRET Stripe webhook signing secret whsec_...
STRIPE_PRICE_MONTHLY Stripe Price ID for monthly plan price_...
STRIPE_PRICE_YEARLY Stripe Price ID for yearly plan price_...

Portal Web

Variable Description Example
VITE_AUTH_URL Backend API URL (used by portal frontend) https://auth.iminklet.com

URLs and CORS

Variable Description Example
FRONTEND_URL Portal URL (used for CORS and OAuth redirects) https://portal.iminklet.com
BACKEND_URL Backend public URL https://auth.iminklet.com

Secret Management

Never commit the .env file to version control. Use secure methods to transfer secrets to the production server (e.g., scp, secrets manager, or encrypted storage).

DNS Configuration

Configure DNS records for your domain:

Record Name Value
A portal.iminklet.com Server IP address
A auth.iminklet.com Server IP address

Caddy handles TLS certificate provisioning automatically once DNS is pointing to the server.

Health Checks

Verify the deployment is healthy:

# Backend health
curl https://auth.iminklet.com/health

# Portal (returns HTML)
curl -s -o /dev/null -w "%{http_code}" https://portal.iminklet.com

Expected: backend returns {"status":"ok"}, portal returns HTTP 200.