Deploying PopChoice with Coolify
This project is ready to run on a VPS with Coolify using coolify.compose.yml.
The stack runs PostgreSQL with pgvector, Redis, the Next.js web app, BullMQ
workers, Bull Board, and optional catalog/data tools. PopChoice application
containers are pulled from GitHub Container Registry instead of being built on
the VPS.
Recommended VPS shape
Start with at least:
- 2 vCPU
- 4 GB RAM
- 40 GB disk
The app will run on smaller boxes, but builds for the Node monorepo are much less pleasant on 2 GB RAM.
Coolify resource
- Create a new Coolify project on the VPS.
- Add a new resource from this Git repository.
- Choose Docker Compose.
- Set the compose file path to
coolify.compose.yml. - Assign a domain to the
webservice. Because the container listens on port3000, set the service domain ashttps://your-domain.example:3000. - Optional: assign a private/admin domain to
bull-board, also with port3000. SetOPERATOR_AUTH_USERNAMEandOPERATOR_AUTH_PASSWORDwhen you want the shared operator login prompt. - Optional: assign a private/admin domain to
backoffice, also with port3000. The backoffice is a separate service likebull-board, not a route insideweb, and uses the same operator auth variables. - Optional: assign an internal/design-review domain to
storybook, with port80. The Storybook service is static nginx output and does not need app secrets, Postgres, Redis, OpenAI, or TMDB credentials.
Coolify treats Docker Compose as the source of truth for services, environment variables, and storage. The compose file declares named volumes for PostgreSQL and Redis so data survives redeploys.
The compose file is intentionally runtime-only for PopChoice services. It pulls prebuilt images using:
APP_IMAGE_PREFIX=ghcr.io/shchilkin/popchoice
IMAGE_TAG=developmentUse IMAGE_TAG=development for simple auto-deploys from the latest successful
development build. Use IMAGE_TAG=sha-<12-char-github-sha> when you want to
promote or roll back an exact immutable release. Keep the same IMAGE_TAG for
web, workers, bull-board, storybook, docs, backoffice,
movie-seed, movie-discovery, and movie-backfill; running mixed commits is
intentionally not supported.
If the GHCR packages are private, add registry credentials in Coolify so the VPS
can pull ghcr.io/shchilkin/popchoice/*. Public packages do not need registry
credentials.
Required variables
Set these in Coolify before the first deploy:
OPENAI_API_KEY=...
POSTGRES_PASSWORD=...
AUTH_SESSION_SECRET=...
NEXT_PUBLIC_BASE_URL=https://your-domain.example
IMAGE_TAG=developmentFor Docker Compose resources, these must exist in the resource's Environment Variables page. If you keep the real values as Coolify shared variables, add resource variables that reference them:
OPENAI_API_KEY={{ environment.OPENAI_API_KEY }}
POSTGRES_PASSWORD={{ environment.POSTGRES_PASSWORD }}
AUTH_SESSION_SECRET={{ environment.AUTH_SESSION_SECRET }}
NEXT_PUBLIC_BASE_URL=https://your-domain.example
IMAGE_TAG=developmentUse {{ project.NAME }} instead of {{ environment.NAME }} if the shared
variable is stored at project scope. A shared variable existing on the project
or environment is not enough by itself; it must be referenced by the Compose
resource so Coolify writes it into the generated .env file. The PostgreSQL
password is required by Compose and by PostgreSQL on first database
initialization, so a missing value now fails before containers are created.
Recommended optional variables:
APP_IMAGE_PREFIX=ghcr.io/shchilkin/popchoice
POSTGRES_USER=popchoice
POSTGRES_DB=popchoice
TMDB_API_KEY=...
LOG_LEVEL=info
API_KEY_HMAC_SECRET=...
VALID_API_KEYS=...
RESEND_API_KEY=...
EMAIL_FROM=PopChoice <noreply@mail.your-domain.example>
EMAIL_REPLY_TO=support@your-domain.example
OPERATOR_AUTH_USERNAME=...
OPERATOR_AUTH_PASSWORD=...
OPERATOR_AUTH_REALM=PopChoice OperatorsSet NEXT_PUBLIC_BASE_URL to the URL users see in their browser, without a
trailing slash. Even though the Coolify domain field includes :3000 for proxy
routing, the app's public URL is usually just https://your-domain.example.
API key variables
VALID_API_KEYS is not a plaintext key. It must contain one or more
comma-separated scrypt digests generated with API_KEY_HMAC_SECRET.
Generate a production API key and digest locally:
API_KEY_HMAC_SECRET="$(openssl rand -hex 32)" node -e "const {randomBytes,scryptSync}=require('crypto'); const secret=process.env.API_KEY_HMAC_SECRET; const key=randomBytes(32).toString('hex'); const hash=scryptSync(key, secret, 32, {N:16384,r:8,p:1}).toString('hex'); console.log('API_KEY_HMAC_SECRET='+secret); console.log('PLAINTEXT_API_KEY='+key); console.log('VALID_API_KEYS='+hash)"Store only these values in Coolify:
API_KEY_HMAC_SECRET=<API_KEY_HMAC_SECRET output>
VALID_API_KEYS=<VALID_API_KEYS output>Keep PLAINTEXT_API_KEY outside Coolify in a password manager or secret store.
External callers send that plaintext value as Authorization: Bearer <key> or
X-API-Key: <key>.
If you store these as project shared variables, reference them from the Docker Compose resource environment:
API_KEY_HMAC_SECRET={{ project.API_KEY_HMAC_SECRET }}
VALID_API_KEYS={{ project.VALID_API_KEYS }}Password reset email
Password reset requests use Resend in production. Create a Resend API key and
verify a sending domain such as mail.your-domain.example, then set these
variables on the Coolify Compose resource:
RESEND_API_KEY={{ project.RESEND_API_KEY }}
EMAIL_FROM=PopChoice <noreply@mail.your-domain.example>
EMAIL_REPLY_TO=support@your-domain.exampleEMAIL_REPLY_TO is optional. In local development and previews, the app exposes
the reset URL after a successful forgot-password request so the flow can be
tested without sending real mail. In production, the URL is sent only by email.
Operator surfaces
Bull Board and Backoffice are operational/admin surfaces. They use shared Basic Auth that is separate from the normal user-facing app login. Before assigning a public or admin domain to either service, set these variables on the Coolify Compose resource:
OPERATOR_AUTH_USERNAME=<operator username>
OPERATOR_AUTH_PASSWORD=<long random password>
OPERATOR_AUTH_REALM=PopChoice OperatorsOPERATOR_AUTH_REQUIRED defaults to 0 for the Coolify operator services so
the pet-project deployment does not break while the full RBAC model is still
being built. If the username and password are missing, the operator apps start
with a warning and no operator login. Set OPERATOR_AUTH_REQUIRED=1 when you
want fail-closed behavior. Leave the Compose health checks on /healthz; that
endpoint intentionally bypasses the login prompt so Coolify can verify
containers without storing operator credentials in health checks. Protected
operator routes are rate-limited in-process to slow repeated login attempts. In
Bull Board and the backoffice, successful requests are skipped by the limiter;
tune OPERATOR_AUTH_RATE_LIMIT_MAX and
OPERATOR_AUTH_RATE_LIMIT_WINDOW_SECONDS if manual testing needs a wider
window.
Version and build metadata
PopChoice exposes non-secret build metadata at:
/api/buildThe browser console also installs a small helper:
PopChoice.version;
PopChoice.commit;
await PopChoice.info();Set these optional variables on the Coolify Compose resource:
APP_VERSION=0.1.0-beta.0
APP_CHANNEL=beta
APP_COMMIT_SHA=<current git commit sha>
APP_GIT_BRANCH=development
APP_PR_NUMBER=<preview PR number, optional>
APP_IMAGE_REPOSITORY=ghcr.io/shchilkin/popchoice/web
APP_IMAGE_TAG=<image tag, optional>
APP_IMAGE_DIGEST=sha256:<image digest, optional>
SOURCE_COMMIT=<PR head commit, optional>
SOURCE_BRANCH=<PR branch, optional>If your Coolify version can include the source commit in the build/deploy
environment, enable that option and map the exposed commit value to
APP_COMMIT_SHA. If it is not set, the app reports the commit as unknown
instead of guessing.
For Docker Compose deployments, also enable Coolify's
Include Source Commit in Build option if it is available. The web image
persists non-secret build arguments as fallback metadata, so /api/build can
still report the commit when Coolify does not pass source metadata as runtime
environment variables.
When deploying a prebuilt GitHub Container Registry image, prefer runtime metadata from the image workflow artifact over Coolify source metadata:
- Use
APP_COMMIT_SHAfor the GitHub Actions checkout commit that produced the image. - Use
SOURCE_COMMITandSOURCE_BRANCHfor the PR head commit and branch. - Use
APP_IMAGE_DIGESTfor the digest-pinned image reference being deployed.
This lets /api/build connect the running preview back to the PR check and the
exact image digest.
First deploy
Make sure the container-images.yml workflow has already published the selected
IMAGE_TAG, then deploy the stack from Coolify. The startup order is:
- PostgreSQL and Redis start and pass health checks.
webapplies every SQL file indb/init/withstart:with-migrations, then starts Next.js.workersandbull-boardstart against the same internal Redis and PostgreSQL services.backoffice,docs, andstorybookstart as independent operator/docs UI surfaces.
After deployment, set the Coolify health check path for the web service to:
/api/healthThe endpoint returns 200 only when the Next.js app can reach both PostgreSQL
and Redis. It returns a sanitized 503 response when either dependency is not
available.
The Compose file injects Coolify's SERVICE_NAME_DB and SERVICE_NAME_REDIS
variables into the application containers, with local fallbacks to build
internal connection URLs. This matters for preview deployments because Coolify
can vary service names per preview stack; plain Docker Compose still falls back
to db and redis.
Post-deploy smoke checklist
Run this checklist after production deploys and after any infrastructure change:
https://your-domain.exampleloads with a trusted certificate.https://docs.your-domain.example/docsloads the documentation site if thedocsservice is enabled.https://your-domain.example/api/healthreturns200.https://your-domain.example/api/buildshows the expected beta version and commit hash.https://storybook.your-domain.exampleloads the component workshop if thestorybookservice is enabled.- A quiz submission creates and completes a recommendation.
- Worker logs show Redis readiness and recommendation job completion.
- The latest PostgreSQL backup exists in the configured backup destination.
Optional observability stack
For searchable Docker/Coolify logs, run the separate Loki, Alloy, and Grafana stack documented in OBSERVABILITY-LOGS.md. Keep it as a separate Coolify Docker Compose resource so PopChoice containers do not depend on Loki availability at runtime.
For external uptime and cheap synthetic monitoring, run Uptime Kuma as a separate observability stack and configure the monitors in Uptime Kuma Monitoring. Keep those checks read-only by default so production monitoring does not spend AI-provider credits.
For Prometheus metrics and the initial Grafana dashboard, enable the app metrics endpoints on the PopChoice resource:
METRICS_ENABLED=true
METRICS_BEARER_TOKEN=<long-random-token>
TRACING_ENABLED=true
TRACING_SAMPLE_RATE=0.05
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://observability-otel-collector:4318/v1/tracesGrafana also provisions Tempo traces plus conservative alert rules and runbooks for app, DB, Redis, queue, provider, disk, and monitoring-stack incidents. Keep the observability config in Git as the restore source of truth, and follow Observability Traces, Observability Alerts, and Observability Runbooks when wiring notification contact points or testing restore.
Then run the separate observability stack documented in
OBSERVABILITY-METRICS.md. The stack listens locally
on 127.0.0.1:9090 for Prometheus and 127.0.0.1:3001 for Grafana. Set
POPCHOICE_APP_NETWORK if Coolify names the app resource network differently
from popchoice_default, and set the Postgres exporter credentials with
POSTGRES_EXPORTER_DATA_SOURCE_URI, POSTGRES_EXPORTER_DATA_SOURCE_USER, and
POSTGRES_EXPORTER_DATA_SOURCE_PASS. Because the observability stack is a
separate Coolify resource, duplicate the app database password into
POSTGRES_EXPORTER_DATA_SOURCE_PASS; it is not inherited automatically from the
PopChoice app resource.
Automatic redeploys
For a simple continuous deployment path:
- Set
IMAGE_TAG=developmenton the Coolify Compose resource. - Add the repository secret
COOLIFY_DEPLOY_WEBHOOKwith the production Coolify deploy webhook URL. - Add the repository secret
COOLIFY_TOKENwith a Coolify API token that has deploy permission. - Optional but recommended: add
POPCHOICE_PRODUCTION_BASE_URL, for examplehttps://pop-choice.shchilkin.dev, so GitHub Actions can verify/api/healthand/api/buildafter the webhook runs. - Optional if Grafana is reachable from GitHub Actions: add
GRAFANA_URLandGRAFANA_SERVICE_ACCOUNT_TOKENso the deploy job can create a short silence for deploy-sensitive alerts before triggering Coolify. - Merge to
development.
GitHub Actions builds and publishes every PopChoice runtime image first. Only
after the full image matrix succeeds does the workflow call the deploy webhook.
The webhook call is an authenticated GET request using
Authorization: Bearer ${COOLIFY_TOKEN}. Coolify then pulls the already-built
development images and restarts the stack.
Normal redeploys may briefly stop or replace the web container before
Prometheus can scrape web:3000 again. That alert is now a P2
deploy-sensitive visibility signal. When Grafana secrets are present, the
workflow creates a short silence for noise_profile=deploy-sensitive before
the webhook. When POPCHOICE_PRODUCTION_BASE_URL is present, the workflow then
polls public /api/health and /api/build and fails if production does not
recover in time.
To smoke-test the same path without a new merge, manually run the
Container Images workflow on the development branch. Manual runs rebuild and
republish the same image set, update the development image tag, then call the
Coolify deploy webhook after the matrix succeeds.
For stricter release promotion, keep automatic deployment disabled, copy the
sha-<12-char-github-sha> tag from the workflow run, set IMAGE_TAG to that
exact tag in Coolify, and redeploy manually. That gives better rollback and
auditability at the cost of one manual promotion step.
Do not point Coolify at source builds for production while also using GHCR images. Choose one deployment source of truth; the recommended production path is GHCR images plus this compose file.
Documentation service
The docs service deploys the Fumadocs site from the same GHCR image bundle as
the application runtime:
APP_IMAGE_PREFIX=ghcr.io/<owner>/<repo>
IMAGE_TAG=developmentPoint a separate public domain, such as docs.your-domain.example, at the
docs service in Coolify. The service listens on port 3000 and redirects /
to /docs. It does not need application secrets, Postgres, Redis, OpenAI, or
TMDB credentials.
Keep the docs service on the same IMAGE_TAG as the app services. This makes
the deployed documentation describe the same commit as the running app and keeps
rollback behavior predictable.
Storybook service
The storybook service deploys the static Storybook build from the same GHCR
image bundle as the application runtime. Point a separate internal or
design-review domain, such as storybook.your-domain.example, at the
storybook service in Coolify and use port 80.
The service is static nginx output. It does not need application secrets,
Postgres, Redis, OpenAI, or TMDB credentials. Keep it on the same IMAGE_TAG as
the app services so component docs match the deployed code.
Pull request previews
Use Coolify's GitHub App integration for PR previews so deployments can be created from pull requests and reported back to GitHub.
-
Add a wildcard DNS record pointing at the VPS:
*.preview.your-domain.example A <VPS_PUBLIC_IP> -
Enable preview deployments for repository members, collaborators, and contributors only. Keep public PR preview deployments disabled.
-
Use this preview URL template:
https://{{pr_id}}.preview.your-domain.example:3000 -
Keep previews as full isolated stacks. Each PR should get its own
web,workers,db,redis, and named volumes. -
Configure preview environment variables separately from production if your Coolify version exposes preview overrides. Use limited-quota
OPENAI_API_KEYandTMDB_API_KEYvalues when possible, and generate preview-only values forPOSTGRES_PASSWORD,AUTH_SESSION_SECRET,API_KEY_HMAC_SECRET, andVALID_API_KEYS. If your Coolify version uses the same environment variable list for production and previews, make sure the production-safe shared-variable references above resolve for previews too. -
Set preview
NEXT_PUBLIC_BASE_URLfrom Coolify's generated web service URL for port3000. -
Leave
bull-boardwithout a preview domain unless temporarily debugging a PR. -
Do not set
COMPOSE_PROFILES=toolsglobally. It enables the profiledmovie-seedservice during every production and preview deploy. Run seeding manually or as a Coolify scheduled task instead.
Before relying on previews, verify that opening a PR creates a preview deployment and GitHub comment, the preview URL loads over HTTPS, quiz submission completes without touching production data, and closing or merging the PR removes the preview deployment.
Prebuilt PR preview images
GitHub Actions builds production images in
.github/workflows/container-images.yml and publishes them to GHCR:
ghcr.io/shchilkin/popchoice/web
ghcr.io/shchilkin/popchoice/workers
ghcr.io/shchilkin/popchoice/bull-board
ghcr.io/shchilkin/popchoice/storybook
ghcr.io/shchilkin/popchoice/docs
ghcr.io/shchilkin/popchoice/db-migrate
ghcr.io/shchilkin/popchoice/movie-seed
ghcr.io/shchilkin/popchoice/movie-discovery
ghcr.io/shchilkin/popchoice/movie-backfillFor same-repository PRs, the workflow tags each image as both
sha-<12-char-github-sha> and pr-<number>, then uploads
container-image-<role> artifacts with digest-pinned references. Use the
digest-pinned references for Coolify previews whenever possible:
ghcr.io/shchilkin/popchoice/web@sha256:<digest>
ghcr.io/shchilkin/popchoice/workers@sha256:<digest>
ghcr.io/shchilkin/popchoice/bull-board@sha256:<digest>
ghcr.io/shchilkin/popchoice/storybook@sha256:<digest>The preview stack should pull those images and run them with the same commands,
environment variables, health checks, PostgreSQL, and Redis dependencies shown
in coolify.compose.yml. Do not let the preview rebuild Dockerfiles when a
digest-pinned PR image exists; the reviewed artifact is already built and
published by CI.
Set the web container's non-secret provenance variables from the image artifact:
APP_COMMIT_SHA=<artifact commitSha>
APP_GIT_BRANCH=<github ref name>
APP_PR_NUMBER=<artifact pullRequestNumber>
APP_IMAGE_REPOSITORY=<artifact repository>
APP_IMAGE_TAG=<artifact tag>
APP_IMAGE_DIGEST=<artifact digest>
SOURCE_COMMIT=<artifact sourceCommitSha>
SOURCE_BRANCH=<artifact sourceRef>After the preview starts, verify /api/build reports the expected PR number,
commit, image repository, image tag, and image digest. If the digest does not
match the image reference configured in Coolify, redeploy the preview from the
current workflow artifact before testing or promotion.
Preview domain and certificate notes
Coolify preview URLs need to match the wildcard certificate shape. If DNS and certificates are configured for:
*.preview.your-domain.examplethen use preview hostnames with only one dynamic label before preview, for
example:
https://450.preview.your-domain.example:3000
https://450-random.preview.your-domain.example:3000Avoid hostnames such as:
https://450.random.preview.your-domain.example:3000because a single-label wildcard certificate for *.preview.your-domain.example
does not cover the extra 450.random nesting. Browsers may show an untrusted
certificate warning even though production is healthy.
For the public app URL used by the browser, prefer the generated web service URL
without manually inventing extra subdomain levels. Do not expose db, redis,
or workers with public domains.
If a preview database fails with:
Database is uninitialized and superuser password is not specifiedthen POSTGRES_PASSWORD did not reach the preview stack. Check the Compose
resource's environment variables, not only shared variables, and delete the
failed preview stack before redeploying so PostgreSQL initializes from a clean
volume with the password present.
If web repeatedly logs getaddrinfo EAI_AGAIN db while the preview
PostgreSQL container is healthy, the app is trying to resolve the plain Compose
service name instead of Coolify's preview-specific service name. Verify the
preview has picked up the latest compose file and that DATABASE_URL resolves
through SERVICE_NAME_DB. Inside the preview container, SERVICE_NAME_DB
should be set to a value like db-pr-400, and SERVICE_NAME_REDIS should be
set to a value like redis-pr-400.
If a preview API route fails with a PostgreSQL error such as:
column "poster_url" does not existthe preview stack is running newer code against an older preview database volume. Force a redeploy after migrations are present, or delete/recreate the preview deployment if the volume was initialized before the schema change. This is common for long-lived PR previews because Coolify correctly preserves their PostgreSQL volumes across redeploys.
For metadata-dependent features such as movie memory, a successful schema migration is only the first step. Posters and localized titles also require seed or backfill data. Missing posters should be treated as catalog-data drift, not as a frontend-only bug.
Seeding movie data
The database schema is created automatically by the web service, but movie rows still need to be seeded. After the first successful web deploy, run this from a Coolify terminal or an SSH shell on the VPS:
docker compose --profile tools -f coolify.compose.yml run --rm movie-seedThe same tools profile also exposes movie-backfill from the matching
prebuilt image:
docker compose --profile tools -f coolify.compose.yml run --rm movie-backfillIf you prefer doing this from your laptop against the production database, use the public PostgreSQL connection string and run:
DATABASE_URL=<production-database-url> npm run populate-dbOptional movie discovery service
The compose file includes a profiled movie-discovery service for scheduled
TMDB discovery syncs. Enable the discovery profile only if you want automatic
catalog growth. It requires:
TMDB_API_KEY=...
OPENAI_API_KEY=...
DATABASE_URL=...
SYNC_SCHEDULE=0 0 * * 0The service runs once immediately on startup and then follows SYNC_SCHEDULE,
so enable it deliberately to avoid surprise API usage.
Backups
Configure Coolify backups for the PostgreSQL volume before relying on this in production. The application state lives primarily in PostgreSQL; Redis is used for queues and rate limiting.
Store backups in S3-compatible storage rather than only on the VPS. Enable
Coolify notifications for failed deploys, failed backups, and server disk or
resource warnings. On the Hetzner firewall, expose only the public ports needed
by this setup: 22, 80, and 443; do not expose PostgreSQL or Redis.