diff --git a/.env.example b/.env.example index a4f02e9d..4ad542c7 100644 --- a/.env.example +++ b/.env.example @@ -145,6 +145,11 @@ DEPLOYMENT_MODE=self_hosted # production and proxy it to the tracking service on :3000. # TRACKING_DOMAIN=localhost:3000 +# Host port the tracking service is published on. 3000 is a very common default +# for other self-hosted tools, so remap it here when it is already taken and +# point your reverse proxy at the new port. The container port stays 3000. +# TRACKING_PORT=3001 + # Behind a reverse proxy with HTTPS, set each one explicitly instead: # APP_URL=https://app.example.com # API_PUBLIC_URL=https://api.example.com diff --git a/docker-compose.yml b/docker-compose.yml index 1d1497c1..17a207a8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -202,6 +202,7 @@ services: # ─── infrastructure ─────────────────────────────────────────────────── postgres: + restart: unless-stopped image: postgres:16-alpine environment: POSTGRES_USER: warmbly @@ -217,6 +218,7 @@ services: retries: 10 redis: + restart: unless-stopped image: redis:7-alpine ports: ["16379:6379"] volumes: @@ -228,6 +230,7 @@ services: retries: 10 nats: + restart: unless-stopped image: nats:2.10-alpine # -js: JetStream (durable streams). -m 8222: HTTP monitoring for healthcheck. command: ["-js", "-sd", "/data", "-m", "8222"] @@ -251,6 +254,7 @@ services: # publishing it would hand every captured message to anyone who can reach the # host. Set MAILPIT_BIND=0.0.0.0 if you deliberately want it reachable. mailpit: + restart: unless-stopped image: axllent/mailpit:latest profiles: ["sandbox"] ports: @@ -267,6 +271,7 @@ services: # In the `sandbox` profile so the lean `make up` self-host never pulls it. # `make sandbox` / `make infra` start it by naming it explicitly. dovecot: + restart: unless-stopped image: dovecot/dovecot:latest profiles: ["sandbox"] environment: @@ -283,6 +288,7 @@ services: # ─── application ────────────────────────────────────────────────────── backend: + restart: unless-stopped build: context: . dockerfile: deploy/docker/backend.Dockerfile @@ -341,6 +347,7 @@ services: start_period: 20s consumer: + restart: unless-stopped build: context: . dockerfile: deploy/docker/consumer.Dockerfile @@ -357,6 +364,7 @@ services: # (or run more on other machines via the SSH enrollment flow). Outbound IPs # belong to the mail provider, so more workers = more parallelism, not more IPs. worker: + restart: unless-stopped build: context: . dockerfile: deploy/docker/worker.Dockerfile @@ -384,10 +392,14 @@ services: nats: { condition: service_healthy } tracking: + restart: unless-stopped build: context: ./tracking dockerfile: Dockerfile - ports: ["3000:3000"] + # 3000 is a very common default for other self-hosted tools. Remap the host + # side with TRACKING_PORT when it is already taken; the container port and + # everything derived from TRACKING_DOMAIN stay as they are. + ports: ["${TRACKING_PORT:-3000}:3000"] environment: APP_ENV: ${APP_ENV:-dev} AWS_CONFIG_ENABLED: ${AWS_CONFIG_ENABLED:-false} @@ -409,6 +421,7 @@ services: nats: { condition: service_healthy } realtime: + restart: unless-stopped build: context: . dockerfile: deploy/docker/realtime.Dockerfile @@ -442,6 +455,7 @@ services: # the release publishes to GHCR is what runs here. For hot-reload development # use `make app` / docker-compose.dev.yml instead. web: + restart: unless-stopped build: context: ./web dockerfile: Dockerfile @@ -458,6 +472,7 @@ services: backend: { condition: service_healthy } admin: + restart: unless-stopped build: context: ./admin dockerfile: Dockerfile diff --git a/docs/content/docs/development/deployment-guide.mdx b/docs/content/docs/development/deployment-guide.mdx index ba9d8e5f..bc438788 100644 --- a/docs/content/docs/development/deployment-guide.mdx +++ b/docs/content/docs/development/deployment-guide.mdx @@ -37,6 +37,8 @@ flowchart LR | 4 GB RAM | The running stack idles near 300 MB; the first build is the demanding part | | Free ports | 5173, 5174, 8080, 4000, 3000, 4222, 8222, 15432, 16379 | +`3000` is worth checking before you start: it is the default for a lot of other self-hosted software, and a collision there stops the tracking service from publishing. Set `TRACKING_PORT` to move the host side and point your reverse proxy at the new port. + The first build compiles Go, Rust, and Elixir services and builds two frontends. On a modern laptop that takes about **6 minutes**. Later builds reuse the cache and are much faster. That is the whole list. No SMTP relay, no captcha keys, no cloud account, and no `.env` to write before the first run. diff --git a/docs/content/docs/development/troubleshooting.mdx b/docs/content/docs/development/troubleshooting.mdx index 887f9ac1..843a2882 100644 --- a/docs/content/docs/development/troubleshooting.mdx +++ b/docs/content/docs/development/troubleshooting.mdx @@ -78,7 +78,8 @@ Newer builds return the invite-only refusal with its own machine code, `registra | Connecting a mailbox is rejected on the port | SMTP must be `587` or `465`. The Mailpit sink used by `make sandbox` listens on `1025` with no STARTTLS, so it cannot be used as a test mailbox | | A mailbox stalls after about an hour | The worker is missing `BOX_GOOGLE_*` or `BOX_OUTLOOK_*`. The backend starts the OAuth flow but each worker refreshes the token. Set them and restart the worker | | Scheduled sends never fire | Delayed sends run through the in-process Postgres task poller (`TASKS_PROVIDER=local`), so the backend must be running | -| Opens and clicks never record | Check `TRACKING_DOMAIN` resolves and the tracking service answers on `/health`. If you overrode `KAFKA_TRACKING_TOPIC`, it has to be overridden for the Rust publisher and the Go subscriber together | +| Opens and clicks never record | First check the container is actually up with `docker compose -p warmbly ps -a`: a dead `tracking` breaks nothing else, because sends do not wait on it. Then check `TRACKING_DOMAIN` resolves and the service answers on `/health`. If you overrode `KAFKA_TRACKING_TOPIC`, it has to be overridden for the Rust publisher and the Go subscriber together | +| `tracking` exits immediately with `Bind for 0.0.0.0:3000 failed: port is already allocated` | Something else on the host owns port `3000`, a very common default. Set `TRACKING_PORT=3001` in `.env`, re-run `make up`, and point your reverse proxy's tracking host at the new port | | Seeding fails with `no migration found for version N` | The seed image is older than your schema. Re-run with `--build` | | Worker `install_state: error` | Test connection first (is the SSH key in `authorized_keys`?), then read `last_error` and Logs on the worker's detail page | | Worker heartbeat offline | Can the VPS reach the backend URL, NATS or Kafka, and Redis? Is the container running (Live status)? | diff --git a/tracking/src/main.rs b/tracking/src/main.rs index 9ad3fffd..d18b8a4f 100644 --- a/tracking/src/main.rs +++ b/tracking/src/main.rs @@ -12,11 +12,12 @@ mod producer; use axum::{routing::get, Router}; use std::net::SocketAddr; +use std::time::Duration; use tower_http::{ cors::{Any, CorsLayer}, trace::TraceLayer, }; -use tracing::info; +use tracing::{info, warn}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use crate::config::Config; @@ -24,6 +25,44 @@ use crate::handlers::{health, track_click, track_open, AppState}; use crate::observability::report_error; use crate::producer::Producer; +/// Connects the event-bus producer, retrying transient failures. +/// +/// The whole stack starts at once, so the first lookup of `nats` can fail with +/// "DNS error: failed to lookup address information: Try again" purely because +/// the other container is not up yet. Exiting on that leaves the service dead, +/// and nothing else fails loudly when it is: sends still succeed, so the only +/// symptom is opens and clicks silently never recording. +async fn connect_producer(config: &Config) -> Producer { + const MAX_ATTEMPTS: u32 = 8; + const MAX_DELAY: Duration = Duration::from_secs(10); + + let mut delay = Duration::from_millis(500); + let mut attempt: u32 = 1; + + loop { + match Producer::from_config(config).await { + Ok(producer) => { + if attempt > 1 { + info!("Tracking event producer connected after {attempt} attempts"); + } + return producer; + } + Err(e) => { + if attempt >= MAX_ATTEMPTS { + report_error("Failed to create tracking event producer", e.as_ref()); + std::process::exit(1); + } + warn!( + "Tracking event producer not ready (attempt {attempt}/{MAX_ATTEMPTS}), retrying in {delay:?}: {e}" + ); + tokio::time::sleep(delay).await; + delay = (delay * 2).min(MAX_DELAY); + attempt += 1; + } + } + } +} + #[tokio::main] async fn main() { // Initialize tracing @@ -48,13 +87,7 @@ async fn main() { // Event-bus producer (NATS by default; Kafka when EVENTBUS_PROVIDER=kafka // and the `kafka` feature is compiled in). - let producer = match Producer::from_config(&config).await { - Ok(p) => p, - Err(e) => { - report_error("Failed to create tracking event producer", e.as_ref()); - std::process::exit(1); - } - }; + let producer = connect_producer(&config).await; let state = AppState::new(producer, &config);