Self-host the LMS on Dokploy (open-source PaaS built on Docker + Traefik) with wildcard subdomain routing for multi-tenancy.
- VPS/dedicated server (Ubuntu 22.04+, 4GB RAM min with Supabase Cloud, 8GB+ if self-hosting Supabase)
- Domain name (e.g.,
lmsplatform.com) - Cloudflare account (free tier) for DNS + wildcard SSL
- Dokploy installed on the server
curl -sSL https://dokploy.com/install.sh | shAccess the dashboard at http://your-server-ip:3000.
We recommend Cloudflare (free tier) for DNS because Traefik's wildcard SSL requires DNS-01 challenge, and Cloudflare has first-class integration. If you're currently on GoDaddy or another registrar, migrate your nameservers to Cloudflare:
- Create a free account at dash.cloudflare.com
- Add your domain — Cloudflare will scan existing DNS records
- Cloudflare gives you two nameservers (e.g.,
karina.ns.cloudflare.com,karl.ns.cloudflare.com) - Update nameservers at your registrar (e.g., GoDaddy > My Domains > DNS > Nameservers > Custom) to point to Cloudflare's nameservers
- Wait for propagation (usually 5-30 minutes)
Once active, configure these DNS records:
| Type | Name | Value | Proxy |
|---|---|---|---|
| A | @ |
your-server-ip |
DNS only (grey) |
| A | * |
your-server-ip |
DNS only (grey) |
| CNAME | www |
lmsplatform.com |
DNS only (grey) |
Critical: Both @ and * records MUST be DNS only (grey cloud) so Traefik handles SSL directly. If proxied through Cloudflare, the wildcard cert won't work with Traefik's DNS challenge. You can enable Cloudflare proxy on the @ record later once everything is working.
Clean up old records: If you migrated from another provider, delete any leftover A records pointing to old IPs (e.g., GoDaddy forwarding IPs). You should have exactly ONE A record per name.
- Go to dash.cloudflare.com/profile/api-tokens
- Click "Create Token"
- Use the "Edit zone DNS" template
- Zone Resources: Include > Specific zone > your domain
- Click Continue to summary > Create Token
- Copy and save the token — you'll need it for Traefik configuration
Security: Never share this token publicly. If compromised, rotate it immediately from the same page.
This is what makes multi-tenant subdomains work with HTTPS.
In Dokploy: Web Server (left sidebar) > scroll to Web Server section > click Traefik button > select "Modify Environment" from the dropdown:
CF_DNS_API_TOKEN=your-cloudflare-api-token
Save the environment.
In Dokploy: Traefik File System (left sidebar) > open the traefik.yml file.
Add a letsencrypt-wildcard DNS challenge resolver alongside the existing letsencrypt HTTP challenge resolver. Your full traefik.yml should look like:
global:
sendAnonymousUsage: false
providers:
swarm:
exposedByDefault: false
watch: true
docker:
exposedByDefault: false
watch: true
network: dokploy-network
file:
directory: /etc/dokploy/traefik/dynamic
watch: true
entryPoints:
web:
address: :80
websecure:
address: :443
http3:
advertisedPort: 443
http:
tls:
certResolver: letsencrypt
api:
insecure: true
certificatesResolvers:
letsencrypt:
acme:
email: you@yourdomain.com
storage: /etc/dokploy/traefik/dynamic/acme.json
httpChallenge:
entryPoint: web
letsencrypt-wildcard:
acme:
email: you@yourdomain.com
storage: /etc/dokploy/traefik/dynamic/acme-wildcard.json
dnsChallenge:
provider: cloudflare
resolvers:
- "1.1.1.1:53"
- "8.8.8.8:53"Save the file, then go back to Web Server and click Traefik > Reload to apply changes.
Note: The
letsencryptresolver (HTTP challenge) is used for the root domain. Theletsencrypt-wildcardresolver (DNS challenge via Cloudflare) is used for*.yourdomain.com. Both coexist.
The repo includes a production Dockerfile and .dockerignore. The Next.js config uses output: 'standalone' for self-contained Docker builds.
- Projects > New Project > name it "LMS"
- Add Service > Application
- Source: Git (connect your GitHub repo)
- Build type: Dockerfile
- Dockerfile path:
./Dockerfile
In Dokploy app settings > Environment:
# Supabase
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_OR_ANON_KEY=eyJ...
SUPABASE_SERVICE_ROLE_KEY=eyJ...
# Platform Domain (no protocol, no trailing slash)
NEXT_PUBLIC_PLATFORM_DOMAIN=lmsplatform.com
NEXT_PUBLIC_APP_URL=https://lmsplatform.com
# Stripe — Student Payments (Connect)
STRIPE_SECRET_KEY=sk_live_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
# Stripe — Platform Billing
STRIPE_PLATFORM_WEBHOOK_SECRET=whsec_...
# OpenAI (AI grading)
OPENAI_API_KEY=sk-...
# Email (Mailgun)
MAILGUN_API_KEY=...
MAILGUN_DOMAIN=mg.lmsplatform.com
MAILGUN_API_URL=https://api.mailgun.net
EMAIL_FROM=noreply@lmsplatform.com
# Certificates
CERTIFICATE_ENCRYPTION_KEY=your-32-char-key
CERTIFICATE_ISSUER_NAME=Your Platform Name
# Company
COMPANY_NAME=Your Company
COMPANY_EMAIL=hello@lmsplatform.com
# Cron
CRON_SECRET=your-random-secret-hereIn the app's Domains tab:
Domain 1 — Platform root:
In the app's Domains tab, click Add Domain:
- Host:
lmsplatform.com - Port:
3000 - HTTPS: Yes
- Certificate Provider:
Let's Encrypt(standard HTTP challenge)
Domain 2 — Wildcard subdomains:
Important: Do NOT add
*.lmsplatform.comas a regular domain in Dokploy — Traefik does not support literal*inHost()rules and will error with:"*.lmsplatform.com" is not a valid hostname. You must useHostRegexpvia the Advanced config.
In the app's Advanced tab, find the Traefik dynamic configuration. You'll see the existing router config for your root domain. Add wildcard routers alongside it.
Here's what the full Advanced config should look like (replace YOUR-SERVICE-NAME with the Dokploy-generated service name visible in the existing config, and YOUR-APP-CONTAINER with the container hostname):
http:
routers:
# --- Root domain (auto-generated by Dokploy) ---
your-app-router:
rule: Host(`lmsplatform.com`)
service: YOUR-SERVICE-NAME
middlewares:
- redirect-to-https
entryPoints:
- web
your-app-router-websecure:
rule: Host(`lmsplatform.com`)
service: YOUR-SERVICE-NAME
middlewares: []
entryPoints:
- websecure
tls:
certResolver: letsencrypt
# --- Wildcard subdomains (add these manually) ---
lms-wildcard:
rule: HostRegexp(`^[a-z0-9-]+\.lmsplatform\.com$`)
service: YOUR-SERVICE-NAME
middlewares:
- redirect-to-https
entryPoints:
- web
lms-wildcard-secure:
rule: HostRegexp(`^[a-z0-9-]+\.lmsplatform\.com$`)
service: YOUR-SERVICE-NAME
middlewares: []
entryPoints:
- websecure
tls:
certResolver: letsencrypt-wildcard
domains:
- main: lmsplatform.com
sans:
- "*.lmsplatform.com"
services:
YOUR-SERVICE-NAME:
loadBalancer:
servers:
- url: http://YOUR-APP-CONTAINER:3000
passHostHeader: trueHow to find YOUR-SERVICE-NAME and YOUR-APP-CONTAINER: Look at the existing config in the Advanced tab — Dokploy auto-generates these when you add the root domain. They follow the pattern project-name-hash-service-N (e.g., guille-personal-lms-lj9e61-service-15).
After saving, redeploy the app. Allow 1-2 minutes for Let's Encrypt to issue the wildcard certificate via DNS challenge.
vercel.json declares these schedules, but Dokploy does not read vercel.json —
something on this side has to call the routes or they never run at all. Every
/api/cron/* route authenticates with Authorization: Bearer $CRON_SECRET
(§3.3), so any scheduler that can issue an HTTP GET will do.
Pick exactly one of the three mechanisms below. Running two means every route fires twice; the routes are written to tolerate that, but it doubles the load and makes logs hard to read.
.github/workflows/cron.yml runs all seven schedules. It needs two repository
settings under Settings → Secrets and variables → Actions:
| Kind | Name | Value |
|---|---|---|
| Secret | CRON_SECRET |
Same value as the CRON_SECRET env var on the Dokploy app |
| Variable | CRON_BASE_URL |
Production origin, e.g. https://lmsplatform.com |
A missing setting fails the run loudly rather than silently no-opping. Verify it end to end with a manual run:
gh workflow run cron.yml -f route=enforce-plan-limits
gh run watchTwo caveats: GitHub delays scheduled runs under load (dropping high-frequency
ones first, so the */10 reconcilers are the most affected), and it disables
scheduled workflows after 60 days with no repository activity. If either matters
for your deployment, use Option B instead.
In the Dokploy dashboard, open the LMS application → Schedules → create one
per line below, shell bash, command:
curl -sS -f -H "Authorization: Bearer $CRON_SECRET" https://lmsplatform.com/api/cron/<route>Most reliable of the three (it runs on the host, on time), but it is invisible to
the repository — nothing in code review will tell you it exists or that it broke.
If you choose this, disable the schedules in .github/workflows/cron.yml.
# crontab -e
# Expire lapsed student subscriptions
0 0 * * * curl -s -H "Authorization: Bearer YOUR_CRON_SECRET" https://lmsplatform.com/api/cron/expire-subscriptions
# Daily digest + streak nudge — must run HOURLY (each tenant sends at its own local hour)
0 * * * * curl -s -H "Authorization: Bearer YOUR_CRON_SECRET" https://lmsplatform.com/api/cron/daily-digest
# Weekly league rollover (Mondays 01:00). pg_cron inside the database also runs it (Mondays 00:05), so this entry is a fallback.
0 1 * * 1 curl -s -H "Authorization: Bearer YOUR_CRON_SECRET" https://lmsplatform.com/api/cron/league-rollover
# Expire lapsed manual-transfer platform (school billing) subscriptions: reminders, grace, downgrade to free. Replaces the retired pg_cron job.
0 2 * * * curl -s -H "Authorization: Bearer YOUR_CRON_SECRET" https://lmsplatform.com/api/cron/expire-platform-subscriptions
# Reconcile access cutoffs for tenants that grew past their plan limits with no plan-change event
0 3 * * * curl -s -H "Authorization: Bearer YOUR_CRON_SECRET" https://lmsplatform.com/api/cron/enforce-plan-limits
# Confirm on-chain Solana payments
*/10 * * * * curl -s -H "Authorization: Bearer YOUR_CRON_SECRET" https://lmsplatform.com/api/cron/solana-reconcile
# Confirm Binance personal-wallet payments
*/10 * * * * curl -s -H "Authorization: Bearer YOUR_CRON_SECRET" https://lmsplatform.com/api/cron/binance-personal-reconcile
/api/cron/solana-pullexists but is deliberately not on any schedule here or invercel.json. It submits on-chain USDC transfers for native Solana subscriptions; enabling it is a separate decision. Run it manually viagh workflow run cron.yml -f route=solana-pullif needed.
Request flow for school.lmsplatform.com:
- DNS: Wildcard
*.lmsplatform.comresolves to your server IP - Traefik: Matches the
HostRegexprule, routes to the Next.js container - proxy.ts: Extracts
schoolfromHostheader, queriestenantstable, setsx-tenant-idheader - App: Components read
x-tenant-idand filter queries bytenant_id
NEXT_PUBLIC_PLATFORM_DOMAIN tells proxy.ts what the root domain is:
lmsplatform.com→ platform root (no tenant)school.lmsplatform.com→ tenant "school"
No code changes are needed — proxy.ts already handles this.
- Create project at supabase.com
- Push migrations:
supabase link --project-ref YOUR_REF supabase db push
- Configure Auth:
- Site URL:
https://lmsplatform.com - Redirect URLs:
https://lmsplatform.com/**,https://*.lmsplatform.com/** - Enable the
custom_access_token_hookfunction under Authentication > Hooks
- Site URL:
- Configure Storage buckets as needed
- Copy API keys to Dokploy env vars
Pros: Zero maintenance, automatic backups, managed Auth/Realtime/Storage. Cons: Monthly cost (~$25/mo Pro plan), data on Supabase infrastructure.
Requires 8GB+ RAM (Supabase alone needs ~3-4GB).
- In Dokploy: Projects > Add Service > Compose > Templates
- Select Supabase template (requires Dokploy v0.22.5+)
- Configure environment variables
- Set domain for Studio (e.g.,
supabase.lmsplatform.com) - Deploy
- Clone:
git clone https://github.com/supabase/supabase --depth 1 - Copy
docker/docker-compose.ymland.env.example - Create as a Compose service in Dokploy
- Configure all env vars
POSTGRES_PASSWORD=your-strong-password
JWT_SECRET=your-jwt-secret-min-32-chars
ANON_KEY=generate-with-supabase-cli
SERVICE_ROLE_KEY=generate-with-supabase-cli
SITE_URL=https://lmsplatform.com
ADDITIONAL_REDIRECT_URLS=https://*.lmsplatform.com/**
API_EXTERNAL_URL=https://api.lmsplatform.com
SUPABASE_PUBLIC_URL=https://api.lmsplatform.com
# SMTP
SMTP_HOST=smtp.mailgun.org
SMTP_PORT=587
SMTP_USER=postmaster@mg.lmsplatform.com
SMTP_PASS=your-mailgun-smtp-password
SMTP_ADMIN_EMAIL=admin@lmsplatform.com
SMTP_SENDER_NAME=LMS Platform| Subdomain | Service | Port |
|---|---|---|
api.lmsplatform.com |
Supabase Kong gateway | 8000 |
supabase.lmsplatform.com |
Supabase Studio | 3000 |
NEXT_PUBLIC_SUPABASE_URL=https://api.lmsplatform.com
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_OR_ANON_KEY=your-generated-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-generated-service-role-keysupabase db push --db-url postgresql://postgres:YOUR_PASSWORD@your-server-ip:5432/postgresThe LMS relies on custom_access_token_hook() for JWT claims:
- Apply all migrations (the hook function is included)
- In Supabase Studio > Authentication > Hooks: enable the custom access token hook pointing to
public.custom_access_token_hook
Pros: Full control, data on your server, lower latency, no Supabase bill. Cons: You manage backups/updates/security, more RAM needed, no auto-scaling.
Create two webhook endpoints in the Stripe Dashboard:
Student Payments (Connect):
- URL:
https://lmsplatform.com/api/stripe/webhook - Events:
payment_intent.succeeded,charge.refunded,payout.paid - Signing secret →
STRIPE_WEBHOOK_SECRET
Platform Billing:
- URL:
https://lmsplatform.com/api/stripe/platform-webhook - Events:
checkout.session.completed,charge.refunded,invoice.payment_failed - Signing secret →
STRIPE_PLATFORM_WEBHOOK_SECRET
After deployment, verify:
-
https://lmsplatform.comloads the platform homepage -
https://school.lmsplatform.comloads a tenant (after creating one) - SSL works on both root and wildcard subdomains (padlock icon)
- Login/signup works (Supabase Auth)
- Creating a new school gives it a subdomain that resolves
- Stripe webhooks receive test events (
stripe trigger payment_intent.succeeded) - Cron job runs daily (
/api/cron/expire-subscriptions) - Email sending works (test signup confirmation)
Symptom: school.lmsplatform.com returns a Traefik 404 page, but lmsplatform.com works.
Causes and fixes:
-
Missing wildcard router: Check that the
lms-wildcardandlms-wildcard-securerouters exist in the Advanced config. Dokploy's regular domain UI does not support wildcards. -
Traefik error
"*.domain.com" is not a valid hostname: You added*.domain.comas a regular domain in Dokploy's Domains tab. Delete it — wildcards must useHostRegexpin the Advanced config. -
DNS not resolving: Run
dig +short school.lmsplatform.com— it should return your server IP. If not, check that the*A record exists in Cloudflare and nameservers have propagated. -
Wildcard cert not issued: Check Traefik logs (Web Server > Traefik > View Logs) for ACME errors. Common causes:
CF_DNS_API_TOKENnot set in Traefik environment- Cloudflare API token doesn't have
DNS:Editpermission on the correct zone letsencrypt-wildcardresolver not defined intraefik.ymlacme-wildcard.jsonstorage path conflict — ensure it's different from the HTTP challenge storage
-
Cloudflare proxy (orange cloud) enabled on wildcard: The
*A record must be DNS only (grey cloud). Cloudflare proxy intercepts TLS and breaks Traefik's DNS challenge.
Symptom: User completes the create-school flow but the subdomain shows "Invalid tenant."
Cause: The create_school RPC requires an authenticated user (auth.uid() must not be null). If the user's session expired or cookies weren't sent, the RPC fails silently on the client.
Fix: Check the browser console for RPC errors. The user should log in again and retry the create-school flow. Alternatively, create the tenant manually via SQL:
-- Create tenant
INSERT INTO tenants (name, slug, status)
VALUES ('School Name', 'school-slug', 'active')
RETURNING id;
-- Add admin user (replace UUIDs)
INSERT INTO tenant_users (tenant_id, user_id, role, status)
VALUES ('tenant-uuid-from-above', 'user-uuid', 'admin', 'active');Traefik auto-renews Let's Encrypt certificates. If renewal fails:
- Check Traefik logs for ACME errors
- Verify the Cloudflare API token hasn't been revoked
- Ensure the
acme-wildcard.jsonfile is writable - Try deleting the
acme-wildcard.jsonfile and reloading Traefik to force re-issuance
Start with Supabase Cloud to avoid managing Postgres backups, Auth configuration, and the custom access token hook. Migrate to self-hosted later once traffic justifies it.
The critical work is in Sections 2-3: getting Traefik wildcard SSL + DNS challenge working. Once that's done, subdomain routing works automatically via proxy.ts.