The launcher, Docker, plain Node, development mode
Running Atalaia
Atalaia runs in containers. There are three of them — the API (port 3000), the worker (no port) and the management console (port 3001) — and they all talk to one Postgres.
That Postgres is deliberately not one of the containers. Atalaia needs a connection string and nothing else — no extension, no managed feature, nothing that ties it to one host — so bring whichever Postgres 13 or later you already have.
The database first
docker run -d --name atalaia-db -e POSTGRES_PASSWORD=postgres -p 5432:5432 postgres:17
postgresql://postgres:postgres@127.0.0.1:5432/postgres
Put it in .env as DATABASE_URL. A managed instance works the same way, as does a local Supabase, Neon or anything else — with one thing to watch: use the session connection, port 5432, not a 6543 transaction pooler. ./scripts/atalaia.sh doctor warns when it sees 6543, because pgbouncer in transaction mode breaks prepared statements and LISTEN, and the queue needs both. The failure it causes is intermittent and reads as a bug in the queue.
Migrations run themselves. Both the API and the worker apply any pending ones on boot, behind an advisory lock so they cannot race.
The launcher
./scripts/atalaia.sh up
It creates .env from .env.example if it is missing, generates the secrets that have no sensible default (API_KEY, and a SETUP_PASSWORD for the first console account), starts the three containers, and waits until the API and the console answer their health endpoints. Docker if the daemon answers, Apple's container otherwise.
| Command | What it does |
|---|---|
up |
Start API, worker and console. |
down |
Stop everything the launcher started, in either runtime. |
restart |
down followed by up. |
status |
Health of each service and what is currently running. |
logs [service] |
Follow logs. Service names: atalaia, atalaia-worker, atalaia-console. |
init |
Create .env from .env.example and generate missing secrets. |
install |
Install dependencies with pnpm — for developing, not for running. |
build |
Build the console bundle and the CLI. |
test |
Run the test suite. |
doctor |
Check the runtime, the database and the configuration. |
| Option | Effect |
|---|---|
--docker |
Force Docker Compose. |
--container |
Force Apple's container CLI. |
--build |
Rebuild images before starting. |
--no-console |
Start the API and the worker only. |
-h, --help |
Full usage. |
./scripts/atalaia.sh up --build
./scripts/atalaia.sh logs atalaia-worker
./scripts/atalaia.sh status
./scripts/atalaia.sh down
Ports. The launcher reads PORT and UI_PORT from .env — or from the environment, which wins — and docker-compose.yml interpolates the same variables, so the published port never drifts from the port the process listens on.
What each container is
| Service | Image | Port | Health |
|---|---|---|---|
atalaia |
multi-stage node:24-alpine, from Dockerfile |
3000 |
GET /health |
atalaia-worker |
the same image, node src/interface/worker.js |
none | it exits if it cannot reach Postgres, and is restarted |
atalaia-console |
multi-stage node:24-alpine, from ui/Dockerfile |
3001 |
GET /healthz |
The worker is where the work happens: feed cycles, repository scans, dependency freshness and the weekly report all run there, taken off the queue. The API only serves requests and enqueues jobs. That separation is why a scan no longer competes with the console for the same event loop, and why killing the API mid-scan no longer loses it. See queues.md.
The console waits for the API to report healthy and reaches it over the compose network at http://atalaia:3000 — never localhost, which inside a container is the container itself.
Nothing mounts a volume: there is no state on this side any more.
With Docker directly
One thing the launcher does that a bare docker compose up does not: translate the database host.
A database on this machine means .env says 127.0.0.1 — which is what the CLI, the tests and doctor need. Inside a container that address is the container itself, and the connection is refused with ECONNREFUSED 127.0.0.1:5432. Compose reads .env for interpolation, so it passes the host-shaped URL straight through.
Export a container-reachable URL yourself, or use the launcher:
cp .env.example .env # or: ./scripts/atalaia.sh init
export DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:54622/postgres
docker compose up -d --build
docker compose ps # all three should be "healthy"
docker compose logs -f atalaia-worker
docker compose down
A remote database needs no translation: its host is already on the network. ./scripts/atalaia.sh doctor prints the URL the containers will get whenever it differs from the one in .env.
More workers, if one is not keeping up:
docker compose up -d --scale atalaia-worker=3
The queue hands each job to exactly one of them, and the exclusive queues stay exclusive across all of them — that guarantee lives in Postgres, not in the process.
With Apple container
macOS 15+, no Docker Desktop:
container system start
./scripts/atalaia.sh up --container
Apple's runtime has no compose, so the launcher creates a network, builds each image and starts each container itself, polling the health endpoint between them where compose would have used depends_on: service_healthy. Both paths are generated from one table of service definitions in scripts/atalaia.sh so they cannot drift.
This path is written but not yet exercised: the machine this was built on has no Apple container installed. The Docker path is verified.
Developing
The services still run in containers; the tooling around them does not.
corepack enable
pnpm install # root + ui workspaces
TEST_DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/postgres pnpm test
pnpm --filter atalaia-console run dev:client # Vite on :5173, proxying to the console
pnpm run dev:cli # the terminal client from source
pnpm test without TEST_DATABASE_URL runs the unit suites and skips the integration ones, saying so.
up prints the public address under the ports, once the tunnel has settled — it waits for it rather than reporting "none" a second too early. none there means Slack and Telegram cannot reach this instance, and says which variable to set.
In non-production (NODE_ENV !== 'production') the API opens a tunnel and hands the URL to Slack and Telegram, so their Acknowledge/Resolve buttons reach your laptop. TUNNEL_PROVIDER picks one: auto (the default) takes ngrok when NGROK_AUTH_TOKEN is set and Cloudflare's quick tunnel otherwise, which needs no account; none opens nothing. Slack also needs SLACK_APP_TOKEN and SLACK_APP_ID to have its Request URL updated. Set PUBLIC_URL and no tunnel is opened at all — a hostname you own wins.