Installation

Run Tick with Docker Compose, plain docker run, or natively with Node and PM2.

Tick is a single Nuxt server plus a PostgreSQL database. Pick whichever of the three setups fits your host.

The repo's docker-compose.yml runs the app together with a bundled Postgres 17 (persistent named volume tick-pgdata). Compose reads .env automatically; NUXT_SESSION_PASSWORD is required and the stack refuses to start without it.

git clone https://github.com/ticktimer/Tick.git tick && cd tick
cp .env.example .env
# edit .env: set NUXT_SESSION_PASSWORD (openssl rand -base64 36)
docker compose up -d

Open http://localhost:3000 and register your first account.

Notes on the compose file:

  • The app image defaults to ghcr.io/ticktimer/tick:latest with a commented-out build: . line, so you can swap in a local build from a fresh clone.
  • TICK_PORT changes the published host port, TICK_IP the host address it binds to, POSTGRES_PASSWORD the bundled database password (all optional, see the env table).
  • Opening Tick over plain http://<LAN-IP>:3000 (no TLS) needs NUXT_SESSION_COOKIE_SECURE=false; without it, sign-in shows a "browser refused the sign-in cookie" message and you stay logged out — see Plain HTTP on a LAN.
  • Behind a reverse proxy on the same host, set TICK_IP=127.0.0.1. Docker writes published ports directly into iptables, so a port bound to 0.0.0.0 stays reachable even when ufw or firewalld claims to block it.
  • Postgres is not exposed to the host by default; uncomment the ports block in the db service if you want to reach it directly.
  • Both services have healthchecks; the app waits for the database to be healthy before starting.
The app service passes the database, session, session-cookie, migration, registration mode, rate limit and SMTP variables from .env through to the container, so setting them in .env is enough. Demo mode is deliberately not passed through (it wipes data): add it to the environment: block by hand, and only on a throwaway instance. Compose expands $ inside .env values, so single-quote secrets that contain one.

docker run (bring your own Postgres)

If you already run Postgres elsewhere, the image alone is enough:

docker run -d --name tick -p 3000:3000 \
  -e NUXT_DATABASE_URL='postgresql://tick:password@db-host:5432/tick' \
  -e NUXT_SESSION_PASSWORD="$(openssl rand -base64 36)" \
  ghcr.io/ticktimer/tick:latest
Generate NUXT_SESSION_PASSWORD once and keep it stable — it encrypts session cookies, and changing it logs every user out.

Multi-arch images (linux/amd64, linux/arm64) are published to GHCR by CI on pushes to main (latest, sha-… tags) and on v* tags (semver). The package is public — docker pull ghcr.io/ticktimer/tick:latest works without docker login. The image runs as a non-root user and ships a healthcheck against /login.

Native (Node + PM2)

Requires Node 22+ and a reachable Postgres.

npm ci
npm run build
export NUXT_DATABASE_URL='postgresql://tick:password@localhost:5432/tick'
export NUXT_SESSION_PASSWORD="$(openssl rand -base64 36)"   # keep it stable across restarts
node .output/server/index.mjs        # or:
pm2 start ecosystem.config.cjs       # see the file for env docs

PM2 inherits your shell environment; for a persistent setup put the NUXT_* variables in your service manager's env file (or a PM2 ecosystem env block) rather than exporting them ad hoc.

Reverse proxy

The production server speaks plain HTTP on port 3000. For anything beyond localhost, put a reverse proxy in front of it for TLS, compression and security headers. The repo ships ready-made configs in deploy/:

  • deploy/nginx.conf.example — drop into /etc/nginx/conf.d/tick.conf. Contains two variants: a public host with TLS (port 80 redirects to 443; uncomment one ssl_certificate pair — certbot paths or your internal CA's) and a commented-out plain-HTTP variant for LAN-only boxes — also set NUXT_SESSION_COOKIE_SECURE=false if you use it; prefer the TLS variant with an internal-CA cert instead. Run nginx -t and reload.
  • deploy/Caddyfile.example — public hostnames get automatic Let's Encrypt certificates with zero cert config. On an internal box, uncomment tls internal (Caddy's built-in CA) or use an http:// site address, which also needs NUXT_SESSION_COOKIE_SECURE=false; tls internal avoids that.

Details and setup commands are in deploy/README.md. Whatever proxy you use, these five things matter:

RequirementWhy
Forward Host and X-Forwarded-Proto / X-Forwarded-ForTick builds absolute links (invite and password-reset URLs) from the request origin, and session cookies need the right host
client_max_body_size 8m (nginx) / request_body max_size 8MB (Caddy)CSV import posts up to 5 MB of body; the default 1 MB proxy limit rejects it before Tick sees it
No caching on /sw.js and /manifest.webmanifestOtherwise installed PWA clients keep serving a stale app shell after an upgrade
TLS, plus gzip and security headersThe app speaks plain HTTP and leaves HSTS to you — see Security
Leave NUXT_SESSION_COOKIE_SECURE at true behind TLSThe browser sees https, so the Secure cookie works, and Secure keeps it off the port-80 redirect
If you enable the (commented) HSTS header, do it only once TLS is known-good — browsers cache it, and a broken certificate then locks users out for the whole max-age.
Behind a proxy, Tick's auth rate limiter sees the proxy's address for every request, which makes its 10/min window instance-wide rather than per-client. Rate-limit at the proxy instead and set NUXT_AUTH_RATE_LIMIT=0.

