After reading this
- You will know what each health endpoint checks
- You will know how the scheduler’s advisory locks prevent duplicate work
- You will know how to back up, restore, and verify a restore actually works
- You will know how to upgrade safely
Health and logging
All public probe responses expose only
status and version. With PostgreSQL down, liveness stays 200 and readiness becomes 503.
Every service rotates Docker JSON logs (10 MB x 3 files by default). The Next.js runtime cache is pruned at startup and every six hours, retaining at most 512 MB and seven days by default. Tune these without editing Compose:
Scheduler and queue runbook
The scheduler is a single, restartable worker. It runs the following jobs on fixed intervals using PostgreSQL advisory locks — a second scheduler container or restart receives409 Already running and skips rather than doing duplicate
work:
Queue rows left
processing by a crash become eligible again after five minutes.
Mail deliveries left sending beyond the reconciliation lease become
unknown; they are never resent automatically because SMTP/IMAP may have
accepted the message before the process lost its response. Investigate the
provider outcome before resolving them.
Candidate email retries and attempts
Every claim of anemail_outbox row counts as one attempt, including claims
that end in a crash or a stall:
- A failed attempt is retried with a growing delay (1, 2, 3, then 4 minutes).
- After five attempts the row ends
failedwith its last error. It is not retried again automatically. It stays in Settings → Email → Delivery log, where an operator can Retry it (one new attempt, now) or Cancel it. - A row whose worker stalls is reclaimed while attempts remain. If the fifth
attempt also stalls, the row ends
failedwith an explanatory error instead of looping. - Once the provider has accepted an email, the row is recorded as accepted and
is never sent again, even if the local bookkeeping that follows fails. If the
success record keeps failing, the row is held as
failedwith its provider message ID, so no automatic path or Delivery log action sends it again. - SMTP can accept a message and then stop responding. When that happens the
outcome is unknown. The row ends
failedwith a Delivery outcome unknown note and is not retried automatically. Check the sent folder before retrying it from the Delivery log. - SMTP waits are bounded: 10 seconds to connect, 10 seconds for the greeting, and 20 seconds of socket inactivity.
- Retry records the previous attempt count and error in the row’s payload
(
operatorRetries) before it starts a fresh attempt.
email-outbox job runs for at most 120 seconds: it starts no new row after
that and hands unstarted rows back to the queue. The scheduler’s timeout for the
job is 150 seconds, so a normal run always finishes first. Failed runs record a
short error message with no addresses or query parameters.
Cron run records
cron_runs uses one vocabulary: succeeded, failed, or skipped. The app
records the runs it executes, including the error text of failed runs. The
scheduler records a fallback row only when the app recorded nothing for that
run (for example a 409 skip or an unreachable app). A request that timed out
is not recorded by the scheduler, because the app may still finish and record
its own result. Rows older than 30 days are pruned by the scheduler every six
hours. Metrics count each run once, and older success rows are reported as
succeeded. Only succeeded runs count as a fresh success: a skipped tick
means another run still holds the lock, so it does not refresh a job’s
last-success time in the doctor.
Email warnings in doctor output
The doctor’semail check reports counts of failed, overdue pending, and
stuck processing outbox rows. It shows WARNING when failed rows exceed 10,
overdue pending rows exceed 25, or any processing row is stuck. A warning does
not fail the healthcheck: a bad candidate email should not restart or take the
instance out of rotation. Review the Delivery log when it appears.
The scheduler container’s healthcheck runs its built-in doctor. It requires a recent successful-or-skipped record for each scheduled job, not merely a running container. The configured value is the minimum stale window; slower jobs also get a window of two scheduled intervals so daily reconciliation jobs are not falsely reported as stale:
docker system df -v and keep encrypted backups outside the VPS.
The image exposes serve, migrate, scheduler, and doctor. Runtime migrations use committed SQL through drizzle-orm; Drizzle Kit and the TypeScript toolchain are build-time only.
Backup, restore, and upgrade
pg_dump -Fc, includes local uploads and config, and checksums every included file before it can succeed. By default it creates a local rollback archive readable only by the deployment owner — this is convenient for a failed update, not a replacement for off-host disaster recovery.
Restore verifies every archived file checksum, creates a safety backup before changing data, stops the app, scheduler, and migration services to avoid a database race, restores PostgreSQL and local uploads, then requires readiness to succeed. A failed restore keeps the safety archive under backups/.
S3 objects are not embedded in the archive. Use bucket versioning and an off-provider backup policy alongside
npx @harly/cli backup.doctor. Migrations are forward-only — restore the full backup when a release does not declare schema-compatible image rollback.
For managed platforms, use their managed PostgreSQL backups and snapshots together with S3 bucket encryption and versioning; they do not require installing age.
Verify your backups actually restore
A backup you have never restored is a guess. Prove recovery end-to-end against a throwaway installation with the destructive harness:Development database
Repository contributors use a separate development Compose file, not the production stack:tooling/docker/compose.dev.yml. Development needs Node 22+, pnpm, Docker, about 4 GB of available RAM, and 10 GB of free disk for a comfortable full-monorepo workflow. Turbopack’s persistent filesystem cache is disabled to prevent multi-gigabyte .next growth. pnpm clean removes generated workspace caches without touching PostgreSQL volumes.
Seeding the demo workspace
pnpm db:seed:demo replaces every workspace-scoped row of the target workspace
with synthetic demo data. The CLI refuses to run until you name that workspace
in the confirmation, so an accidental run cannot wipe real hiring data:
--confirm-wipe=<workspace-slug> works too. The slug must match exactly. The guard
counts every row in a workspace-scoped table except configuration (settings,
custom roles, document access roles and pipeline stage definitions) and the
checklist and sample-data bookkeeping rows. A workspace with more than 25 such
records must also be pinned with DEMO_WORKSPACE_ID. Never run this
against a production database that holds real candidates.
With DEMO_MODE=true, the scheduled demo reset also requires DEMO_WORKSPACE_ID.
Without it the reset refuses to run and deletes nothing. The reset never wipes a
per-visitor sandbox; those expire on their own.
Database extensions
Workspace search and the pg_trgm extension
Workspace search uses trigram indexes when PostgreSQL has the pg_trgm
extension. The migration that creates them tries to install the extension and
never fails the upgrade if it cannot: managed PostgreSQL plans sometimes refuse
CREATE EXTENSION. Without the extension search still works, but name, email
and title lookups scan the tables.
Install pg_trgm before you upgrade, with a role that may create extensions, or
ask your provider to enable it. The migration only runs once, so if the
extension is added later the trigram indexes are not created retroactively.
Troubleshooting
Scheduler doctor reports a stale job
Symptom:docker compose exec -T scheduler node /app/runtime.mjs doctor shows a job past its staleness window.
Cause: the scheduler container restarted, hung, or the instance was suspended longer than HARLY_SCHEDULER_STALE_AFTER_SECONDS.
Solution: confirm app readiness first, then docker compose restart scheduler. Do not invoke cron routes manually.
Restore fails checksum verification
Symptom:npx @harly/cli restore exits before touching data.
Cause: the archive is corrupted or was modified after the backup ran.
Solution: use a different backup archive. The safety backup created before the attempted restore is preserved under backups/.
Related pages
Self-hosting overview
VPS requirements and DNS setup.
Proxy modes
Choose Caddy, an external reverse proxy, or local-only.