eZacto powered by CONFLICT

Self-host in a container

This path runs the same Ezacto application and SQLite schema without a Cloudflare account. One Node.js 22 container stores the organization database in /data/db.sqlite, content-addressed attachments under /data/attachments, and delivers authentication mail through your SMTP server.

Ezacto is pre-1.0. Pin a reviewed commit and complete a restore drill before entering production books.

Prerequisites

1. Build a pinned image

Check out the reviewed commit you intend to operate, then build and tag that exact source:

git checkout YOUR_REVIEWED_COMMIT
docker build --pull --tag ezacto:YOUR_REVIEWED_COMMIT .

Do not use a moving application tag for recovery. The snapshot records the source container's image reference and image ID so an operator can identify the writer version later.

2. Create the environment file

Create this file outside the repository with mode 0600:

APP_BASE_URL=https://time.example.com
API_CURSOR_SIGNING_KEY=REPLACE_WITH_32_RANDOM_BYTES_AS_BASE64URL
SMTP_URL=smtps://username:password@smtp.example.com:465
SMTP_FROM=Ezacto <billing@example.com>
ENVIRONMENT=production
RELEASE=YOUR_REVIEWED_COMMIT

Add EZACTO_BOOTSTRAP_TOKEN as well if you intend to create the owner over the API rather than through the sign-up form. Step 4 explains the choice, which is worth making before the instance is reachable rather than after.

EZACTO_BOOTSTRAP_TOKEN=REPLACE_WITH_GENERATED_EZACTO_TOKEN

Generate the signing key without writing raw bytes to disk:

node -e "process.stdout.write(require('node:crypto').randomBytes(32).toString('base64url'))"

The bootstrap token uses the native API-token format, not a bare signing key. Generate its random selector and secret separately, and copy the complete ezacto_…_… output into the protected environment file:

node -e "const {randomBytes}=require('node:crypto'); process.stdout.write('ezacto_'+randomBytes(12).toString('base64url')+'_'+randomBytes(32).toString('base64url'))"

The SMTP values are checked once at startup, and a failure there is a warning rather than a refusal: the instance starts, logs the endpoint it could not reach, and retries from the queue. That is deliberate — an instance that runs and cannot send beats one that will not run, and a mail server that goes down must not stop a restart. It also means you can bring an instance up to look at it before you have a mail server, which is what the example above does: smtp.example.com is the reserved documentation domain and will never deliver. Replace it before anybody relies on mail arriving.

APP_BASE_URL must be the exact public HTTPS origin; only literal localhost may use HTTP. Percent-encode reserved characters in the SMTP username and password. Optional Google login requires both OIDC_GOOGLE_CLIENT_ID and OIDC_GOOGLE_CLIENT_SECRET. Keep the environment file in the same protected configuration backup as your reverse proxy and SMTP account; physical Ezacto snapshots deliberately exclude secrets.

SMTP_FROM is also the only mailbox the container can attest for organization invoice mail. An administrator must create an smtp sender identity whose email and provider identity exactly match that mailbox, then refresh it before selecting it as the default. The resulting status is operator configured: Ezacto has matched deployment configuration, not verified DKIM, SPF, or DNS alignment. Those remain the SMTP operator's responsibility. Changing SMTP_FROM makes an older attestation unusable at send time until the matching identity is refreshed.

3. Start the instance

Create one named volume and publish the application only on loopback for the reverse proxy:

docker volume create ezacto-data
docker run --detach \
  --name ezacto \
  --restart unless-stopped \
  --publish 127.0.0.1:3000:3000 \
  --volume ezacto-data:/data \
  --env-file /secure/path/ezacto.env \
  ezacto:YOUR_REVIEWED_COMMIT

Configure the reverse proxy to forward https://time.example.com to http://127.0.0.1:3000.

Tell Ezacto how many proxies are in front

Behind a proxy, every request arrives from the proxy's address, and Ezacto rate limits sign-in by client address. Left unset, that means one shared bucket for the whole instance: ten sign-in attempts per fifteen minutes between all your people, so one person retyping a password can lock everybody out, and a single attacker can do it deliberately.

Set the number of proxies between the internet and the container:

TRUSTED_PROXY_HOPS=1

With that, Ezacto counts that many entries in from the right of X-Forwarded-For and rate limits per visitor. One is right for a single Caddy, nginx or Traefik in front. Add one for each additional hop, a CDN in front of your proxy being the usual second.