Plain HTTP on a LAN

Opening Tick directly at http://<LAN-IP>:3000, with no reverse proxy: the session cookie is Secure by default, and browsers refuse to store a Secure cookie over plain HTTP. Login returns success but the browser drops the cookie — the sign-in page reports this, naming the host. In order of preference:

  1. Caddy with tls internal — real HTTPS on a LAN hostname; trust its root certificate once per device. See deploy/Caddyfile.example.
  2. A VPN or overlay network with HTTPS certs (WireGuard, Tailscale).
  3. Your own internal CA with nginx variant A.
  4. Set NUXT_SESSION_COOKIE_SECURE=false, only on a network you fully trust — it also puts session cookies and passwords in cleartext on the wire.

See Plain HTTP and the Secure flag for what option 4 costs, and what stays protected without Secure.

Registration modes

By default anyone who can reach the host can create a workspace. NUXT_PUBLIC_REGISTRATION changes that:

ValueEffect
open (default)Self-serve signup at /register; each new account gets its own personal org
invite/register only accepts a valid ?invite=TOKEN link; without one the form is replaced by an explanation, and the API answers 403
closedNo self-serve signup at all. Existing users still log in; new people must be invited into an org
NUXT_PUBLIC_REGISTRATION=invite

Pick invite or closed for anything exposed to the internet. Both still let owners and admins add people through invite links.

Email (SMTP)

Mail is optional. Configure it and Tick emails invites and password-reset links; leave it unset and the app keeps working with visible fallbacks.

NUXT_SMTP_HOST=smtp.example.com
NUXT_SMTP_PORT=587
NUXT_SMTP_USER=tick@example.com
NUXT_SMTP_PASS=app-password
NUXT_SMTP_SECURE=false                     # true = implicit TLS, usually port 465
NUXT_MAIL_FROM="Tick <tick@example.com>"

Mail counts as configured only when both NUXT_SMTP_HOST and NUXT_MAIL_FROM are set. Without it:

  • Invites still work — the dialog shows the …/register?invite=TOKEN link to copy and send yourself.
  • Password resets still work — the reset link is written to the server console (docker compose logs app) instead of being emailed.
That console fallback means anyone who can read your logs can take over an account. Configure SMTP, or treat log access as account access.

Demo mode

For a public playground instance, NUXT_DEMO_MODE=true blocks the destructive operations (password change, org rename, member removal, invite create/revoke, import commit, permanent trash deletion) with a 403, and NUXT_PUBLIC_DEMO_MODE=true shows a dismissible banner with the demo credentials.

Demo mode wipes and reseeds the database's demo org. On boot, and then hourly whenever the last reset is over 24 hours old, it deletes the "Hollow Studio" org and the mara@example.com user — cascading away their clients, projects, tasks, tags and entries — and reseeds three weeks of fake history. Never enable it on an instance that holds real data.

Automatic migrations

The built server applies pending SQL migrations from server/db/migrations on startup — a Nitro plugin runs them before the first request, in Docker and native installs alike. That means upgrades are just "pull new version, restart".

While they run, every request is answered 503 Starting up with a Retry-After header, the healthcheck included — a container that is still migrating is not yet healthy. Requests are served normally as soon as the migrations finish, which is typically well inside the healthcheck's start period.

If a migration fails, the server logs the error and exits with status 1 rather than serving traffic against a half-migrated schema. Under the shipped Compose file (restart: unless-stopped) the container then restarts and retries; if the migration is genuinely broken it will keep restarting, which is the visible, loud failure you want. Check docker compose logs app for the SQL error, fix or roll back the migration, and restart.

To run migrations out-of-band instead (e.g. in a CI step or with a locked-down DB user), set NUXT_AUTO_MIGRATE=false and apply them yourself with npx drizzle-kit migrate. The server then starts immediately and assumes the schema is current.

Environment variables

Two are required to boot; the rest are opt-in:

VariableRequiredDefaultDescription
NUXT_DATABASE_URLyes—Postgres connection string, e.g. postgresql://tick:password@localhost:5432/tick
NUXT_SESSION_PASSWORDyes—Session cookie encryption secret, 32+ chars. Generate: openssl rand -base64 36. Changing it logs everyone out
NUXT_PUBLIC_REGISTRATIONnoopenopen / invite / closed — see registration modes
NUXT_AUTH_RATE_LIMITno10Auth POSTs per IP per minute; 0 defers to your proxy
NUXT_SESSION_COOKIE_SECUREnotrueSecure flag on the session cookie; false only for plain-HTTP LAN access — see Plain HTTP on a LAN
NUXT_AUTO_MIGRATEnotrueApply pending SQL migrations on production startup
NITRO_HOST / NITRO_PORTno0.0.0.0 / 3000Bind address and port of the production server

The Environment variables reference has the complete table — SMTP, mail sender, migrations directory, demo mode and the docker-compose-only variables — plus the npm scripts.

Development setup

npm i
cp .env.example .env      # point NUXT_DATABASE_URL at a local Postgres
npm run db:push           # create the schema (dev uses push, not migrations)
npm run db:seed           # demo org + ~3 weeks of entries
npm run dev

Seed credentials: mara@example.com / tick-demo (org "Hollow Studio"). Other scripts: npm run typecheck, npm run db:generate (writes a new SQL migration from schema changes — commit the server/db/migrations output so deployed instances pick it up).