17 KiB
Deploying SpomeniQR on Coolify
Canonical reference: this guide is maintained for background/context. The verified production walkthrough (working Traefik setup, v3
HostRegexprouting labels, Docker 29 compatibility, and a dedicated wildcard subdomain certificate section) lives in coolifyDeployFinal.md. Start there for a new deployment; keep this file's notes below in mind as well.
This guide covers deploying SpomeniQR to a VPS using Coolify — a self-hosting platform similar to Heroku/Vercel that manages Docker deployments, SSL, and databases.
Prerequisites
- A VPS with Docker installed (Coolify handles this)
- Coolify installed on your VPS (see coolify.io/docs)
- Domain
testbed.mkwith DNS configured:- A record
@→ your VPS IP - A record
*→ your VPS IP (wildcard for subdomains)
- A record
- A Contabo S3 bucket set up with public read policy
Architecture Overview
Coolify will run two containers:
- app — Next.js standalone build (Dockerfile)
- db — PostgreSQL 16 (Coolify managed database)
We do not deploy Nginx via Docker — Coolify has its own reverse proxy (Traefik/Caddy) that handles SSL, subdomain routing, and the X-Subdomain header.
Internet
│
├── *.testbed.mk ──► Coolify Proxy (:80/:443)
│ ├── Extracts subdomain → sets X-Subdomain header
│ └── Proxies to app:3000
│
└── testbed.mk ──► Coolify Proxy ──► app:3000
Step 1: Add a New Project in Coolify
- Open your Coolify dashboard
- Click + Add New Project
- Name it
SpomeniQR
Step 2: Create the Database
- Inside the project, click + Add New Resource → Database
- Select PostgreSQL
- Configure:
- Name:
spomeniqr-db - PostgreSQL Version: 16
- Database Name:
monuments - Username:
postgres - Password: generate a strong password or set your own
- Name:
- Click Deploy
- After deployment, note the Internal Connection String — it looks like:
You'll need this forpostgresql://postgres:YOUR_PASSWORD@spomeniqr-db:5432/monumentsDATABASE_URL.
Step 3: Add the Application
- Inside the project, click + Add New Resource → Application
- Select Public Repository (or Private if your repo is private)
- Configure:
- Name:
spomeniqr - Repository URL: your Git repo URL
- Branch:
main - Build Pack: Docker (recommended) or Nixpacks
- Name:
Option A: Docker (Recommended — uses the project's Dockerfile)
Set Build Pack to Docker. Coolify will use the Dockerfile in the repo root.
No additional configuration needed — it already includes Prisma generate,
next build, migrations (prisma migrate deploy), and automatic super-admin
provisioning on startup. This is the verified path (tested end-to-end locally).
Note: If using the Docker build pack, the DATABASE_URL must use
spomeniqr-db as the host (Coolify internal network), not localhost.
Build-time variables: the NEXT_PUBLIC_* env vars are inlined into the
browser bundle during next build. The Dockerfile declares matching ARGs, so
mark every NEXT_PUBLIC_* variable as a build variable in Coolify (the
checkbox on each env var). Without this, client-side Clerk auth (sign-in /
sign-up) will have no publishable key. Server-side middleware also reads them at
runtime, so keep them set as regular runtime vars too.
Option B: Nixpacks (auto-detected)
Leave the build pack as Nixpacks. Coolify will auto-detect Next.js and build it.
Add these Build Commands:
npx prisma generate && npm run build
Add this Start Command:
npx prisma migrate deploy && node .next/standalone/server.js
Note: With a custom start command the auto-seed does not run; provision the
super-admin manually after the first deploy with npx prisma db seed (or see the
seed note in Step 4).
Step 4: Configure Environment Variables
In the application settings, go to Environment Variables and add:
# Database — use the Coolify internal connection string
DATABASE_URL=postgresql://postgres:YOUR_PASSWORD@spomeniqr-db:5432/monuments
# Clerk
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_live_...
CLERK_SECRET_KEY=sk_live_...
# Custom Clerk frontend API domain (if configured in Clerk dashboard) — the CSP
# allowlist needs it. Build variable. Omit if not using a custom domain.
NEXT_PUBLIC_CLERK_FAPI_HOST=clerk.testbed.mk
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL=/
NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL=/
# Contabo S3
S3_ENDPOINT=https://eu2.contabostorage.com
S3_REGION=eu-2
S3_ACCESS_KEY_ID=your-access-key
S3_SECRET_ACCESS_KEY=your-secret-key
S3_BUCKET_NAME=monuments-images
# App
NEXT_PUBLIC_APP_URL=https://testbed.mk
NEXT_PUBLIC_APP_DOMAIN=testbed.mk
# Admin session signing secret (64+ random hex chars; openssl rand -hex 32)
ADMIN_SESSION_SECRET=your-random-64-char-secret
# Super-admin — username + BCRYPT HASH (not plaintext). Generate with:
# node -e "import('bcryptjs').then(b => b.default.hash('YOUR_PASSWORD', 12).then(console.log))"
# Coolify UI env vars are passed to the container literally — no `$` escaping needed.
SUPER_ADMIN_USERNAME=super
SUPER_ADMIN_PASSWORD_HASH=$2b$12$REPLACE_WITH_BCRYPT_HASH
# Node
NODE_ENV=production
Important:
DATABASE_URLmust point to the Coolify internal hostname (spomeniqr-db), notlocalhost.- Use your production Clerk keys (
pk_live_/sk_live_), not the test ones. - Use a different, strong super-admin password than your local development one.
- Generate the hash on your dev machine (in the project, so it uses the
project's
bcryptjs) and paste it verbatim — Coolify passes values literally, no$$escaping:node -e "import('bcryptjs').then(b => b.default.hash('YOUR_PASSWORD', 12).then(console.log))" - Mark every
NEXT_PUBLIC_*variable as a build variable (checkbox) so it is inlined into the client bundle atnext buildand visible tonext.config.ts.NEXT_PUBLIC_CLERK_FAPI_HOSTin particular must be a build variable — the CSP is generated at build time and will otherwise block Clerk JS if you use a custom Clerk frontend API domain. Server-only vars (CLERK_SECRET_KEY,DATABASE_URL,ADMIN_SESSION_SECRET,SUPER_ADMIN_PASSWORD_HASH,S3_*) stay as normal runtime variables. - The
superadmin row is provisioned automatically on container start byscripts/start.sh(node prisma/seed.cjsafter migrations). If you instead use a custom Nixpacks start command (Option B below), run the seed manually after the first deploy:npx prisma db seed. ADMIN_SESSION_SECRETis required at runtime — without it every super-admin login returns 500 (ADMIN_SESSION_SECRET env var is not set). Generate withopenssl rand -hex 32.scripts/start.shlogsADMIN_SESSION_SECRET: setat boot so a missing value is obvious.- Changing any
NEXT_PUBLIC_*build variable requires a redeploy, not just a restart — those values are baked into the client bundle and the CSP atnext build.
Step 5: Configure Domain & Subdomain Routing
Set the Main Domain
- In the application settings, go to Configuration → Domains
- Add:
testbed.mk - Enable HTTPS — Coolify will auto-provision a Let's Encrypt certificate
Enable Wildcard Subdomain Routing
This is the critical part. Coolify's proxy needs to:
- Accept requests for
*.testbed.mk - Extract the subdomain and pass it as the
X-Subdomainheader to the Next.js app
Option A: Coolify Proxy Configuration (Recommended)
- In your application, go to Configuration → Domains
- Add both domains:
testbed.mk*.testbed.mk
- Coolify will request a wildcard SSL certificate. If your DNS provider supports DNS-01 challenges (Cloudflare, Route53, etc.), this works automatically. Otherwise, you may need to add each subdomain manually.
Option B: Custom Proxy Configuration
If Coolify doesn't support wildcard domains easily, add a custom Caddy/Traefik configuration in the Coolify settings:
For Caddy (Coolify's default proxy):
Create a file at /data/coolify/proxy/caddy/custom/testbed.mk:
*.testbed.mk {
reverse_proxy app:3000 {
header_up X-Subdomain {http.request.host.labels.2}
header_up X-Forwarded-Proto {scheme}
}
}
testbed.mk {
reverse_proxy app:3000 {
header_up X-Forwarded-Proto {scheme}
}
}
For Traefik (alternative proxy), you'd add labels to the container:
traefik.http.routers.spomeniqr.rule: HostRegexp(`{subdomain:[a-z0-9-]+}.testbed.mk`) || Host(`testbed.mk`)
traefik.http.middlewares.spomeniqr-subdomain.headers.customrequestheaders.X-Subdomain:
Note: The proxy configuration varies based on your Coolify version and proxy choice. Check the Coolify docs for the latest instructions on wildcard domains.
Step 6: Configure Clerk
- 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
- Custom frontend API domain (recommended): under Domains, set
clerk.testbed.mkas the Frontend API custom domain and add the DNS record Clerk instructs (a CNAME). clerk-js then loads fromhttps://clerk.testbed.mk/npm/@clerk/clerk-js@.... Because this domain can't be derived from the publishable key at build time, you must setNEXT_PUBLIC_CLERK_FAPI_HOST=clerk.testbed.mk(Step 4) as a build variable — otherwise the app's CSP blocksclerk.browser.jsand the auth-gated buttons on the home page stop working.
Step 7: Configure Contabo S3
- Log into Contabo Object Storage
- Create a bucket named
monuments-imagesin regioneu-2 - Set the bucket policy for public read:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "PublicReadGetObject",
"Effect": "Allow",
"Principal": { "AWS": ["*"] },
"Action": ["s3:GetObject"],
"Resource": ["arn:aws:s3:::monuments-images/*"]
}
]
}
- Set CORS to allow uploads:
[
{
"AllowedHeaders": ["*"],
"AllowedMethods": ["GET", "PUT", "POST"],
"AllowedOrigins": ["https://testbed.mk", "https://*.testbed.mk"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3600
}
]
- Create an API key with read/write permissions
Step 8: Deploy
- Click Deploy in the Coolify dashboard
- Watch the build logs — it should:
- Install dependencies
- Run
npx prisma generate - Build the Next.js app
- Run
npx prisma migrate deploy - Provision the super-admin (
[seed] Super-admin 'super' provisioned.) - Start the server on port 3000
- First deploy: make sure every
NEXT_PUBLIC_*variable (includingNEXT_PUBLIC_CLERK_PUBLISHABLE_KEYandNEXT_PUBLIC_CLERK_FAPI_HOST) is already marked as a build variable — they're baked in during this build. - Once deployed, visit
https://testbed.mkto verify
Step 9: Verify Subdomain Routing
Test that wildcard subdomains work:
- Create a memorial page with subdomain
test-memorial - Visit
https://test-memorial.testbed.mk - Check browser DevTools network tab — the
X-Subdomainheader should be set by the proxy - The Next.js middleware reads
X-Subdomainand rewrites to the correct page
If subdomains aren't working:
- Check DNS:
dig *.testbed.mk +shortshould return your VPS IP - Check Coolify proxy logs for the wildcard domain configuration
- Verify the
X-Subdomainheader is being set in the proxy config
Step 10: Verify Everything
# App health
curl -s https://testbed.mk | head -5
# CSP must allowlist the Clerk frontend API host (script-src + connect-src)
curl -sI https://testbed.mk | grep -i content-security-policy
# Super-admin login (expect {"success":true})
curl -s -X POST https://testbed.mk/api/admin/login -H "Content-Type: application/json" \
-d '{"username":"super","password":"YOUR_PASSWORD"}'
# Subdomain routing
curl -sI https://test-memorial.testbed.mk | head -5
# API health
curl -s https://testbed.mk/api/check-subdomain?slug=test | python3 -m json.tool
# Database connection (from Coolify terminal)
docker exec spomeniqr-app npx prisma db push --accept-data-loss
Troubleshooting
Build fails with Prisma errors
Make sure DATABASE_URL points to the Coolify internal hostname (e.g., spomeniqr-db:5432), not localhost. The app and database must be on the same Coolify network.
Images return 401 from S3
Make sure you've applied the bucket policy for public read. See Step 7. If Contabo doesn't serve public objects via URL, the app uses an /api/image?key=... proxy route as a fallback.
Subdomain routing not working
- Verify DNS wildcard
*.testbed.mkpoints to your VPS - Check that Coolify's proxy config includes both
testbed.mkand*.testbed.mk - Check the proxy access logs — the
X-Subdomainheader should appear - If using a custom Caddyfile, make sure it's in the right directory and reload the proxy
Clerk authentication issues
- Ensure production Clerk keys are set (not
pk_test_/sk_test_) - Verify
testbed.mkand*.testbed.mkare added in Clerk dashboard domains - Check that
NEXT_PUBLIC_APP_URLandNEXT_PUBLIC_APP_DOMAINare set correctly - CSP blocking Clerk JS — if the browser console shows
Content-Security-Policy: ... blocked a script ... clerk.testbed.mk/npm/@clerk/clerk-js...(and auth buttons do nothing): the CSP was built without the Clerk frontend API host. SetNEXT_PUBLIC_CLERK_FAPI_HOST(Step 4) as a build variable and redeploy — the CSP is generated at build time.
Super-admin login fails
- 401 "Невалидно корисничко име или лозинка" — wrong password, or
SUPER_ADMIN_PASSWORD_HASHwas pasted with$$escaping (the dev Compose format) so the stored hash is wrong. Re-paste the raw$2b$12$...hash and redeploy (the seed upserts the row on boot). - 500 / "ADMIN_SESSION_SECRET env var is not set" —
ADMIN_SESSION_SECRETis missing. Add it (runtime var) and restart. - Verify the boot log contains
[seed] Super-admin 'super' provisioned.— the seed skips silently whenSUPER_ADMIN_PASSWORD_HASHis unset.
Database migration issues
If migrations fail on deploy, you can run them manually from the Coolify terminal:
# SSH into the app container
docker exec -it spomeniqr-app sh
# Run migrations
npx prisma migrate deploy
# Or push schema changes directly
npx prisma db push
Environment Variables Reference
| Variable | Required | Description |
|---|---|---|
DATABASE_URL |
Yes | PostgreSQL connection string (Coolify internal) |
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY |
Yes | Clerk publishable key (pk_live_...) |
NEXT_PUBLIC_CLERK_FAPI_HOST |
No* | Custom Clerk frontend API domain (clerk.testbed.mk) — required when using a custom domain; build variable |
CLERK_SECRET_KEY |
Yes | Clerk secret key (sk_live_...) |
NEXT_PUBLIC_CLERK_SIGN_IN_URL |
Yes | /sign-in |
NEXT_PUBLIC_CLERK_SIGN_UP_URL |
Yes | /sign-up |
NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL |
Yes | Redirect after sign-in (/) |
NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL |
Yes | Redirect after sign-up (/) |
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 |
NEXT_PUBLIC_APP_URL |
Yes | https://testbed.mk |
NEXT_PUBLIC_APP_DOMAIN |
Yes | testbed.mk |
ADMIN_SESSION_SECRET |
Yes | Secret signing admin session cookies (openssl rand -hex 32) |
SUPER_ADMIN_USERNAME |
No | Super-admin username (default super) |
SUPER_ADMIN_PASSWORD_HASH |
No | Super-admin bcrypt hash; if unset, super login is unavailable |
NODE_ENV |
Yes | production |
* NEXT_PUBLIC_CLERK_FAPI_HOST is only needed when a custom Clerk frontend API
domain is configured in the Clerk dashboard. It must be a build variable so
the CSP (generated at next build) allowlists clerk.browser.js.
Useful Coolify Commands
- Redeploy: Project → Application → Deploy
- View Logs: Project → Application → Logs
- SSH into container: Project → Application → Terminal
- Database Management: Project → Database → Admin (pgAdmin or Prisma Studio)
- SSL Certificates: Managed automatically by Coolify for configured domains
Updates
To update the application:
- Push changes to your Git repository
- Coolify will auto-deploy if Auto Deploy is enabled, or click Deploy manually
- The
start.shscript runsnpx prisma migrate deployon every startup, so schema changes are applied automatically