Running attrect yourself
attrect is an application you run on your own server. There is no hosted version to join. The source is under the MIT licence.
One container holds everything: Bun serves the built frontend and answers the API on port 3000. The data lives in a single SQLite file.
Building from source needs two commercial licences
The frontend uses FontAwesome Pro and WebAwesome Pro. Both are paid products in private npm registries. Without access to those packages the frontend cannot be built from source — the rest of the application is unaffected, the backend has no private dependencies.
If you use a prebuilt image you need neither licence: the packages are already inside the built frontend.
What you need
- Docker with Compose
- A reverse proxy that terminates TLS. The container speaks plain HTTP on port 3000 and does not deal with certificates — see reverse proxy with Caddy.
- A volume for the database. Without one the data lives in the container's writable layer and goes with it.
Setting it up
Write a compose.yaml:
services:
app:
image: registry.cepharum.de/attrect:latest
ports:
- "3000:3000"
environment:
NODE_ENV: production
DATABASE_PATH: /data/attrect.db
SECURE_COOKIES: "true"
volumes:
- db-data:/data
restart: unless-stopped
volumes:
db-data:The image is publicly readable: docker compose pull needs no registry login. Prefer a commit's short SHA over :latest — then what is running can be named.
docker compose up -dOn start the application creates the schema and runs every migration. /api/health answers ok once it is done; the image declares that call as its own HEALTHCHECK, so docker compose ps reports health rather than merely a running process.
Creating the first administrator
After a fresh install no user exists at all, and nobody can sign in until one does:
docker compose exec app bun run user -- add \
--login anna --email anna@example.com --name "Anna Example" --role adminWithout --password a random one is generated and printed once. Write it down before you close the terminal.
Further users are created in the application by an administrator. The command line can do it too:
bun run user -- passwd --login anna
bun run user -- role --login anna --role lead
bun run user -- disable --login annaRoles
| Role | Sees |
|---|---|
member | their own entries |
lead | additionally everything booked on their own projects |
admin | everything, and manages users, customers, projects and locks |
TLS is not optional
The session cookie is marked Secure. Over plain HTTP the browser discards it and nobody can sign in — it looks like a wrong password but is the missing TLS termination. For local trials behind an HTTP proxy only, set SECURE_COOKIES: "false".
Reverse proxy with Caddy
Caddy is the recommendation here: it obtains and renews the certificates by itself, and the whole configuration is the block below. Any other proxy does as well — what attrect needs is TLS termination and a forward to port 3000.
Caddyfile:
attrect.example.com {
reverse_proxy app:3000
}With that, attrect no longer publishes its port on the host — only Caddy listens outwards:
services:
app:
image: registry.cepharum.de/attrect:latest
expose:
- "3000"
environment:
NODE_ENV: production
DATABASE_PATH: /data/attrect.db
SECURE_COOKIES: "true"
volumes:
- db-data:/data
restart: unless-stopped
caddy:
image: caddy:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
restart: unless-stopped
volumes:
db-data:
caddy-data:
caddy-config:Caddy's volume for /data is not optional
It holds the certificates and the account key. Without it Caddy fetches new ones after every docker compose up — and Let's Encrypt limits that per domain and week. A few restarts later the site stands there without a valid certificate.
Port 80 has to stay open even though nothing is served on it: that is where the challenge runs that gets Caddy its certificates. And the domain has to point at the host before you start.
Rate-limiting sign-in attempts
Caddy cannot do this out of the box — the counting lives in a plugin, and Caddy plugins are compiled in. That makes it a two-stage image:
FROM caddy:builder AS builder
RUN xcaddy build --with github.com/mholt/caddy-ratelimit
FROM caddy:alpine
COPY --from=builder /usr/bin/caddy /usr/bin/caddyThen in the Caddyfile:
attrect.example.com {
rate_limit {
zone signin {
match {
path /api/auth/login /api/auth/password
}
key {http.request.remote.host}
events 10
window 1m
}
}
reverse_proxy app:3000
}Ten attempts per minute and IP is plenty for anybody mistyping their password and makes working patiently through a list of them useless. /api/auth/password is in there because a password is checked there just as well.
And in the compose block, replace image: caddy:alpine with a build: . pointing at that Dockerfile.
Environment variables
| Variable | Default | Purpose |
|---|---|---|
PORT | 3000 | port of the backend |
DATABASE_PATH | ./attrect.db | path to the SQLite file |
NODE_ENV | — | production enables HSTS |
SECURE_COOKIES | true | set to false only when testing without TLS |
First steps in the application
Nothing can be recorded until there is something to book onto. In order:
- Create a customer. The recording increment hangs off it — the step the arrow keys move in the time fields (default: 15 minutes).
- Create a project under that customer. This is where it is decided whether work booked onto it is billable.
- Create activities — "Development", "Communication", whatever you need.
- Optionally: hourly rates, budgets per period, and the calendar of days nobody works. Without public holidays and absences the forecast counts on too many working days. A year's statutory holidays come from "Import holidays" under Public holidays and absences — pick where the company is based once, as that decides the regional holidays; the choice is kept for the years to come. Days that are already off stay as they are, so the import can safely be repeated.
- Start recording — see recording time.
Upgrading
Migrations run on start, each in a transaction. A failed one therefore leaves no half-migrated database — but it does leave the old container gone, which is why the backup comes first. Migrations only go forwards; there is no down path, and a rollback means a restore.
docker compose exec app bun run backup -- --out /data/backup-$(date +%F).db
docker compose pull && docker compose up -dBackups and restore
The backup uses VACUUM INTO, so the snapshot is consistent even while requests are being served. Do not copy the .db file — in WAL mode the copy would be incomplete.
# Snapshot, and copy it off the host
docker compose exec app bun run backup -- --out /data/backup-$(date +%F).db
docker compose cp app:/data/backup-$(date +%F).db ./
# Restore: stop, put the file in place, start
docker compose down
docker compose cp ./backup-2026-08-01.db app:/data/attrect.db
docker compose up -dThe target name has to be the one in DATABASE_PATH — if that says something other than attrect.db, the file belongs there instead. docker compose exec app ls /data shows what it is called.
A snapshot a day, kept off the host, is the whole backup strategy this needs. It is one file.
Building from source
Only with the two licences named above. The private registries are resolved through an .npmrc in the project root, which goes into the build as a BuildKit secret rather than into an image:
git clone https://gitlab.com/cepharum/attrect.git
cd attrect
# write .npmrc with credentials for @fortawesome and @web.awesome.me
DOCKER_BUILDKIT=1 docker build --secret id=npmrc,src=.npmrc -t attrect .For development, backend and frontend run separately:
cd backend && bun install && bun run dev # API on :3000
cd frontend && bun install && bun run dev # Vite on :5173, /api is proxiedPermissions on the volume
The container runs as the base image's unprivileged bun user (uid 1000), and the image gives /data to that user. A volume created from this image is therefore owned correctly — there is nothing for you to do here.
If you bring a volume from somewhere else, uid 1000 has to be able to write in it. Otherwise the first write fails with SQLITE_READONLY, and the ownership is set once:
docker run --rm -v <volume>:/data alpine chown -R 1000:1000 /dataWhat attrect does not do itself
The application does not count sign-in attempts. Sessions are server-side and passwords are hashed, but nothing except limiting the attempts helps against somebody working patiently through a list of them — and the place for that is the proxy standing in front of it anyway. How to do it with Caddy is above.
Privacy
The application loads nothing from third-party hosts — no web fonts, no CDNs, no tracking. A cookie banner is therefore unnecessary: the session cookie and the stored display settings (language, theme) are technically required or set by the user themselves.