Installation
Tick is a single Nuxt server plus a PostgreSQL database. Pick whichever of the three setups fits your host.
Docker Compose (recommended)
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:latestwith a commented-outbuild: .line, so you can swap in a local build from a fresh clone. TICK_PORTchanges the published host port,TICK_IPthe host address it binds to,POSTGRES_PASSWORDthe bundled database password (all optional, see the env table).- Opening Tick over plain
http://<LAN-IP>:3000(no TLS) needsNUXT_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 to0.0.0.0stays reachable even when ufw or firewalld claims to block it. - Postgres is not exposed to the host by default; uncomment the
portsblock in thedbservice if you want to reach it directly. - Both services have healthchecks; the app waits for the database to be healthy before starting.
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
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 onessl_certificatepair — certbot paths or your internal CA's) and a commented-out plain-HTTP variant for LAN-only boxes — also setNUXT_SESSION_COOKIE_SECURE=falseif you use it; prefer the TLS variant with an internal-CA cert instead. Runnginx -tand reload.deploy/Caddyfile.example— public hostnames get automatic Let's Encrypt certificates with zero cert config. On an internal box, uncommenttls internal(Caddy's built-in CA) or use anhttp://site address, which also needsNUXT_SESSION_COOKIE_SECURE=false;tls internalavoids that.
Details and setup commands are in deploy/README.md. Whatever proxy you use, these five things matter:
| Requirement | Why |
|---|---|
Forward Host and X-Forwarded-Proto / X-Forwarded-For | Tick 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.webmanifest | Otherwise installed PWA clients keep serving a stale app shell after an upgrade |
| TLS, plus gzip and security headers | The app speaks plain HTTP and leaves HSTS to you — see Security |
Leave NUXT_SESSION_COOKIE_SECURE at true behind TLS | The browser sees https, so the Secure cookie works, and Secure keeps it off the port-80 redirect |
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:
- Caddy with
tls internal— real HTTPS on a LAN hostname; trust its root certificate once per device. Seedeploy/Caddyfile.example. - A VPN or overlay network with HTTPS certs (WireGuard, Tailscale).
- Your own internal CA with nginx variant A.
- 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:
| Value | Effect |
|---|---|
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 |
closed | No 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=TOKENlink 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.
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.
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:
| Variable | Required | Default | Description |
|---|---|---|---|
NUXT_DATABASE_URL | yes | — | Postgres connection string, e.g. postgresql://tick:password@localhost:5432/tick |
NUXT_SESSION_PASSWORD | yes | — | Session cookie encryption secret, 32+ chars. Generate: openssl rand -base64 36. Changing it logs everyone out |
NUXT_PUBLIC_REGISTRATION | no | open | open / invite / closed — see registration modes |
NUXT_AUTH_RATE_LIMIT | no | 10 | Auth POSTs per IP per minute; 0 defers to your proxy |
NUXT_SESSION_COOKIE_SECURE | no | true | Secure flag on the session cookie; false only for plain-HTTP LAN access — see Plain HTTP on a LAN |
NUXT_AUTO_MIGRATE | no | true | Apply pending SQL migrations on production startup |
NITRO_HOST / NITRO_PORT | no | 0.0.0.0 / 3000 | Bind 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).