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:
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:
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:
Build Process¶
- GitHub Actions checks out the repository at the tagged commit
- Builds the Docker image using the repository's
Dockerfile - Tags the image with both the version tag and
latest:ghcr.io/inklet-2026/backend:v1.2.0ghcr.io/inklet-2026/backend:latest
- Pushes both tags to GHCR
Creating a Release¶
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¶
Start / Restart Services¶
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¶
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.