Skip to content

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:

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.

bash
docker compose up -d

On 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:

bash
docker compose exec app bun run user -- add \
  --login anna --email anna@example.com --name "Anna Example" --role admin

Without --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:

bash
bun run user -- passwd  --login anna
bun run user -- role    --login anna --role lead
bun run user -- disable --login anna

Roles ​

RoleSees
membertheir own entries
leadadditionally everything booked on their own projects
admineverything, 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:

caddy
attrect.example.com {
	reverse_proxy app:3000
}

With that, attrect no longer publishes its port on the host — only Caddy listens outwards:

yaml
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:

dockerfile
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/caddy

Then in the Caddyfile:

caddy
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 ​

VariableDefaultPurpose
PORT3000port of the backend
DATABASE_PATH./attrect.dbpath to the SQLite file
NODE_ENV—production enables HSTS
SECURE_COOKIEStrueset 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:

  1. Create a customer. The recording increment hangs off it — the step the arrow keys move in the time fields (default: 15 minutes).
  2. Create a project under that customer. This is where it is decided whether work booked onto it is billable.
  3. Create activities — "Development", "Communication", whatever you need.
  4. 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.
  5. 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.

bash
docker compose exec app bun run backup -- --out /data/backup-$(date +%F).db
docker compose pull && docker compose up -d

Backups 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.

bash
# 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 -d

The 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:

bash
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:

bash
cd backend  && bun install && bun run dev    # API on :3000
cd frontend && bun install && bun run dev    # Vite on :5173, /api is proxied

Permissions 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:

bash
docker run --rm -v <volume>:/data alpine chown -R 1000:1000 /data

What 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.