Set it only if the container is genuinely unreachable except through those proxies. X-Forwarded-For is a request header like any other, so anything that can reach the container directly can choose its own value and therefore its own rate-limit bucket, which is no rate limit at all. That is why the default is 0, which ignores the header completely. Publishing on 127.0.0.1 as above is what makes the setting safe. Give container shutdown at least 30 seconds; Ezacto stops accepting requests, drains queued SMTP work within its budget, checkpoints SQLite WAL, and closes the database before exit.

How often a signed-in request writes

A session remembers when it was last used, and an API token when it was last used, and both are recorded at most once every five minutes rather than on every request. Inside that interval a signed-in page view reads the database and writes nothing. The idle timeout is still measured from the last recorded use, so a session can idle out up to five minutes early, never late. To change the interval:

LAST_SEEN_INTERVAL_SECONDS=300

0 records every request, as releases before #951 did. The ceiling is 3600; a value Ezacto cannot read stops the container at startup and names the variable.

Confirm the container is healthy and non-root:

docker inspect --format '{{.State.Health.Status}}' ezacto
docker exec ezacto node -e "if(process.getuid()===0)process.exit(1)"
curl -fsS https://time.example.com/healthz

The listener opens only after configuration, the SMTP verification attempt, SQLite integrity checks, and migrations. SMTP verification failure warns and retries; invalid configuration, integrity failures, and failed migrations stop startup. If it does not become healthy, inspect docker logs ezacto; the process reports the invalid contract by name without printing its value.

4. Create the owner

A new instance has no accounts, and the first account to claim first run becomes the administrator. Until you have claimed it, anyone who can reach APP_BASE_URL can. Pick one of the two paths below before the instance is publicly reachable.

The claim is permanent once made: an unverified claim is not currently released, so a stranger who signs up first leaves you with no route in except the bootstrap token below, or editing the database by hand.

Either: the sign-up form, immediately

Open APP_BASE_URL, use the sign-up form, and follow the SMTP verification link. The first verified account creates the organization and becomes its administrator. Do this as soon as the container is healthy, before you point public DNS at it or open the firewall.

Or: the bootstrap token, at your leisure

Set EZACTO_BOOTSTRAP_TOKEN in the environment file before the first start. While it is set, /auth/signup is not served at all, so the race described above cannot happen: there is no form for a stranger to reach, and the window never opens rather than merely being short. Two authenticated endpoints create the owner without the sign-up form or an inbox:

curl -sS -X POST "$APP_BASE_URL/__ezacto/bootstrap" \
  -H "authorization: Bearer $EZACTO_BOOTSTRAP_TOKEN" \
  -H 'content-type: application/json' \
  -d '{"organization_name":"Example Co","owner_first_name":"Ada",
       "owner_last_name":"Lovelace","owner_email":"ada@example.com"}'

curl -sS -X POST "$APP_BASE_URL/__ezacto/bootstrap/owner-password" \
  -H "authorization: Bearer $EZACTO_BOOTSTRAP_TOKEN" \
  -H 'content-type: application/json' \
  -d '{"password":"a long passphrase you have not used elsewhere"}'

Both answer 503 when the variable is unset, so the surface does not exist on an instance that never enabled it. Remove EZACTO_BOOTSTRAP_TOKEN from the environment file and restart once you can sign in. It is first-owner authority, and it should not outlive the setup that needed it.

Either way, sign in, create a client and project, and record a test entry before inviting anyone else.

Updates

Take a verified snapshot first. Build the new reviewed commit under a new image tag, stop and remove the old container, then create a container with the same named volume and environment file. Startup applies pending migrations and runs SQLite checks before serving traffic.

Do not assume an old image can read a database after a new migration. Application rollback and data rollback are separate operations; restore the pre-upgrade snapshot into a new volume if the database itself must go back.

Backup and restore

Follow RESTORE.md. The tested physical path requires a cleanly stopped container, captures both db.sqlite and every attachment, verifies SHA-256 before and after restore, requires the source container to be the volume's sole reference, and refuses a non-empty or already-attached target. It is a complete recovery mechanism for this one-container layout, but it is not the portable D18 logical export promised by issue #28: it has no per-table CSV files and is not a Worker-to-container escape bundle.

Operational notes

Outbound webhooks require a separate WEBHOOK_ENCRYPTION_KEY in the private container environment. Keep this 32-byte base64url key with recovery material; see outbound webhook setup and verification. Administrators manage endpoints at Settings → Company → Webhooks.