Compare commits

...

17 Commits
admin ... main

Author SHA1 Message Date
f19ef4ca91 docs updated
Some checks are pending
CI / build (push) Waiting to run
2026-08-04 22:44:31 +02:00
0ecd0908b3 traefic fix in compose file
Some checks are pending
CI / build (push) Waiting to run
2026-08-04 22:30:35 +02:00
4a86790364 compose formating
Some checks are pending
CI / build (push) Waiting to run
2026-08-04 08:41:41 +02:00
08ea4d35b2 another traefic conf
Some checks are pending
CI / build (push) Waiting to run
2026-08-04 08:36:24 +02:00
ca8f2357db app port in dicker compose
Some checks are pending
CI / build (push) Waiting to run
2026-08-04 08:28:18 +02:00
036dd72a39 yaml formating
Some checks are pending
CI / build (push) Waiting to run
2026-08-04 07:31:00 +02:00
3b7bd8f84f another formting error
Some checks are pending
CI / build (push) Waiting to run
2026-08-04 07:20:54 +02:00
09bc7650ae fix formating
Some checks are pending
CI / build (push) Waiting to run
2026-08-04 07:16:19 +02:00
33a36546d2 traefic fix
Some checks are pending
CI / build (push) Waiting to run
2026-08-04 06:43:07 +02:00
a21019b9ea cert resolver fix
Some checks are pending
CI / build (push) Waiting to run
2026-08-04 06:25:54 +02:00
586ed51a6a docs: capture CSP/build-variable and admin-login gotchas in Coolify guide
Some checks are pending
CI / build (push) Waiting to run
2026-08-03 20:15:56 +02:00
89b1dc9a51 fix: allow custom Clerk frontend API domain in CSP (NEXT_PUBLIC_CLERK_FAPI_HOST)
Some checks are pending
CI / build (push) Waiting to run
2026-08-03 19:43:28 +02:00
2af9c823fb docs: prefer Docker build pack + build-variable note; log ADMIN_SESSION_SECRET at boot
Some checks are pending
CI / build (push) Waiting to run
2026-08-03 19:34:36 +02:00
af43a56ef5 docker file fix
Some checks are pending
CI / build (push) Waiting to run
2026-08-03 18:38:16 +02:00
9c8fcd35df fix
Some checks are pending
CI / build (push) Waiting to run
2026-08-03 18:33:14 +02:00
0fb94b6a1b Merge branch 'admin'
Some checks are pending
CI / build (push) Waiting to run
2026-08-03 18:12:58 +02:00
68d52e10c5 updated git ignore 2026-08-01 23:47:13 +02:00
11 changed files with 811 additions and 25 deletions

1
.gitignore vendored
View File

@ -9,6 +9,7 @@
# next.js
/.next/
/.next_old/
/out/
# production

View File

@ -9,6 +9,17 @@ RUN npm ci
FROM base AS builder
WORKDIR /app
RUN apk add --no-cache openssl
# NEXT_PUBLIC_* vars are inlined into the client bundle at build time. Pass
# them via build args (Coolify: mark them as build variables) or the browser
# ClerkProvider / public URLs will be missing.
ARG NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
ARG NEXT_PUBLIC_CLERK_FAPI_HOST
ARG NEXT_PUBLIC_CLERK_SIGN_IN_URL
ARG NEXT_PUBLIC_CLERK_SIGN_UP_URL
ARG NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL
ARG NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL
ARG NEXT_PUBLIC_APP_URL
ARG NEXT_PUBLIC_APP_DOMAIN
COPY --from=deps /app/node_modules ./node_modules
COPY package.json package-lock.json ./
COPY next.config.ts ./

View File

@ -10,13 +10,26 @@ services:
- coolify
labels:
- traefik.enable=true
- traefik.http.routers.spomeniqr-wildcard.rule=HostRegexp(`^[a-z0-9-]+\.testbed\.mk$$`)
- traefik.http.routers.spomeniqr-wildcard.entryPoints=http,https
- traefik.http.routers.spomeniqr-wildcard.service=spomeniqr-svc
- traefik.http.routers.spomeniqr-wildcard.tls=true
- traefik.http.routers.spomeniqr-wildcard.tls.certresolver=letsencrypt
- traefik.http.services.spomeniqr-svc.loadbalancer.server.port=3000
- traefik.docker.network=coolify
- traefik.http.routers.testbed-root.rule=Host(`testbed.mk`)
- traefik.http.routers.testbed-root.entryPoints=https
- traefik.http.routers.testbed-root.service=spomeniqr-svc
- traefik.http.routers.testbed-root.tls=true
- traefik.http.routers.testbed-wildcard.rule=HostRegexp(`^[a-z0-9-]+\.testbed\.mk$$`)
- traefik.http.routers.testbed-wildcard.entryPoints=https
- traefik.http.routers.testbed-wildcard.service=spomeniqr-svc
- traefik.http.routers.testbed-wildcard.tls=true
- traefik.http.routers.testbed-root-http.rule=Host(`testbed.mk`)
- traefik.http.routers.testbed-root-http.entryPoints=http
- traefik.http.routers.testbed-root-http.middlewares=redirect-to-https
- traefik.http.routers.testbed-root-http.service=spomeniqr-svc
- traefik.http.routers.testbed-wildcard-http.rule=HostRegexp(`^[a-z0-9-]+\.testbed\.mk$$`)
- traefik.http.routers.testbed-wildcard-http.entryPoints=http
- traefik.http.routers.testbed-wildcard-http.middlewares=redirect-to-https
- traefik.http.routers.testbed-wildcard-http.service=spomeniqr-svc
- traefik.http.services.spomeniqr-svc.loadbalancer.server.port=3000
- traefik.http.middlewares.gzip.compress=true
- traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https
networks:
coolify:

