Skip to content

Self-hosting with Docker

Run a dedicated LicenseMeter instance with Docker, PostgreSQL and your own credentials.

This creates a new, dedicated installation. It does not copy the hosted service’s data, secrets, or app registrations.

Install Docker Engine with Compose v2 or newer, or Docker Desktop. Allow about 4 GB of memory for the initial build; operational requirements depend on tenant size and count.

git clone https://github.com/ugurkocde/licensemeter-website.git
cd licensemeter-website
node scripts/setup-docker.mjs
docker compose --env-file .env.docker up --build -d
docker compose --env-file .env.docker ps

The helper needs Node.js 24. Without it, copy .env.docker.example to .env.docker and replace each __GENERATE_SECRET__ with a different openssl rand -hex 32 result. Keep database passwords hexadecimal because they are embedded in connection URLs. Restrict the file to its owner (chmod 600 .env.docker).

Open http://localhost:3000 and select Open the sample tenant. Use localhost for the local browser demo. Production cookies remain Secure; their protection is not disabled to accommodate plain HTTP on a remote host.

Always specify --env-file .env.docker; the normal .env may contain unrelated development settings. The helper never overwrites an existing file. Retain its secrets through upgrades.

ServiceResponsibility
dbPostgreSQL 17, stored in the postgres_data named volume
migrateApplies versioned SQL once with the database owner’s connection, then exits
webNon-root Next.js server using the limited licensemeter_app login
schedulerCalls authenticated background-job routes on the internal network

The web service waits for successful migrations. Restarts do not run destructive schema pushes. The initial migration requires an empty dedicated database. PostgreSQL has no published port, and the web port binds to 127.0.0.1 by default. Build layers exclude secrets, Git history, private notes, and database files.

  1. Put a TLS reverse proxy in front of the web service.
  2. Set APP_BASE_URL=https://licenses.example.com, without a path.
  3. Set DEMO_MODE=false before exposing the service to users.
  4. Create your own Microsoft registrations as described in Microsoft application setup.
  5. Set AUTH_MICROSOFT_ENTRA_ID_ID, AUTH_MICROSOFT_ENTRA_ID_SECRET, CONNECTOR_CLIENT_ID, and CONNECTOR_CLIENT_SECRET in .env.docker.
  6. Apply the changed environment with docker compose --env-file .env.docker up -d.
  7. Sign in and open Connectors to complete consent.

Sign-in is Microsoft Entra ID and needs no other identity service. Do not reuse LicenseMeter’s hosted client IDs or credentials.

Keep the app port private. The trusted proxy must overwrite Host, X-Forwarded-Host, X-Forwarded-Proto, and X-Real-IP. Rate limiting uses X-Real-IP; forward the connecting client’s address, not a header they supplied.

For Nginx on the same host, inside an existing TLS virtual host:

location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_buffering off;
proxy_read_timeout 600s;
client_max_body_size 8m;
}

A containerized proxy should join the web service’s network. Do not publish PostgreSQL. Manage TLS certificates through your usual process. Startup rejects non-local HTTP origins.

  • Email requires your own RESEND_API_KEY and verified EMAIL_FROM sender.
  • The support form requires your SUPPORT_TO_EMAIL. Self-hosted requests are not sent to the hosted support mailbox by default. Optionally set both Turnstile keys for your hostname.
  • Crisp is disabled unless you configure your own CRISP_WEBSITE_ID.
  • Operational alerts optionally use ALERT_EMAIL and/or ALERT_WEBHOOK_URL. Configure destinations you control.
  • Vercel Analytics is not enabled by the Docker setup.

Optional. Without it LicenseMeter only knows that Resend accepted a message, and Settings says that delivery tracking is not available. With it, owners and admins see under Settings, Email delivery whether the digest, the report and leak alerts arrived, and the instance stops mailing addresses that bounce permanently, are suppressed by Resend, or report an email as spam.

  1. In Resend, add a webhook with the endpoint URL https://<your-app-host>/api/webhooks/resend. The URL must be reachable from the internet over HTTPS.
  2. Subscribe it to these seven events: email.sent, email.delivered, email.delivery_delayed, email.failed, email.bounced, email.suppressed, email.complained.
  3. Copy the webhook’s signing secret (it starts with whsec_) into RESEND_WEBHOOK_SECRET in .env.docker.
  4. Apply it with docker compose --env-file .env.docker up -d.

The route verifies the signature of every request and answers 503 while the secret is unset. Treat the secret like a password. Blocked addresses are kept per workspace and are deleted with the workspace. There is no unblock control yet, so once an address works again, remove it from Resend’s suppression list and delete its row from email_blocks.

Marketing/legal content, contact details, and the external provider-status page describe licensemeter.com. Adapt them to your organization and infrastructure before presenting them as your policies. The self-hosted robots.txt asks crawlers to avoid the instance; this is not access control.

Schedules use UTC, matching vercel.json:

JobSchedule
Directory and connector syncDaily 03:00
Digest emailMonday 06:00
Monthly reportDay 1 of the month, 07:00

Run one scheduler. It does not backfill missed times or retry ambiguous email requests. Restarting within a scheduled minute may invoke that job again; avoid restarting during email windows. Inspect scheduler logs after operational changes. You can replace it with an external scheduler calling the same routes with Authorization: Bearer <CRON_SECRET>; disable the bundled service to avoid duplicate scheduling.

Back up PostgreSQL and .env.docker, especially DATA_ENCRYPTION_KEY. Without the original key, restored connector credentials cannot be decrypted. Changing environment passwords alone does not rotate passwords in an existing PostgreSQL volume.

mkdir -p backups
chmod 700 backups
docker compose --env-file .env.docker exec -T db \
pg_dump -U postgres -d licensemeter -Fc > backups/licensemeter.dump
chmod 600 backups/licensemeter.dump

Store backups outside Git and test restoration in an isolated installation. Use pg_restore against a dedicated empty database and restore the matching environment keys before starting the app. Retention and backup access controls are the operator’s responsibility.

docker compose down preserves data. docker compose down -v permanently deletes the database volume. Do not use -v for routine upgrades or troubleshooting.

Read the changes, take a verified database/environment backup, then:

docker compose --env-file .env.docker stop web scheduler
git pull --ff-only
docker compose --env-file .env.docker build
docker compose --env-file .env.docker up -d --wait

Migrations run before the web service. If one fails, inspect its logs before retrying. Do not bypass the dependency check or run drizzle-kit push --force on your data. Rollback can require restoring the matching database backup, not merely an older image.

SymptomCheck
Web waits for migrationInspect docker compose --env-file .env.docker logs migrate
Login does not persistLocal demo: use localhost. Remote instance: verify HTTPS and proxy headers
No real sign-in buttonConfigure both Microsoft sign-in variables and recreate web
Wrong consent redirectCorrect APP_BASE_URL and registered redirect URIs
Support unavailableSet your destination mailbox, sender, and matching Turnstile keys
Shared rate limitsMake the trusted proxy overwrite X-Real-IP
Stale dataCheck scheduler logs and /api/health

Start with docker compose --env-file .env.docker logs --tail 100 web migrate scheduler. Redact tenant data and credentials before sharing logs.

References: Next.js self-hosting, standalone output, Compose startup dependencies.