12 KiB
Deployment Guide — testbed.mk on VPS
Architecture Overview
Internet
│
├── *.testbed.mk ──► Nginx (:80/:443)
│ ├── Extracts subdomain → sets X-Subdomain header
│ └── Proxies to app:3000
│
└── testbed.mk ──► Nginx ──► Next.js app
├── Clerk (auth)
├── PostgreSQL (db:5432)
└── Contabo S3 (images)
Stack: Docker Compose with 3 containers — app (Next.js), db (PostgreSQL 16), nginx (Nginx)
1. VPS Preparation
System Requirements
- Ubuntu 22.04+ or similar Linux
- Minimum 2GB RAM, 1 vCPU
- Open ports: 22 (SSH), 80 (HTTP), 443 (HTTPS)
Install Docker
# Update packages
sudo apt update && sudo apt upgrade -y
# Install Docker
curl -fsSL https://get.docker.com | sh
# Install Docker Compose (if not included)
sudo apt install -y docker-compose-plugin
# Add your user to docker group (optional, avoids sudo)
sudo usermod -aG docker $USER
newgrp docker
# Verify
docker --version
docker compose version
2. DNS Configuration
In your DNS provider, create these records for testbed.mk:
| Type | Host | Value | TTL |
|---|---|---|---|
| A | @ |
YOUR_VPS_IP |
300 |
| A | * |
YOUR_VPS_IP |
300 |
This means:
testbed.mk→ your VPSanything.testbed.mk→ your VPS (wildcard)
Verify DNS propagation:
dig testbed.mk +short
dig random.testbed.mk +short
# Both should return your VPS IP
3. Deploy the Application
Clone the Repository
git clone <your-repo-url> /opt/spomeniQR
cd /opt/spomeniQR
Create Environment File
cp .env.example .env
nano .env
Fill in all values:
# Clerk (PRODUCTION keys from https://dashboard.clerk.com)
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_live_...
CLERK_SECRET_KEY=sk_live_...
# Database (strong password!)
DATABASE_URL=postgresql://postgres:STRONG_PASSWORD_HERE@db:5432/monuments
POSTGRES_PASSWORD=STRONG_PASSWORD_HERE
# Contabo S3
S3_ENDPOINT=https://eu2.contabostorage.com
S3_REGION=eu-2
S3_ACCESS_KEY_ID=your-contabo-access-key
S3_SECRET_ACCESS_KEY=your-contabo-secret-key
S3_BUCKET_NAME=monuments-images
# App
NEXT_PUBLIC_APP_URL=https://testbed.mk
NEXT_PUBLIC_APP_DOMAIN=testbed.mk
Important: Use a strong, unique password for POSTGRES_PASSWORD.
Create the Prisma Migration
Before the first deployment, create the initial migration locally or on the server:
# Start only the database first
docker compose up db -d
# Wait for it to be ready (~5 seconds)
sleep 5
# Run the migration
docker compose exec db psql -U postgres -c "CREATE DATABASE monuments;" 2>/dev/null || true
# Run Prisma migration using a temporary container
docker compose run --rm app npx prisma migrate deploy
Alternatively, you can generate a migration file locally first:
# On your local machine (with DATABASE_URL pointing to any postgres)
npx prisma migrate dev --name init
Then commit the generated migration files. The scripts/start.sh entrypoint will run npx prisma migrate deploy automatically on every container start.
4. Configure Contabo S3
Create the Bucket
- Log into Contabo Object Storage at https://contabostorage.com
- Create a bucket named
monuments-images - Choose the same region as your
S3_REGION(e.g.eu-2)
Set Public Read Access
The monument images need to be publicly viewable. Set this bucket policy:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": { "AWS": ["*"] },
"Action": ["s3:GetObject"],
"Resource": ["arn:aws:s3:::monuments-images/*"]
}
]
}
Set CORS (for browser uploads)
[
{
"AllowedHeaders": ["*"],
"AllowedMethods": ["GET", "PUT"],
"AllowedOrigins": ["https://testbed.mk", "https://*.testbed.mk"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3600
}
]
Create API Keys
- In the Contabo Object Storage panel, create a new access key
- Grant it read/write permissions on the
monuments-imagesbucket - Copy the Access Key ID and Secret Access Key to your
.env
5. Configure Clerk (Production)
- Go to Clerk Dashboard
- Switch to your Production instance
- Under Paths, set:
- Sign-in:
/sign-in - Sign-up:
/sign-up
- Sign-in:
- Under Domains, add:
testbed.mk*.testbed.mk(if supported — otherwise just the apex domain)
- Copy the Production publishable key and secret key to your
.env(the ones starting withpk_live_andsk_live_)
6. SSL / HTTPS Setup
Option A: Let's Encrypt with Certbot (Recommended)
The project includes two Nginx configs:
nginx/conf.d/default.conf— HTTP only (for initial setup / local dev)nginx/conf.d/production.conf— HTTPS with Let's Encrypt
Step 1: Start with HTTP first
Make sure default.conf is active (it is by default):
docker compose up -d
Verify the app is reachable: http://testbed.mk
Step 2: Get the SSL certificate
# Install certbot on the host
sudo apt install -y certbot
# Get a wildcard certificate (requires DNS-01 challenge for *.testbed.mk)
# OR get a single-domain cert (simpler, no wildcard):
# For a single-domain cert (covers testbed.mk only, NOT subdomains):
sudo certbot certonly --webroot \
-w /opt/spomeniQR/certbot/www \
-d testbed.mk
# For a wildcard cert (covers testbed.mk AND *.testbed.mk):
# You MUST use DNS-01 challenge. Example with Cloudflare DNS plugin:
sudo apt install -y python3-certbot-dns-cloudflare
sudo certbot certonly \
--dns-cloudflare \
--dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
-d testbed.mk \
-d '*.testbed.mk'
For the wildcard certificate, you need to use a DNS-01 challenge. This requires:
- Your DNS provider's API credentials
- The appropriate certbot DNS plugin
If your DNS provider doesn't have a certbot plugin, you can use acme.sh with DNS manual mode.
Alternative: Use a certificate for each subdomain on-the-fly. This requires a more complex Nginx setup (not covered here — wildcard cert is recommended for simplicity).
Step 3: Switch to HTTPS config
# Replace the HTTP config with the HTTPS config
cd /opt/spomeniQR/nginx/conf.d/
mv default.conf default.conf.bak
cp production.conf default.conf
# Restart nginx
docker compose restart nginx
Step 4: Auto-renewal
Let's Encrypt certificates expire every 90 days. Set up auto-renewal:
# Test renewal
sudo certbot renew --dry-run
# Add a cron job for auto-renewal
sudo crontab -e
# Add this line:
0 3 * * * certbot renew --quiet --deploy-hook "docker restart spomeniqr-nginx"
Option B: No SSL (Development / Staging)
If you're just testing, keep default.conf as-is. The app works over HTTP. Just make sure:
- Clerk dashboard allows
http://testbed.mkas a domain NEXT_PUBLIC_APP_URL=http://testbed.mkin your.env
7. Build and Run
cd /opt/spomeniQR
# Build and start all services
docker compose up -d --build
# View logs
docker compose logs -f app
# Check status
docker compose ps
The app should now be live at https://testbed.mk (or http://testbed.mk if no SSL).
8. Verify Everything Works
Check the Services
# All containers should be "Up"
docker compose ps
# Check app logs
docker compose logs app | tail -20
# Check nginx logs
docker compose logs nginx | tail -20
Test the Endpoints
# Landing page
curl -I https://testbed.mk
# Subdomain routing (should pass X-Subdomain header)
curl -I https://eiffel-tower.testbed.mk
# API health check
curl https://testbed.mk/api/check-subdomain?slug=test
Test in Browser
- Visit
https://testbed.mk— should show the landing page - Click Sign Up — should create a Clerk account
- Go through the onboarding wizard
- After publishing, visit
https://{your-subdomain}.testbed.mk - Verify photos upload correctly (check Contabo S3 bucket)
9. Maintenance
View Logs
# All services
docker compose logs -f
# Specific service
docker compose logs -f app
docker compose logs -f db
docker compose logs -f nginx
Database Access
# Connect to PostgreSQL
docker compose exec db psql -U postgres -d monuments
# Or use Prisma Studio (opens a web GUI)
docker compose run --rm app npx prisma studio
Update the Application
cd /opt/spomeniQR
git pull origin main
docker compose up -d --build
The start.sh script runs npx prisma migrate deploy automatically, so any schema changes will be applied on startup.
Database Backup
# Create a backup
docker compose exec db pg_dump -U postgres monuments > backup_$(date +%Y%m%d).sql
# Restore from backup
cat backup_20240101.sql | docker compose exec -T db psql -U postgres monuments
Restart Services
# Restart everything
docker compose restart
# Restart only the app (e.g., after env change)
docker compose restart app
# Restart nginx (e.g., after config change)
docker compose restart nginx
10. Troubleshooting
App won't start
docker compose logs app
Common issues:
- DATABASE_URL is wrong: Ensure it matches your
POSTGRES_PASSWORDand usesdbas hostname (notlocalhost) inside Docker - Clerk keys are wrong: Verify
pk_live_/sk_live_keys - S3 credentials wrong: Check your Contabo access key
502 Bad Gateway
The app isn't running or not ready yet:
docker compose ps # Check if app is running
docker compose logs app # Check for startup errors
docker compose restart app # Try restarting
Subdomain routing not working
- Check DNS:
dig random.testbed.mk +shortshould return your VPS IP - Check Nginx config contains the
X-Subdomainheader logic:docker compose exec nginx cat /etc/nginx/conf.d/default.conf - Check the middleware: subdomains rely on the
X-Subdomainheader set by Nginx
SSL certificate errors
# Check if certificate files exist
ls -la /opt/spomeniQR/certbot/conf/live/testbed.mk/
# Renew manually
sudo certbot renew --force-renewal
S3 uploads failing
- Check CORS configuration includes
https://testbed.mk - Check bucket name matches
S3_BUCKET_NAMEin.env - Check access key has read+write permissions
- Test with:
curl -I https://eu2.contabostorage.com/monuments-images/
Can't connect to PostgreSQL
# Check if db is healthy
docker compose ps db
# Try connecting
docker compose exec db psql -U postgres -d monuments -c "SELECT 1;"
# Check the connection string
docker compose exec app printenv DATABASE_URL
11. Environment Variables Reference
| Variable | Required | Description |
|---|---|---|
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY |
Yes | Clerk publishable key (pk_live_...) |
CLERK_SECRET_KEY |
Yes | Clerk secret key (sk_live_...) |
DATABASE_URL |
Yes | PostgreSQL connection string |
POSTGRES_PASSWORD |
Yes | PostgreSQL password (used by db container) |
S3_ENDPOINT |
Yes | Contabo S3 endpoint URL |
S3_REGION |
Yes | Contabo S3 region (e.g. eu-2) |
S3_ACCESS_KEY_ID |
Yes | S3 access key |
S3_SECRET_ACCESS_KEY |
Yes | S3 secret key |
S3_BUCKET_NAME |
Yes | S3 bucket name (monuments-images) |
NEXT_PUBLIC_APP_URL |
Yes | Public URL (https://testbed.mk) |
NEXT_PUBLIC_APP_DOMAIN |
Yes | Domain only (testbed.mk) |