View File

@ -1,5 +1,11 @@
# Deploying SpomeniQR on Coolify
> **Canonical reference:** this guide is maintained for background/context. The
> **verified production walkthrough** (working Traefik setup, v3 `HostRegexp`
> routing labels, Docker 29 compatibility, and a dedicated **wildcard subdomain
> certificate** section) lives in **[coolifyDeployFinal.md](./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](https://coolify.io/) — a self-hosting platform similar to Heroku/Vercel that manages Docker deployments, SSL, and databases.
## Prerequisites
@ -60,9 +66,26 @@ Internet
- **Name:** `spomeniqr`
- **Repository URL:** your Git repo URL
- **Branch:** `main`
- **Build Pack:** **Nixpacks** (auto-detected) or **Docker** (uses the Dockerfile)
- **Build Pack:** **Docker** (recommended) or **Nixpacks**
### Option A: Nixpacks (Recommended — simpler)
### 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 `ARG`s, 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.
@ -76,11 +99,9 @@ Add this **Start Command:**
npx prisma migrate deploy && node .next/standalone/server.js
```
### Option B: Docker (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 migration and standalone server startup.
**Note:** If using Docker build pack, the `DATABASE_URL` must use `spomeniqr-db` as the host (Coolify internal network), not `localhost`.
**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
@ -93,6 +114,9 @@ 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=/
@ -126,10 +150,30 @@ NODE_ENV=production
- `DATABASE_URL` must point to the Coolify **internal** hostname (`spomeniqr-db`), not `localhost`.
- 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 at `next build` and visible to `next.config.ts`.
`NEXT_PUBLIC_CLERK_FAPI_HOST` in 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 `super` admin row is provisioned automatically on container start by
`scripts/start.sh` (`node prisma/seed.cjs` after migrations). If you instead
use a custom Nixpacks start command (Option A below), run the seed manually
use a custom Nixpacks start command (Option B below), run the seed manually
after the first deploy: `npx prisma db seed`.
- `ADMIN_SESSION_SECRET` is required at runtime — without it every super-admin
login returns 500 (`ADMIN_SESSION_SECRET env var is not set`). Generate with
`openssl rand -hex 32`. `scripts/start.sh` logs `ADMIN_SESSION_SECRET: set`
at 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 at
`next build`.
## Step 5: Configure Domain & Subdomain Routing
@ -196,6 +240,14 @@ traefik.http.middlewares.spomeniqr-subdomain.headers.customrequestheaders.X-Subd
4. Under **Domains**, add:
- `testbed.mk`
- `*.testbed.mk`
5. **Custom frontend API domain (recommended):** under **Domains**, set
`clerk.testbed.mk` as the Frontend API custom domain and add the DNS record
Clerk instructs (a CNAME). clerk-js then loads from
`https://clerk.testbed.mk/npm/@clerk/clerk-js@...`. Because this domain can't
be derived from the publishable key at build time, you **must** set
`NEXT_PUBLIC_CLERK_FAPI_HOST=clerk.testbed.mk` (Step 4) as a **build
variable** — otherwise the app's CSP blocks `clerk.browser.js` and the
auth-gated buttons on the home page stop working.
## Step 7: Configure Contabo S3
@ -242,8 +294,12 @@ traefik.http.middlewares.spomeniqr-subdomain.headers.customrequestheaders.X-Subd
- 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
3. Once deployed, visit `https://testbed.mk` to verify
3. **First deploy:** make sure every `NEXT_PUBLIC_*` variable (including
`NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` and `NEXT_PUBLIC_CLERK_FAPI_HOST`) is
already marked as a build variable — they're baked in during this build.
4. Once deployed, visit `https://testbed.mk` to verify
## Step 9: Verify Subdomain Routing
@ -265,6 +321,13 @@ If subdomains aren't working:
# 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
@ -297,6 +360,22 @@ Make sure you've applied the bucket policy for public read. See Step 7. If Conta
- Ensure production Clerk keys are set (not `pk_test_` / `sk_test_`)
- Verify `testbed.mk` and `*.testbed.mk` are added in Clerk dashboard domains
- Check that `NEXT_PUBLIC_APP_URL` and `NEXT_PUBLIC_APP_DOMAIN` are 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. Set `NEXT_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_HASH` was 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_SECRET`
is missing. Add it (runtime var) and restart.
- Verify the boot log contains `[seed] Super-admin 'super' provisioned.` — the
seed skips silently when `SUPER_ADMIN_PASSWORD_HASH` is unset.
### Database migration issues
@ -319,9 +398,12 @@ npx prisma db push
|----------|----------|-------------|
| `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 |
@ -334,6 +416,10 @@ npx prisma db push
| `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

652
docs/coolifyDeployFinal.md Normal file
View File

@ -0,0 +1,652 @@
# Deploying SpomeniQR to Coolify — Final Production Reference
This document is the complete, verified guide for deploying **SpomeniQR**
(a Next.js multi-tenant memorial app) to a VPS managed by
[Coolify](https://coolify.io/). It reflects the **working production setup** for
`testbed.mk` including wildcard subdomains, a manual wildcard TLS certificate
issued via `acme.sh` + Contabo DNS, and the Traefik `coolify-proxy` that Coolify
runs.
It supersedes `docs/coolify.md` and `docs/deploy.md` for the Coolify target.
---
## Table of Contents
1. [Working stack snapshot](#1-working-stack-snapshot)
2. [Prerequisites](#2-prerequisites)
3. [DNS configuration](#3-dns-configuration)
4. [Install Coolify](#4-install-coolify)
5. [Create the project & database](#5-create-the-project--database)
6. [Add the application](#6-add-the-application)
7. [Environment variables](#7-environment-variables)
8. [Domain & subdomain routing (Traefik labels)](#8-domain--subdomain-routing-traefik-labels)
9. [Subdomain certificates (wildcard TLS)](#9-subdomain-certificates-wildcard-tls)
10. [Deploy the application](#10-deploy-the-application)
11. [Verify everything](#11-verify-everything)
12. [Troubleshooting](#12-troubleshooting)
13. [Maintenance & commands](#13-maintenance--commands)
---
## 1. Working stack snapshot
The production composition that is known to work:
| Piece | Value |
|-------|-------|
| Proxy | Coolify's **Traefik** (service `coolify-proxy`) |
| Proxy image | `traefik:v3.6.x` (must be **v3.6+**, see [§12.1](#121-traefik-cannot-talk-to-docker-29) — a plain `v3.1` is **broken**) |
| App container | built from this repo's `Dockerfile` (Next.js standalone), listens on `:3000` |
| Database | Coolify-managed PostgreSQL 16, hostname `spomeniqr-db` |
| Wildcard routing | Traefik `HostRegexp` labels on the app container (v3 syntax) |
| Wildcard TLS | `acme.sh` + **Contabo DNS-01** cert for `testbed.mk` + `*.testbed.mk`, served as Traefik **default certificate** |
| Subdomain logic | handled **in-app** from the `Host` header (`src/middleware.ts`) — no `X-Subdomain` proxy header needed |
```
Internet
├── testbed.mk ─────────────► Traefik (coolify-proxy) ──► app:3000
│ │
└── *.testbed.mk ──────────────────────┤ (HostRegexp label)
└─► serves wildcard default cert (acme.sh)
```
The app derives the memorial subdomain directly from the request `Host` header
(`getSubdomain()` in `src/middleware.ts`), rewrites `/``/<subdomain>`, and
renders `src/app/[subdomain]/page.tsx`. **The proxy only needs to route every
host under `testbed.mk` to the app and terminate TLS — it does not need to set
any custom header.**
---
## 2. Prerequisites
- A VPS running a recent Linux (Ubuntu 22.04+ used here), **≥2 GB RAM**.
- **Docker 29.x** is what triggers the critical Traefik compatibility step
below. If your VPS only has Docker ≤28, Traefik `v3.1` still works, but the
production setup documented here assumes Docker 29 and pins Traefik `v3.6`.
- Domain `testbed.mk` registered, with DNS nameserver access (Contabo DNS in
this project, used by the wildcard cert's DNS-01 challenge).
- A Contabo S3 bucket (`monuments-images`) with public-read policy (see
`docs/deploy.md` §4) and a Clerk **production** instance.
---
## 3. DNS configuration
Create two DNS records at your provider:
| Type | Name | Value | TTL |
|------|------|-------|-----|
| A | `@` | `YOUR_VPS_IP` | 300 |
| A | `*` | `YOUR_VPS_IP` | 300 |
- `@` makes `testbed.mk` resolve → VPS.
- `*` (wildcard) makes **every** `*.testbed.mk` resolve → VPS. Required for
tenant subdomains and for the wildcard cert's DNS-01 **ACME validation
domain** (`_acme-challenge.testbed.mk`).
Verify:
```bash
dig testbed.mk +short
dig random.testbed.mk +short # both must print YOUR_VPS_IP
```
> The DNS-01 challenge for a wildcard cert needs an `_acme-challenge.testbed.mk`
> **TXT** record which the DNS client creates automatically via API. The
> Contabo API credentials in `dns_contabo.sh` must therefore have permission to
> manage `testbed.mk` records.
---
## 4. Install Coolify
Use the official installer (`coolify.io/docs/installation`) or the Vagrant-free
server method. On a fresh VPS:
```bash
curl -fsSL https://coolify.io/install | bash
```
Coolify will install Docker and its own stack (`coolify`, `coolify-db`,
`coolify-redis`, `coolify-realtime`, `coolify-sentinel`, `coolify-proxy`, …).
After installation it manages:
- its proxy configuration under **`/data/coolify/proxy/`**
- its application source under **`/data/coolify/source/`**
Finish the installer in the browser and log in to the dashboard.
---
## 5. Create the project & database
1. Dashboard → **+ Add New Project** → name it `SpomeniQR`.
2. Inside the project → **+ Add New Resource → Database → PostgreSQL**:
- Name: `spomeniqr-db`
- PostgreSQL version: **16**
- Database name: `monuments`
- Username: `postgres`
- Password: strong, unique
3. **Deploy**. Copy the **Internal Connection String**, e.g.:
`postgresql://postgres:PASSWORD@spomeniqr-db:5432/monuments`
(the host is the Coolify-internal service name `spomeniqr-db`, not `localhost`).
---
## 6. Add the application
1. Inside the project → **+ Add New Resource → Application**.
2. Select **Public Repository** (or **Private** with a deploy key).
- Name: `spomeniqr`
- Repository URL: your Git URL
- Branch: `main`
- **Build Pack: Docker** (uses the repo's `Dockerfile`; verified path).
3. Wire the database to the app on the shared Coolify network (`quick connect`
button or ensure both are on `coolify` network).
The repo `Dockerfile` already handles `prisma generate`, `next build`,
`prisma migrate deploy`, and provisioning the `super` admin on startup, so no
custom build/start commands are needed.
---
## 7. Environment variables
In the app's **Environment Variables** panel add (mark **every** `NEXT_PUBLIC_*`
as a **Build Variable**):
```env
# Database — Coolify internal hostname
DATABASE_URL=postgresql://postgres:PASSWORD@spomeniqr-db:5432/monuments
# Clerk
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_live_...
CLERK_SECRET_KEY=sk_live_...
NEXT_PUBLIC_CLERK_FAPI_HOST=clerk.testbed.mk # build var, only if using custom frontend API domain
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 (openssl rand -hex 32)
ADMIN_SESSION_SECRET=your-random-64-char-secret
# Super-admin (bcrypt hash, NOT plaintext)
SUPER_ADMIN_USERNAME=super
SUPER_ADMIN_PASSWORD_HASH=$2b$12$REPLACE_WITH_BCRYPT_HASH
# Node
NODE_ENV=production
```
**Critical rules**
- `NEXT_PUBLIC_*` variables are inlined at `next build`. Changing one requires a
**redeploy** (not just a restart). `NEXT_PUBLIC_CLERK_FAPI_HOST` in particular
must be a build var or the build-time CSP blocks Clerk JS.
- `SUPER_ADMIN_PASSWORD_HASH` is passed **literally** by Coolify — paste the raw
`$2b$12$…` hash with no `$$` escaping (that escaping is only for `env_file`
Compose usage). Generate with:
`node -e "import('bcryptjs').then(b => b.default.hash('YOUR_PASSWORD', 12).then(console.log))"`
- Server-only vars (`CLERK_SECRET_KEY`, `DATABASE_URL`, `ADMIN_SESSION_SECRET`,
`SUPER_ADMIN_PASSWORD_HASH`, `S3_*`) stay as normal runtime vars.
- `ADMIN_SESSION_SECRET` missing ⇒ super-admin login returns **500** at boot.
---
## 8. Domain & subdomain routing (Traefik labels)
Coolify's proxy in this project is **Traefik**. Routing to the app is defined by
**Docker labels in the repo's `docker-compose.yaml`**, which Coolify applies
when it deploys the container. Keep that file in sync in the repo:
```yaml
services:
app:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
env_file:
- .env
networks:
- coolify
labels:
- traefik.enable=true
- traefik.docker.network=coolify
- traefik.http.routers.testbed-root.rule=Host(`testbed.mk`)
- traefik.http.routers.testbed-root.entryPoints=https
- traefik.http.routers.testbed-root.service=spomeniqr-svc
- traefik.http.routers.testbed-root.tls=true
- traefik.http.routers.testbed-wildcard.rule=HostRegexp(`^[a-z0-9-]+\.testbed\.mk$$`)
- traefik.http.routers.testbed-wildcard.entryPoints=https
- traefik.http.routers.testbed-wildcard.service=spomeniqr-svc
- traefik.http.routers.testbed-wildcard.tls=true
- traefik.http.routers.testbed-root-http.rule=Host(`testbed.mk`)
- traefik.http.routers.testbed-root-http.entryPoints=http
- traefik.http.routers.testbed-root-http.middlewares=redirect-to-https
- traefik.http.routers.testbed-root-http.service=spomeniqr-svc
- traefik.http.routers.testbed-wildcard-http.rule=HostRegexp(`^[a-z0-9-]+\.testbed\.mk$$`)
- traefik.http.routers.testbed-wildcard-http.entryPoints=http
- traefik.http.routers.testbed-wildcard-http.middlewares=redirect-to-https
- traefik.http.routers.testbed-wildcard-http.service=spomeniqr-svc
- traefik.http.services.spomeniqr-svc.loadbalancer.server.port=3000
- traefik.http.middlewares.gzip.compress=true
- traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https
networks:
coolify:
external: true
```
**Traefik v3 `HostRegexp` — critical syntax note**
- ✅ v3-valid: `HostRegexp(`^[a-z0-9-]+\.testbed\.mk$`)`
- ❌ v2 (removed in v3): `HostRegexp(`{subdomain:[a-zA-Z0-9-]+}.testbed.mk`)`
The v2 named-capture form silently fails to parse under Traefik 3, so the
wildcard router is **not created** and subdomains fall through to Coolify's
default **503** page ("server not available") even though the apex works. **Do
not** reintroduce the v2 form.
In `docker-compose.yaml` the trailing `$` anchor must be written as `$$` so YAML
interpolation yields a literal `$`. After editing, the container must be
**recreated** for the new labels to take effect (a plain restart is not enough).
**Effect of the two HTTPS routers**
- `Host(\`testbed.mk\`)` → apex landing page.
- `HostRegexp(\`^[a-z0-9-]+\.testbed\.mk$\`)` → **every** subdomain
(`perop`, `test-memorial`, …) → same `app:3000`. The app's middleware then
maps the subdomain to a memorial page.
- Both are `tls=true` **without a certresolver**, so Traefik uses the
**default certificate** — which is the wildcard cert from
[§9 Subdomain certificates](#9-subdomain-certificates-wildcard-tls). No
`tls.certresolver` label is needed on these routers.
> Because Coolify's own default routers can also be generated from the UI
> `Domains` panel, prefer adding `testbed.mk` / `*.testbed.mk` there **or**
> relying solely on these labels — avoid having both apply the same rule, which
> creates duplicate-router ambiguity.
---
## 9. Subdomain certificates (wildcard TLS)
This section is dedicated to provisioning and renewing the TLS certificate that
secures **all** `*.testbed.mk` subdomains, since it is the part that is easiest
to get wrong.
### 9.1 Why a wildcard cert
The app issues an arbitrary number of tenant subdomains
(`<subdomain>.testbed.mk`). It is impractical to obtain an individual
certificate per subdomain. A single wildcard certificate for `*.testbed.mk`
covers every present and future tenant. Because it is issued as Traefik's
**default certificate**, any router with `tls=true` and no explicit
certresolver (which is exactly how §8 is configured) is served the wildcard
cert automatically.
### 9.2 Challenge type & provider
Wildcard certs can **only** be validated with the **DNS-01** challenge (an
`_acme-challenge.testbed.mk` TXT record — there is no HTTP path to validate
`.testbed.mk` itself). This project uses `acme.sh` with a custom **Contabo DNS**
client (`dns_contabo.sh`) because the domain is hosted on Contabo DNS.
### 9.3 Required credentials
Obtain from your Contabo customer account:
| Variable | Purpose |
|----------|---------|
| `CONTABO_CLIENT_ID` | OAuth2 Client ID |
| `CONTABO_CLIENT_SECRET` | OAuth2 Client Secret |
| `CONTABO_API_USER` | Contabo account email |
| `CONTABO_API_PASSWORD` | Contabo account password |
`dns_contabo.sh` reads these from the environment / acme.sh account conf and
uses Contabo's API (`https://api.contabo.com/v1`) to create and delete the
`_acme-challenge` TXT records needed for validation.
### 9.4 Install `dns_contabo.sh`
1. Install `acme.sh` (non-root or root):
```bash
curl https://get.acme.sh | sh -s email=you@example.com
```
2. Place `dns_contabo.sh` where acme.sh finds API plugins. It is written to be
self-contained (uses `acme.sh` built-ins `_post`/`_info`/`_err`), so the
cleanest approach is:
```bash
mkdir -p ~/.acme.sh/dnsapi
cp /path/to/dns_contabo.sh ~/.acme.sh/dnsapi/
chmod +x ~/.acme.sh/dnsapi/dns_contabo.sh
```
### 9.5 Export credentials
```bash
export CONTABO_CLIENT_ID="..."
export CONTABO_CLIENT_SECRET="..."
export CONTABO_API_USER="you@example.com"
export CONTABO_API_PASSWORD="..."
```
### 9.6 Issue the wildcard certificate
```bash
# Covers the apex AND every subdomain
~/.acme.sh/acme.sh --issue \
--dns dns_contabo \
-d testbed.mk \
-d '*.testbed.mk'
```
acme.sh stores the result in `~/.acme.sh/testbed.mk_ecc/`:
`fullchain.cer`, `testbed.mk.key`, `testbed.mk.cer`, `ca.cer`.
> **Validation**: the `${DOMAIN}_ecc` directory must contain `testbed.mk.cer`
> and a key. If verification fails, check the DNS TXT records
> (`dig +short _acme-challenge.testbed.mk TXT`) and that the Contabo credentials
> have record permissions on the zone.
### 9.7 Traefik default-certificate config (Coolify proxy)
The proxy (`coolify-proxy`, image `traefik:v3.6`) mounts the host directory
`/data/coolify/proxy/` at `/traefik` (see [§12.2](#122-proxy-mount-layout)).
Two things must be provisioned there:
**a) Copy the cert files into Coolify's certs directory:**
```bash
COOLIFY_CERT_DIR="/data/coolify/proxy/certs"
mkdir -p "$COOLIFY_CERT_DIR"
cp ~/.acme.sh/testbed.mk_ecc/fullchain.cer "$COOLIFY_CERT_DIR/testbed.mk.cert"
cp ~/.acme.sh/testbed.mk_ecc/testbed.mk.key "$COOLIFY_CERT_DIR/testbed.mk.key"
chmod 644 "$COOLIFY_CERT_DIR/testbed.mk.cert"
chmod 600 "$COOLIFY_CERT_DIR/testbed.mk.key"
```
**b) Create the dynamic config** `/data/coolify/proxy/dynamic/testbed.mk-wildcard.yaml`
(loaded by the proxy's file provider; note paths are **inside the container**,
i.e. under `/traefik/…`):
```yaml
tls:
certificates:
- certFile: /traefik/certs/testbed.mk.cert
keyFile: /traefik/certs/testbed.mk.key
stores:
default:
defaultCertificate:
certFile: /traefik/certs/testbed.mk.cert
keyFile: /traefik/certs/testbed.mk.key
```
The `defaultCertificate` under `tls.stores.default` makes this the cert Traefik
serves for any TLS router that has no explicit certresolver — including both
`testbed-root` and `testbed-wildcard` (§8). A change here requires a proxy
reload (restarting `coolify-proxy` picks it up).
### 9.8 Install with acme.sh for auto-renewal
Register the copy + reload steps so acme.sh performs them automatically every
renewal (certs expire every ~90 days):
```bash
~/.acme.sh/acme.sh --install-cert -d testbed.mk -d '*.testbed.mk' \
--fullchain-file "/data/coolify/proxy/certs/testbed.mk.cert" \
--key-file "/data/coolify/proxy/certs/testbed.mk.key" \
--reloadcmd "docker restart coolify-proxy"
```
- `--fullchain-file`/`--key-file` tell acme.sh to **copy** the renewed certs
into Coolify's certs directory on every renewal.
- `--reloadcmd` restarts the proxy so Traefik reloads the files.
- acme.sh installs its own cron job for renewal, so no separate crontab is
needed.
Optionally, a dedicated renewal hook script can be used (kept in
`~/.acme.sh/renewal-hooks/testbed.mk-coolify-reload.sh`) that copies both files
and runs `docker restart coolify-proxy`; advantages are clearer logging and a
manual script you can run to force a reload after a manual cert update.
### 9.9 Verify the wildcard cert is live
```bash
# SANs must list both testbed.mk and *.testbed.mk
openssl x509 -in /data/coolify/proxy/certs/testbed.mk.cert -noout -text | grep -A2 "Subject Alternative Name"
# Traefik default cert actually offered on a subdomain
echo | openssl s_client -connect perop.testbed.mk:443 -servername perop.testbed.mk 2>/dev/null \
| openssl x509 -noout -subject -issuer
# Next renewal date
~/.acme.sh/acme.sh --list | grep testbed.mk
```
### 9.10 Renewal policy summary
| Item | Value |
|------|-------|
| Issuer | Let's Encrypt via `acme.sh` (DNS-01, `dns_contabo`) |
| Covered | `testbed.mk` + `*.testbed.mk` |
| Lifetime | 90 days; auto-renewed by acme.sh cron |
| Destination | `/data/coolify/proxy/certs/testbed.mk.{cert,key}` |
| Reload | `--reloadcmd docker restart coolify-proxy` |
| Served as | Traefik **default certificate** (`tls.stores.default.defaultCertificate`) |
> **Do NOT** rely on Coolify's built-in Let's Encrypt `letsencrypt` certresolver
> for the wildcard. Coolify's default uses an HTTP-01 challenge, which cannot
> validate a wildcard, and Traefik v3 does not auto-provision per-subdomain certs
> from `HostRegexp` routers. The manual DNS-01 wildcard + default-cert approach
> is the correct one for this app.
---
## 10. Deploy the application
1. Ensure `[§7 env vars]` are saved and all `NEXT_PUBLIC_*` are marked as build
variables **before** the first build.
2. Click **Deploy**. Watch the build log to confirm, in order:
- dependencies installed,
- `npx prisma generate`,
- `next build`,
- `npx prisma migrate deploy`,
- `[seed] Super-admin 'super' provisioned.`,
- server listening on port **3000**.
3. If the routing (Traefik) labels changed (e.g. §8 edits in the repo), triggers
a **recreate** so the new labels apply.
---
## 11. Verify everything
```bash
# Apex
curl -s -o /dev/null -w "%{http_code}\n" https://testbed.mk # 200
# Subdomain / wildcard routing + default cert
curl -s -o /dev/null -w "%{http_code}\n" https://perop.testbed.mk # 200
# TLS on a subdomain uses the wildcard cert (no browser error)
echo | openssl s_client -connect perop.testbed.mk:443 -servername perop.testbed.mk 2>/dev/null \
| openssl x509 -noout -subject
# Proxy healthy + no docker-provider errors
docker logs coolify-proxy --since 1m 2>&1 | grep -iE "too old|error" | tail
# API health
curl -s https://testbed.mk/api/check-subdomain?slug=test | python3 -m json.tool
# Admin login (expects {"success":true})
curl -s -X POST https://testbed.mk/api/admin/login -H "Content-Type: application/json" \
-d '{"username":"super","password":"YOUR_PASSWORD"}'
```
Browser checks
- `https://testbed.mk` → landing page.
- Publish a memorial with subdomain `perop`, then `https://perop.testbed.mk`
→ the memorial page with a green lock (no certificate warning).
- DevTools → Network: no CSP violation for `clerk.testbed.mk/npm/...` when the
custom Clerk frontend domain is used.
---
## 12. Troubleshooting
### 12.1 Traefik cannot talk to Docker 29 ("client version 1.24 is too old")
**Symptom**: `coolify-proxy` logs repeat:
```
ERR Failed to retrieve information of the docker client ...
error="Error response from daemon: client version 1.24 is too old.
Minimum supported API version is 1.40, please upgrade your client"
providerName=docker
```
and **every** domain (apex + subdomains) returns 503.
**Cause**: Docker 29 raised its minimum API version (here **1.40**); Traefik
`v3.1` runs a Docker client pinned to **API 1.24** and never negotiates, so the
Docker provider — which discovers the app's label routers — is dead. It is not a
config problem. Coolify's default proxy (image `traefik:v3.1`) hits this on a
Docker 29 host.
**Fix**: pin the proxy image to **Traefik v3.6+** (adds Docker API auto-
negotiation). The proxy compose lives at `/data/coolify/proxy/docker-compose.yml`
(the service is named `traefik`):
```bash
sed -i "s@traefik:v3.1@traefik:v3.6@" /data/coolify/proxy/docker-compose.yml
grep 'image:' /data/coolify/proxy/docker-compose.yml
docker compose -f /data/coolify/proxy/docker-compose.yml up -d --force-recreate traefik
docker exec coolify-proxy traefik version # expect 3.6.x
```
Confirm the provider is healthy and routing resumes:
```bash
docker logs coolify-proxy --since 30s 2>&1 | grep -i "too old" # should be empty
curl -s -o /dev/null -w "%{http_code}\n" https://testbed.mk # 200
```
A future Coolify **update may regenerate the proxy compose back to `v3.1`**.
Re-apply the `sed` above after any Coolify update, or (as a durable stopgap)
lower the daemon's minimum API so the 1.24 client is accepted by adding
`"min-api-version": "1.24"` to `/etc/docker/daemon.json` and restarting Docker:
```bash
# /etc/docker/daemon.json: { ..., "min-api-version": "1.24" }
systemctl restart docker
```
Prefer the v3.6 image fix — it touches only the proxy, not the whole daemon.
### 12.2 Proxy mount layout
`coolify-proxy` mounts the host directory `/data/coolify/proxy/` at `/traefik`.
Everything the proxy reads must therefore be expressed with in-container paths:
- cert files inside the container: `/traefik/certs/*`
- dynamic config inside the container: `/traefik/dynamic/*`
- ACME store: `/traefik/acme.json`
Verify the mount and files:
```bash
docker inspect coolify-proxy --format '{{json .Mounts}}'
docker exec coolify-proxy ls -la /traefik/certs /traefik/dynamic
```
### 12.3 Subdomain shows "server not available" (503) while apex works
- **Most common: v2 `HostRegexp` label.** Ensure both
`testbed-wildcard` and `testbed-wildcard-http` use the v3 form
`HostRegexp(\`^[a-z0-9-]+\.testbed\.mk$$\`)` (see §8). Recreate the container
after editing labels.
- **Docker 29 / Traefik v3.1**: see §12.1.
- **DNS**: `dig perop.testbed.mk +short` must return the VPS IP.
- **Duplicate routers**: if you also added `*.testbed.mk` in Coolify's UI
`Domains`, you may have overlapping routers — prefer one method.
### 12.4 Subdomain loads but shows a certificate error
- Wildcard default cert not loaded / stale. Check §9.7 layout and paths, then
`docker restart coolify-proxy`.
- Cert files not renewed: rerun `~/.acme.sh/acme.sh --renew -d testbed.mk -d '*.testbed.mk' --force`
and check the SANs (must include `*.testbed.mk`).
### 12.5 Build fails with Prisma errors
`DATABASE_URL` must use the internal hostname `spomeniqr-db:5432`, not
`localhost`, and the app + database must be on the shared Coolify network.
### 12.6 Super-admin login fails
- 401: `SUPER_ADMIN_PASSWORD_HASH` pasted with `$$` escaping → re-paste the raw
`$2b$12$…` hash and redeploy.
- 500 / `ADMIN_SESSION_SECRET env var is not set` → set `ADMIN_SESSION_SECRET`
(runtime) and restart.
- Check boot log for `[seed] Super-admin 'super' provisioned.`
### 12.7 CSP blocks Clerk JS (auth buttons dead)
Set `NEXT_PUBLIC_CLERK_FAPI_HOST` (e.g. `clerk.testbed.mk`) as a **build
variable** and **redeploy** — the CSP is generated at build time.
---
## 13. Maintenance & commands
| Action | Command / location |
|--------|--------------------|
| Redeploy app | Project → Application → **Deploy** |
| Re-apply proxy v3.6 pin | `sed -i 's@traefik:v3.1@traefik:v3.6@' /data/coolify/proxy/docker-compose.yml && docker compose -f /data/coolify/proxy/docker-compose.yml up -d --force-recreate traefik` |
| Restart proxy | `docker restart coolify-proxy` |
| Proxy logs | `docker logs coolify-proxy -f` |
| App logs | Project → Application → **Logs** / `docker logs <app-container> -f` |
| Force cert renewal | `~/.acme.sh/acme.sh --renew -d testbed.mk -d '*.testbed.mk' --force` |
| Check cert status | `~/.acme.sh/acme.sh --list` |
| Migrations | auto on container start; manual: `docker exec <app> npx prisma migrate deploy` |
| DB backup | Coolify UI database backup, or `pg_dump` against `spomeniqr-db` |
| Update app | push to Git; Coolify redeploys (or click **Deploy**) |
---
### Environment variables quick reference
| Variable | Req | Notes |
|----------|-----|-------|
| `DATABASE_URL` | Yes | Coolify-internal, host `spomeniqr-db` |
| `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` | Yes | build var; `pk_live_…` |
| `NEXT_PUBLIC_CLERK_FAPI_HOST` | No* | build var; custom Clerk domain |
| `CLERK_SECRET_KEY` | Yes | runtime; `sk_live_…` |
| `NEXT_PUBLIC_CLERK_*` paths | Yes | build vars (see §7) |
| `S3_ENDPOINT` / `S3_REGION` | Yes | Contabo object storage |
| `S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY` | Yes | Contabo API keys |
| `S3_BUCKET_NAME` | Yes | `monuments-images` |
| `NEXT_PUBLIC_APP_URL` | Yes | build var; `https://testbed.mk` |
| `NEXT_PUBLIC_APP_DOMAIN` | Yes | build var; `testbed.mk` |
| `ADMIN_SESSION_SECRET` | Yes | runtime; `openssl rand -hex 32` |
| `SUPER_ADMIN_USERNAME` / `SUPER_ADMIN_PASSWORD_HASH` | No | bcrypt hash, literal |
| `NODE_ENV` | Yes | `production` |
\* only when using a custom Clerk frontend API domain (`clerk.testbed.mk`).

View File

@ -1,13 +1,19 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
...compat.extends("next/core-web-vitals", "next/typescript"),
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",

View File

@ -4,9 +4,12 @@ const s3Host = process.env.S3_ENDPOINT
? new URL(process.env.S3_ENDPOINT).hostname
: "";
// Derive the Clerk frontend API host from the publishable key so the
// CSP allowlist always matches the active environment. Clerk supports
// two publishable key formats:
// Derive the Clerk frontend API host so the CSP allowlist always matches the
// active environment. When a custom Clerk frontend API domain is configured in
// the Clerk dashboard (e.g. clerk.testbed.mk), it is resolved at runtime by
// clerk-js and can NOT be derived from the publishable key — so it must be
// provided explicitly via NEXT_PUBLIC_CLERK_FAPI_HOST. Otherwise Clerk
// supports two publishable key formats:
//
// 1) "Encoded" form (older): pk_test_<base64slug>$
// The base64 portion decodes to "<slug>.clerk.accounts.dev" (test)
@ -17,6 +20,16 @@ const s3Host = process.env.S3_ENDPOINT
// The slug may itself contain hyphens and digits, so only the
// final dash-group is captured as the suffix.
function clerkFrontendApiHost(): string | null {
const custom = process.env.NEXT_PUBLIC_CLERK_FAPI_HOST?.trim();
if (custom) {
try {
const host = new URL(custom.includes("://") ? custom : `https://${custom}`).hostname;
if (host) return host;
} catch {
// fall through to derivation from the publishable key
}
}
const key = process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY;
if (!key) return null;

View File

@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-require-imports */
const { PrismaClient, Role } = require("@prisma/client");
const prisma = new PrismaClient();

View File

@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-require-imports */
const { S3Client, PutBucketPolicyCommand } = require("@aws-sdk/client-s3");
const client = new S3Client({

View File

@ -7,6 +7,7 @@ echo "=== SpomeniQR Starting ==="
echo "DATABASE_URL: ${DATABASE_URL:+set}"
echo "CLERK_SECRET_KEY: ${CLERK_SECRET_KEY:+set}"
echo "S3_ENDPOINT: ${S3_ENDPOINT:+set}"
echo "ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET:+set}"
echo "Running Prisma migrations..."
if ! npx prisma migrate deploy; then

View File

@ -1,4 +1,5 @@
import type { Metadata } from "next";
import Link from "next/link";
export const metadata: Metadata = {
title: "СпоменQR — Во спомен на",
@ -10,12 +11,12 @@ export default function NotFound() {
<div className="flex min-h-screen flex-col items-center justify-center px-6">
<h1 className="text-4xl font-bold text-stone-900">Спомен страницата не е пронајдена</h1>
<p className="mt-4 text-stone-600">Оваа спомен страница не постои или сè уште не е објавена.</p>
<a
<Link
href="/"
className="mt-8 rounded-lg bg-primary px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-light"
>
Почетна
</a>
</Link>
</div>
);
}