# Containers Docs > Documentation for the containers collection: production-ready Docker images built with forge, supervised by ezx, and configured entirely through environment variables. Per-page markdown is available at /.md and an index at /llms.txt. Each page also exposes //llms.txt (plain text) and //llms-full.txt (raw markdown). --- title: Why containers group: Getting started url: https://docs.containers.supanadit.com/why.md --- # Why containers This project is built from personal experience running containers — both Docker and Kubernetes — in production. Docker and Kubernetes are revolutionary, but they still depend on the community to publish the images you actually run. Most open-source programs officially ship containers, yet consistency is the problem. Bitnami used to be the most consistent provider, but it changed its publishing model: no more version pinning unless you move to `bitnami/legacy`, which does not receive security updates. That change caused unexpected breaking changes on personal servers and in professional environments alike. So these images were built instead — modeled on what made Bitnami good, with **extra sauce** on top: - **Everything through environment variables.** Even the complicated configurations — Grafana Loki, Grafana Mimir, Thanos — reduce to plain env vars. No bespoke config languages to learn. - **Declarative, reproducible builds.** Every image is assembled by [forge](https://github.com/supanadit/forge) from a `forge.toml` manifest: pinned downloads with checksums, explicit dependencies, automatic cleanup. No `setup.sh`, no shell entrypoints, no mystery layers. - **Supervised runtime.** A single Go binary, [ezx](https://github.com/supanadit/ezx), stays PID 1: it forwards signals, reaps zombies, rotates logs, probes readiness, and drops privileges per process. - **Power-user bundles.** PostgreSQL ships with a curated extension set, Kafka runs in KRaft mode with no ZooKeeper, WordPress is tuned for serverless OCI environments like Google Cloud Run. The goal is simple: deploy robust systems with minimal effort, and be able to read every layer you are running. ## License The project is MIT licensed. Some bundled software (Grafana Loki, Mimir, MinIO) carries dual licenses; the core of this project — Dockerfiles, entrypoints, and the extra sauce — is fully open source. ## Feedback These images are designed for general-purpose use out of the box, but the scripts are open and customizable for specific use cases. Feedback, feature requests, and bug reports are welcome — open a GitHub Issue to get started. --- title: Quickstart group: Getting started url: https://docs.containers.supanadit.com/quickstart.md --- # Quickstart ## Pull an image Every image is published to GHCR: ```bash docker pull ghcr.io/supanadit/containers/postgresql ``` Tags follow `-rX.Y.Z` (for example `ghcr.io/supanadit/containers/postgresql:postgresql-r1.2.3`) so you can pin exactly the version you tested. ## Run one The smallest useful PostgreSQL: ```bash docker run -d \ --name postgres \ -e POSTGRES_DB=app \ -e POSTGRES_PASSWORD=secret \ -v postgres-data:/opt/containers/data \ -p 5432:5432 \ ghcr.io/supanadit/containers/postgresql ``` Connect from another container and check readiness on the health endpoint: ```bash docker exec -it postgres psql -U app -c 'select version();' curl -s http://localhost:5432/health ``` > [!TIP] > Every container exposes a composite health endpoint. Orchestrators should > probe it instead of a raw TCP handshake — see [Health & readiness](/health). ## Run with Compose ```yaml services: postgres: image: ghcr.io/supanadit/containers/postgresql environment: POSTGRES_DB: app POSTGRES_PASSWORD: secret volumes: - postgres-data:/opt/containers/data ports: - "5432:5432" volumes: postgres-data: ``` ## Where things live Inside every container, the application tree is laid out the same way: | Path | Purpose | | ------------------------- | -------------------------- | | `/opt/containers/data` | Application data | | `/opt/containers/config` | Generated configuration | | `/opt/containers/logs` | Application logs (rotated) | | `/opt/containers/run` | Runtime files, sockets | | `/opt/containers/backup` | Backup output | Mount volumes at these paths and nothing else — the directories are pre-created and owned by the application's dedicated user. ## Next steps - [Configuration](/configuration) — how env vars become config files. - Pick a container from the sidebar for its specific variables and examples. --- title: Configuration group: Getting started url: https://docs.containers.supanadit.com/configuration.md --- # Configuration All configuration happens through environment variables. There is no config file to mount, no init script to run: at boot, each container validates its environment, generates the application's native config file, and starts the app. ## How it works Each container ships a small JavaScript module set (`entrypoint.d/bootstrap/`) that runs under [ezx](https://github.com/supanadit/ezx): 1. **`env.js`** declares every environment variable with a type (`env.bool`, `env.int`) and a default — the single source of truth. 2. **`validation.js`** fails fast on contradictory or missing settings, *before* any side effect happens. 3. **`config.js`** renders the application config from typed values — never `sed`-edited templates. Rules worth knowing: - `env.get(name, default)` applies the default when the variable is set to the empty string, too. Unset and empty behave the same. - Booleans accept `true`/`false`/`1`/`0`/`yes`/`no`. - An empty `value` is still an argument: optional CLI flags are only added when their variable is set, so you never end up with `--flag=`. ## Naming convention Variables are prefixed with the application name: | Container | Prefix | | ---------- | ------------ | | PostgreSQL | `POSTGRES_` | | MariaDB | `MARIADB_` | | Kafka | `KAFKA_` | | etcd | `ETCD_` | | Prometheus | `PROMETHEUS_`| | Grafana | `GRAFANA_` | | WordPress | `WORDPRESS_` | See each container's page for its full variable table. ## Secrets Secret-bearing variables (passwords, keys) are consumed at spawn time: the entrypoint strips them from the child process environment after generating config, so a `ps eww` inside the container does not reveal them. ## Overrides For values that are not exposed as variables, most containers read optional drop-in configuration from `/opt/containers/config`. Mount a file there and the entrypoint merges or references it instead of failing. Per-container pages show the exact mechanism — for example [PostgreSQL custom config](/postgresql#custom-configuration). --- title: Health & readiness group: Getting started url: https://docs.containers.supanadit.com/health.md --- # Health & readiness Every container runs a shared health server inside the image, registered by the entrypoint before the application starts. Each application adds its own protocol-aware probes, and a **composite** endpoint answers only when every probe reports ready. ## Endpoints | Endpoint | Meaning | | ------------- | -------------------------------------------------- | | `/health` | Composite status: healthy only when all probes are | | `/health/app` | The application's own probe (protocol-specific) | The address is configurable via `EZX_HEALTH_ADDR`. If it is unset, the app still starts — health registration logs a warning instead of failing. > [!NOTE] > Always point orchestrators and compose `healthcheck` blocks at `/health`, > not at a TCP port check. The composite endpoint verifies the app actually > answers on its own protocol — a TCP handshake proves nothing. ## Example Compose health check for PostgreSQL: ```yaml healthcheck: test: ["CMD", "wget", "-qO-", "http://127.0.0.1:5432/health"] interval: 10s timeout: 5s retries: 5 ``` The exact port matches the application's listening port; check the container page for details. The test suites in the repository assert that `/health` reports healthy and that the app runs as a non-root user — so a passing CI build is a passing health check in practice. --- title: Volumes & backups group: Getting started url: https://docs.containers.supanadit.com/volumes.md --- # Volumes & backups ## Volume layout Every container pre-creates the same tree under `/opt/containers` and chowns it to the application's dedicated user: | Path | Purpose | | ------------------------ | ------------------------------- | | `/opt/containers/data` | Application data — mount here | | `/opt/containers/config` | Generated + drop-in config | | `/opt/containers/logs` | Logs, rotated by ezx | | `/opt/containers/run` | Sockets, PIDs, runtime files | | `/opt/containers/backup` | Backup output | Mount a named volume or bind mount at `data` (and `backup` if you use backups). Do not mount over the whole `/opt/containers` tree: the entrypoint needs to write generated config into `config` at boot. > [!WARNING] > Chowning is best-effort by design. If you bind-mount a host directory that > is read-only or has a foreign owner, the container still starts — check the > logs for chown warnings instead of assuming a hard failure. ## Built-in backups Several containers ship scheduled backup jobs (PostgreSQL with pgBackRest, MariaDB with scheduled dumps). Backups are ordinary ezx scheduler nodes: they run inside the same container, drop output into `/opt/containers/backup`, and never shell out. Typical setup: ```yaml volumes: - pgdata:/opt/containers/data - pgbackup:/opt/containers/backup environment: PGBACKREST_ENABLED: "true" ``` Per-container pages (see [PostgreSQL](/postgresql#backups)) list the exact variables: schedule, retention, and repository configuration. ## Restoring Backups are plain upstream formats — `pgbackrest restore`, `mariadb-dump` output — so any standard restore procedure works. There is no proprietary format to reverse. --- title: PostgreSQL group: Databases url: https://docs.containers.supanadit.com/postgresql.md --- # PostgreSQL PostgreSQL 13 with the extensions and tooling that production deployments actually need, baked into one image: PgBouncer for connection pooling, pgBackRest for backups, Patroni for HA, and a curated extension set (pgAudit, Citus, pg_cron, pg_repack and friends). Everything is configured through environment variables — the entrypoint generates `postgresql.conf` and `pg_hba.conf` at boot, so there is no config to mount. ## Quickstart ```bash docker run -d \ --name postgres \ -e POSTGRES_DB=app \ -e POSTGRES_PASSWORD=secret \ -v postgres-data:/opt/containers/data \ -p 5432:5432 \ ghcr.io/supanadit/containers/postgresql ``` Or with Compose, plus PgBouncer on port 6432: ```yaml services: postgres: image: ghcr.io/supanadit/containers/postgresql environment: POSTGRES_DB: app POSTGRES_PASSWORD: secret PGBOUNCER_ENABLE: "true" volumes: - postgres-data:/opt/containers/data ports: - "5432:5432" - "6432:6432" volumes: postgres-data: ``` Data lives in `/opt/containers/data` (this is `PGDATA`), generated config in `/opt/containers/config`, and pgBackRest output in `/opt/containers/backup` — see [Volumes](/volumes). ## Configuration | Variable | Default | Description | | --- | --- | --- | | `POSTGRES_DB` | `postgres` | Database created on first boot. | | `POSTGRES_USER` | `postgres` | Superuser name (also the OS user). | | `POSTGRES_PASSWORD` | *(unset)* | Superuser password. With the default `POSTGRES_HOST_AUTH_METHOD=trust` it is optional, but set it for anything real. | | `POSTGRES_HOST_AUTH_METHOD` | `trust` | `pg_hba.conf` auth method for host connections (`md5`, `scram-sha-256`, ...). | | `POSTGRESQL_PORT` | `5432` | Port PostgreSQL listens on. | | `POSTGRESQL_TIMEZONE` | `UTC` | Server timezone. | | `POSTGRES_INITDB_ARGS` | *(empty)* | Extra arguments passed to `initdb` on first boot. | | `POSTGRES_INITDB_WALDIR` | *(empty)* | Place `pg_wal` on a separate path (e.g. a dedicated volume). | | `LOG_LEVEL` | `INFO` | Entrypoint log verbosity. | | `SLEEP_MODE` | `false` | Validate and set up directories, then idle — for debugging the entrypoint. | ### Connection pooling (PgBouncer) Set `PGBOUNCER_ENABLE=true` to start PgBouncer alongside PostgreSQL. | Variable | Default | Description | | --- | --- | --- | | `PGBOUNCER_ENABLE` | `false` | Run PgBouncer in the same container. | | `PGBOUNCER_LISTEN_PORT` | `6432` | PgBouncer listen port. | | `PGBOUNCER_POOL_MODE` | `transaction` | `session`, `transaction` or `statement`. | | `PGBOUNCER_MAX_CLIENT_CONN` | `100` | Maximum client connections. | | `PGBOUNCER_DEFAULT_POOL_SIZE` | `20` | Connections kept per user/database pair. | | `PGBOUNCER_AUTH_TYPE` | `md5` | Client authentication type. | ### Replication and HA | Variable | Default | Description | | --- | --- | --- | | `HA_MODE` | *(empty)* | Enables the native streaming-replication code paths when set. | | `REPLICATION_ROLE` | *(empty)* | `primary` or `standby`. | | `PRIMARY_HOST` / `PRIMARY_PORT` | *(empty)* / `5432` | Where a standby finds its primary. | | `REPLICATION_USER` / `REPLICATION_PASSWORD` | `replicator` / *(empty)* | Replication credentials. | | `REPLICATION_SLOT` | *(empty)* | This standby's physical replication slot. | | `REPLICATION_SLOTS` | *(empty)* | Comma-separated slots a primary ensures exist, one per standby. | | `PATRONI_ENABLE` | `false` | Run Patroni instead of a bare `postgres` node (uses `PATRONI_*` variables and an optional `ETCD_*` DCS). | | `CITUS_ENABLE` | `false` | Load the Citus extension; `CITUS_ROLE` (`coordinator`/`worker`) shapes the config. | ### Backups (pgBackRest) | Variable | Default | Description | | --- | --- | --- | | `PGBACKREST_ENABLE` | `false` | Generate `/etc/pgbackrest.conf` and enable WAL archiving. | | `PGBACKREST_STANZA` | `default` | pgBackRest stanza name. | | `PGBACKREST_AUTO_ENABLE` | `false` | Schedule automatic full/incremental backups. | | `PGBACKREST_ARCHIVE_ENABLE` | `true` | Enable WAL archiving to the repository. | | `PGBACKREST_REPO_TYPE` | *(empty)* | Repository type: `s3`, `gcs` or `sftp` (default is a local repo under `/opt/containers/backup`). | ## Health The entrypoint registers `/health` (composite) and `/health/comprehensive` (detail) on a loopback health server (`EZX_HEALTH_ADDR`, `127.0.0.1:8080` in this image) before PostgreSQL starts, so the endpoint reflects real readiness — including PgBouncer and Patroni when enabled. ```bash curl -s http://127.0.0.1:8080/health/comprehensive # {"healthy":true,"checks":{...}} ``` ## Extensions The image bundles a curated extension set, built from pinned sources in [forge.toml](https://github.com/supanadit/containers/blob/main/docker/postgresql/forge.toml): pgAudit, pg_stat_monitor, Citus, pg_cron, hypopg, pg_repack, pgmetrics and the decoderbufs logical-decoding plugin. Enable them per-database as usual: ```sql CREATE EXTENSION pg_stat_monitor; CREATE EXTENSION pg_cron; ``` > [!TIP] > For a full HA stack, pair this image with Patroni (`PATRONI_ENABLE=true`) > and take backups with pgBackRest — see > [Configuration](/configuration) for how the env vars become > `postgresql.conf` and `pgbackrest.conf`. --- title: MariaDB group: Databases url: https://docs.containers.supanadit.com/mariadb.md --- # MariaDB MariaDB 11.8 built from source with Galera 26.4 for multi-master clustering. The entrypoint generates `my.cnf` from environment variables at boot, creates the database and application user on first start, and can schedule mariabackup jobs with optional S3/SFTP upload — no init scripts to mount. ## Quickstart ```bash docker run -d \ --name mariadb \ -e MARIADB_ROOT_PASSWORD=secret \ -e MARIADB_DATABASE=app \ -e MARIADB_USER=app \ -e MARIADB_PASSWORD=app-secret \ -v mariadb-data:/opt/containers/data \ -p 3306:3306 \ ghcr.io/supanadit/containers/mariadb ``` Or with Compose: ```yaml services: mariadb: image: ghcr.io/supanadit/containers/mariadb environment: MARIADB_ROOT_PASSWORD: secret MARIADB_DATABASE: app MARIADB_USER: app MARIADB_PASSWORD: app-secret volumes: - mariadb-data:/opt/containers/data ports: - "3306:3306" volumes: mariadb-data: ``` ## Configuration | Variable | Default | Description | | --- | --- | --- | | `MARIADB_ROOT_PASSWORD` | *(empty)* | Root password. When empty, `root@localhost` keeps unix_socket auth (combine with `MARIADB_ALLOW_EMPTY_PASSWORD=yes` for the socket-less case). | | `MARIADB_DATABASE` | *(empty)* | Database created on first boot. | | `MARIADB_USER` / `MARIADB_PASSWORD` | *(empty)* | Application account created with full grants on `MARIADB_DATABASE`. | | `MARIADB_ALLOW_EMPTY_PASSWORD` | `no` | `yes` permits an empty root password (development only). | | `MARIADB_PORT` | `3306` | Listen port. | | `MARIADB_BIND_ADDRESS` | `0.0.0.0` | Listen address. | | `MARIADB_MAX_CONNECTIONS` | `151` | `max_connections`. | | `MARIADB_MAX_ALLOWED_PACKET` | `256M` | `max_allowed_packet`. | | `MARIADB_CHARACTER_SET_SERVER` | `utf8mb4` | Default character set. | | `MARIADB_COLLATION_SERVER` | `utf8mb4_unicode_ci` | Default collation. | | `MARIADB_INNODB_BUFFER_POOL_SIZE` | `256M` | `innodb_buffer_pool_size` — raise this for real workloads. | | `MARIADB_INNODB_LOG_FILE_SIZE` | `64M` | `innodb_log_file_size`. | | `MARIADB_SLOW_QUERY_LOG` | `1` | Slow query log on by default; `MARIADB_LONG_QUERY_TIME` (default `2` s) sets the threshold. | | `MARIADB_LOG_BIN` | `1` | Binary logging on by default, expiring after `MARIADB_EXPIRE_LOGS_DAYS` (default `7`) days. | | `MARIADB_TIMEZONE` | `SYSTEM` | Server timezone; timezone tables are preloaded at build time. | ### TLS | Variable | Default | Description | | --- | --- | --- | | `MARIADB_SSL_ENABLE` | `false` | Require TLS. The 11.8 client requests TLS by default, so servers running with `ssl=0` reject TLS clients. | | `MARIADB_SSL_CERT_DIR` | `/opt/containers/config/ssl` | Directory holding `ca.crt`, server cert and key. | ### Galera cluster | Variable | Default | Description | | --- | --- | --- | | `MARIADB_GALERA_ENABLE` | `false` | Enable the WSREP/Galera provider (bundled as `libgalera_smm.so`). | | `MARIADB_GALERA_CLUSTER_NAME` | `galera_cluster` | Cluster name; every node must match. | | `MARIADB_GALERA_SEEDS` | *(empty)* | Comma-separated `gcomm://` seed list of peer nodes. | | `MARIADB_GALERA_SST_METHOD` | `mariabackup` | State snapshot transfer method. | | `MARIADB_GALERA_SERVER_ID` | `1` | Unique server id per node. | | `MARIADB_NODE_ADDRESS` | *(empty)* | This node's address for cluster communication. | ### Backups | Variable | Default | Description | | --- | --- | --- | | `MARIADB_BACKUP_ENABLE` | `false` | Register the scheduled backup node. | | `MARIADB_BACKUP_METHOD` | `mariabackup` | Backup engine. | | `MARIADB_BACKUP_SCHEDULE` | `0 2 * * *` | Cron schedule for full backups. | | `MARIADB_BACKUP_INCR_SCHEDULE` | *(empty)* | Optional cron schedule for incremental backups extending the newest chain. | | `MARIADB_BACKUP_RETENTION_DAYS` | `7` | Local retention window. | | `MARIADB_BACKUP_S3_BUCKET` / `_REGION` / `_ENDPOINT` | *(empty)* / `us-east-1` / *(empty)* | Upload backups to S3-compatible storage. | | `MARIADB_BACKUP_SFTP_HOST` / `_USER` / `_KEY` / `_DEST` | *(empty)* / `backup` / *(empty)* / `/backups` | Upload backups over SFTP. | ## Health `/health` (composite) and `/health/comprehensive` (detail) are registered on the loopback health server (`EZX_HEALTH_ADDR`, `127.0.0.1:8080` in this image). The composite check runs a real `mariadb-admin ping` over the unix socket; the detail view also reports Galera status, disk headroom and process/zombie state. ```bash curl -s http://127.0.0.1:8080/health/comprehensive # {"healthy":true,"checks":{"mariadb":true,"galera":true,"disk":true,"process":true}} ``` ## MaxScale service account When both `MAXSCALE_SERVICE_USER` and `MAXSCALE_SERVICE_PASSWORD` are set, the entrypoint creates that account with the grants MaxScale's monitor needs — ready for [MaxScale](/maxscale) to proxy this server. > [!TIP] > Put MaxScale in front of a Galera cluster for read/write splitting: set > the same `MAXSCALE_SERVICE_USER`/`PASSWORD` on every MariaDB node, then > point MaxScale's monitor at the node list — see > [Configuration](/configuration) for the general env-var model. --- title: Pgpool-II group: Databases url: https://docs.containers.supanadit.com/pgpool-ii.md --- # PgPool-II PgPool-II 4.6 in front of your [PostgreSQL](/postgresql) fleet: connection pooling, read/write load balancing, streaming-replication awareness and watchdog-based HA for the pool itself. The entrypoint generates `pgpool.conf` and `pool_hba.conf` from environment variables and builds `pool_passwd` with `pg_enc`, so PgPool can authenticate to backends using SCRAM — the part a hand-rolled setup usually gets wrong. ## Quickstart `PGPOOL_BACKENDS` is the only required variable — a comma-separated list of `host:port` backends: ```bash docker run -d \ --name pgpool \ -e PGPOOL_BACKENDS=172.10.10.5:5432,172.10.10.6:5432 \ -e PGPOOL_BACKEND_USER=postgres \ -e PGPOOL_BACKEND_PASSWORD=secret \ -v pgpool-config:/opt/containers/config \ -p 5432:5432 \ ghcr.io/supanadit/containers/pgpool-ii ``` Or with Compose: ```yaml services: pgpool: image: ghcr.io/supanadit/containers/pgpool-ii environment: PGPOOL_BACKENDS: "pg1:5432,pg2:5432" PGPOOL_BACKEND_USER: postgres PGPOOL_BACKEND_PASSWORD: secret ports: - "5432:5432" ``` Clients connect to PgPool as if it were PostgreSQL; the PCP control interface listens on `PGPOOL_PCP_PORT` (default `9898`). ## Configuration | Variable | Default | Description | | --- | --- | --- | | `PGPOOL_BACKENDS` | *(required)* | Comma-separated `host:port` backend list, in follow-master order. | | `PGPOOL_BACKEND_WEIGHTS` | *(empty)* | Per-backend load-balancing weights. | | `PGPOOL_BACKEND_FLAGS` | *(empty)* | Per-backend flags (e.g. mark the primary / disable). | | `PGPOOL_PORT` | `5432` | Client-facing listen port. | | `PGPOOL_PCP_PORT` | `9898` | PCP control protocol port. | | `PGPOOL_BACKEND_USER` / `PGPOOL_BACKEND_PASSWORD` | `postgres` / *(empty)* | Credentials PgPool uses to reach backends. | | `PGPOOL_LOAD_BALANCE_MODE` | `on` | Route reads to standbys. | | `PGPOOL_MASTER_SLAVE_MODE` | `on` | Master/slave operation; `PGPOOL_MASTER_SLAVE_SUB_MODE` defaults to `stream` (streaming replication). | | `PGPOOL_NUM_INIT_CHILDREN` | `32` | Number of pgpool child processes (each serves many clients). | | `PGPOOL_MAX_POOL` | `4` | Cached connections per child. | | `PGPOOL_CONNECTION_LIFE_TIME` | `0` | Seconds before a pooled backend connection is dropped (`0` = never). | | `PGPOOL_ENABLE_POOL_HBA` | `false` | Enable `pool_hba.conf` client authentication. | ### Health and SR checks | Variable | Default | Description | | --- | --- | --- | | `PGPOOL_HEALTH_CHECK_PERIOD` | `10` | Seconds between backend health checks (`0` disables). | | `PGPOOL_HEALTH_CHECK_USER` / `_PASSWORD` | backend credentials | Credentials for the health check. | | `PGPOOL_HEALTH_CHECK_MAX_RETRIES` / `_RETRY_DELAY` | `0` / `1` | Retries before a backend is failed over. | | `PGPOOL_SR_CHECK_PERIOD` | `10` | Seconds between streaming-replication delay checks (`0` disables). | | `PGPOOL_DELAY_THRESHOLD` | `0` | Standbys further behind than this (bytes) are not selected for reads. | ### Watchdog (pool HA) | Variable | Default | Description | | --- | --- | --- | | `PGPOOL_ENABLE_WATCHDOG` | `false` | Enable watchdog; requires `PGPOOL_WD_HOSTNAME` and `PGPOOL_DELEGATE_IP`. | | `PGPOOL_DELEGATE_IP` | *(empty)* | Virtual IP failed over between pgpool nodes. | | `PGPOOL_WD_LIFECHECK_METHOD` | `heartbeat` | `heartbeat`, `external` or `quorum`. | | `PGPOOL_WD_PRIORITY` | `1` | Higher wins the delegate IP. | ### Memory query cache | Variable | Default | Description | | --- | --- | --- | | `PGPOOL_MEMORY_CACHE_MODE` | `false` | Enable the in-memory query cache. | | `PGPOOL_MEMQCACHE_MAXCACHE` | `50` | Maximum cache size (MiB). | | `PGPOOL_MEMQCACHE_EXPIRE_TIME` | `10000` | Cache TTL in seconds. | ### TLS | Variable | Default | Description | | --- | --- | --- | | `PGPOOL_SSL` | `false` | Serve TLS to clients. | | `PGPOOL_SSL_KEY` / `PGPOOL_SSL_CERT` | *(empty)* | Paths to the server key and certificate. | | `PGPOOL_KEY` / `PGPOOL_KEY_FILE` | *(empty)* | AES key (or key file) for `pool_passwd` encryption; one is generated under `/opt/containers/config` if unset. | ## Health The image's Docker `HEALTHCHECK` probes `/health/comprehensive` on the loopback health server (`EZX_HEALTH_ADDR`, `127.0.0.1:8080`). A healthy answer means more than a listening port: the check runs a real `SELECT 1` through PgPool to the backend, so authentication problems surface as unhealthy, not as silent client errors. ```bash curl -s http://127.0.0.1:8080/health/comprehensive # {"healthy":true,"checks":{"pgpool":true,"backendAuth":true}} ``` > [!NOTE] > The comprehensive endpoint needs `PGPOOL_HEALTH_CHECK_PASSWORD` (it falls > back to `PGPOOL_BACKEND_PASSWORD`) to run the backend round-trip. > [!TIP] > Point PgPool at a replicated fleet from the > [PostgreSQL](/postgresql) image (`HA_MODE` + `REPLICATION_ROLE`) and > set `PGPOOL_BACKEND_FLAGS` to pin the primary — see > [Configuration](/configuration) for the general env-var model. --- title: MaxScale group: Databases url: https://docs.containers.supanadit.com/maxscale.md --- # MaxScale MariaDB MaxScale 24.02 as a stateless proxy in front of your [MariaDB](/mariadb) servers: one listener that splits writes from reads, a monitor that tracks the topology, and `maxctrl` over a REST API for everything else. The entrypoint generates `maxscale.cnf` from environment variables at boot, so a working read/write-splitting proxy is about six variables. ## Quickstart ```bash docker run -d \ --name maxscale \ -e MAXSCALE_SERVICE_USER=maxscale \ -e MAXSCALE_SERVICE_PASSWORD=maxscale \ -e MAXSCALE_SERVICE_READWRITE_ROUTES=db1:3306,db2:3306 \ -e MAXSCALE_SERVICE_READCONN_ROUTES=db2:3306 \ -e MAXSCALE_MONITOR_MARIADB_MONITOR_SERVERS=db1:3306,db2:3306 \ -e EZX_HEALTH_ADDR=127.0.0.1:8080 \ -v maxscale-data:/opt/containers/data \ -p 3306:3306 -p 8989:8989 \ ghcr.io/supanadit/containers/maxscale ``` Or with Compose, in front of a MariaDB server: ```yaml services: maxscale: image: ghcr.io/supanadit/containers/maxscale environment: MAXSCALE_SERVICE_USER: maxscale MAXSCALE_SERVICE_PASSWORD: maxscale MAXSCALE_SERVICE_READWRITE_ROUTES: "db:3306" MAXSCALE_SERVICE_READCONN_ROUTES: "db:3306" MAXSCALE_MONITOR_MARIADB_MONITOR_SERVERS: "db:3306" EZX_HEALTH_ADDR: "127.0.0.1:8080" ports: - "3306:3306" - "8989:8989" db: image: ghcr.io/supanadit/containers/mariadb environment: MARIADB_ROOT_PASSWORD: secret MAXSCALE_SERVICE_USER: maxscale MAXSCALE_SERVICE_PASSWORD: maxscale ``` The `MAXSCALE_SERVICE_USER`/`PASSWORD` must match an account with monitor grants on the backends — set the same pair on your MariaDB containers and the entrypoint creates it for you. ## Configuration | Variable | Default | Description | | --- | --- | --- | | `MAXSCALE_SERVICE_READWRITE_ROUTES` | `localhost:3306` | Comma-separated backends for the read-write (writes) route. | | `MAXSCALE_SERVICE_READCONN_ROUTES` | `localhost:3306` | Backends for the read-connection (reads) route. | | `MAXSCALE_MONITOR_MARIADB_MONITOR_SERVERS` | `localhost:3306` | Servers the MariaDB monitor watches for topology/failover. | | `MAXSCALE_SERVICE_USER` / `MAXSCALE_SERVICE_PASSWORD` | `maxscale` / `maxscale` | Monitor account MaxScale uses on the backends. | | `MAXSCALE_PORT` | `3306` | Port of the client-facing read-write listener. | | `MAXSCALE_REST_PORT` | `8989` | REST API port (`maxctrl` talks here). | | `MAXSCALE_THREADS` | `auto` | Worker threads (`auto` sizes from CPUs). | | `MAXSCALE_CONFIG_DIR` | `/opt/containers/config` | Config directory; the generated file is `/etc/maxscale.cnf.d/generated.cnf`. | | `MAXSCALE_DATA_DIR` | `/opt/containers/data` | Persistent state (runtime data, `maxctrl` cache). | | `MAXSCALE_LOG_DIR` | `/opt/containers/logs` | Log output (rotated by ezx). | | `MAXSCALE_READY_MAX_ATTEMPTS` / `_INTERVAL` | `30` / `1` | Readiness probe attempts and seconds between them. | | `SLEEP_MODE` (or `MAXSCALE_SLEEP_MODE`) | `false` | Validate config and idle instead of starting — for debugging the entrypoint. | ## Health `/health` (composite) and `/health/comprehensive` (detail) are registered on the loopback health server (`EZX_HEALTH_ADDR`). The composite check requires the REST API on `MAXSCALE_REST_PORT` to answer; the detail view also reports process state, disk headroom on the data mount and zombies. ```bash curl -s http://127.0.0.1:8080/health/comprehensive # {"healthy":true,"checks":{"maxscale":true,"process":true,"disk":true,"zombies":true}} ``` Inspect the running topology with `maxctrl` (or plain HTTP): ```bash docker exec maxscale maxctrl list servers curl -s --user admin:mariadb http://127.0.0.1:8989/v1/maxscale ``` > [!NOTE] > `EZX_HEALTH_ADDR` is not preset in this image (unlike > [PostgreSQL](/postgresql) and MariaDB) — set it to `127.0.0.1:8080` > to get the `/health` endpoints, otherwise the container still starts with > a warning. > [!TIP] > MaxScale shines in front of a [Galera cluster](/mariadb#galera-cluster): > point all three route/monitor variables at the node list and let the > monitor route around a failed node — see > [Configuration](/configuration) for the general env-var model. --- title: Apache Kafka group: Messaging & coordination url: https://docs.containers.supanadit.com/apache-kafka.md --- # Apache Kafka Apache Kafka 3.9 in KRaft mode — no ZooKeeper. The image generates `server.properties` from environment variables, formats the KRaft storage idempotently on first boot, and supervises the broker with the same health endpoint as every other container in this collection. ## Quickstart ```bash docker run -d \ --name kafka \ -v kafka-data:/opt/containers/data \ -p 9092:9092 \ ghcr.io/supanadit/containers/apache-kafka ``` The defaults give you a single combined broker + controller listening on `PLAINTEXT://:9092` and advertising `PLAINTEXT://localhost:9092` — good for local development. If clients connect from other machines, set the advertised listener to a routable hostname: ```bash docker run -d \ --name kafka \ -e KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://kafka.example.com:9092 \ -v kafka-data:/opt/containers/data \ -p 9092:9092 \ ghcr.io/supanadit/containers/apache-kafka ``` ## Configuration | Variable | Default | Description | | --- | --- | --- | | `KAFKA_NODE_ID` | `1` | KRaft node id; must be unique per node. | | `KAFKA_PROCESS_ROLES` | `broker,controller` | Combined role by default; use `broker` or `controller` alone for split roles. | | `KAFKA_CONTROLLER_QUORUM_VOTERS` | `1@localhost:9093` | KRaft quorum definition, `id@host:port` comma-separated. | | `KAFKA_LISTENERS` | `PLAINTEXT://:9092,CONTROLLER://:9093` | Listener definitions. | | `KAFKA_ADVERTISED_LISTENERS` | `PLAINTEXT://localhost:9092` | Address handed to clients; set a routable host in multi-host setups. | | `KAFKA_LISTENER_SECURITY_PROTOCOL_MAP` | `CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,...` | Maps each listener name to its protocol (`SSL`, `SASL_SSL`, ...). | | `KAFKA_INTER_BROKER_LISTENER_NAME` | `PLAINTEXT` | Listener brokers use to talk to each other. | | `KAFKA_CLUSTER_ID` | *(generated)* | Pin the KRaft cluster id; otherwise generated and persisted. | | `KAFKA_NUM_PARTITIONS` | `1` | Default partitions for auto-created topics. | | `KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR` | `1` | Raise to 3 in a real cluster. | | `KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR` | `1` | Raise to 3 in a real cluster. | | `KAFKA_TRANSACTION_STATE_LOG_MIN_ISR` | `1` | Minimum in-sync replicas for the transaction log. | | `KAFKA_LOG_RETENTION_HOURS` | `168` | How long log segments are kept. | | `KAFKA_LOG_SEGMENT_BYTES` | `1073741824` | Segment rollover size. | | `KAFKA_AUTO_CREATE_TOPICS_ENABLE` | `true` | Allow clients to implicitly create topics. | | `KAFKA_DELETE_TOPIC_ENABLE` | `true` | Allow topic deletion. | | `KAFKA_HEAP_OPTS` | *(empty)* | JVM heap, e.g. `-Xmx1g -Xms1g`. | | `KAFKA_LOG4J_ROOT_LOGLEVEL` | `INFO` | Broker log level (`LOG_LEVEL` also works). | ### TLS Set `KAFKA_SSL_ENABLE=true` and either provide PKCS12/JKS stores or inline PEM. TLS applies to every `SSL`/`SASL_SSL` listener. | Variable | Default | Description | | --- | --- | --- | | `KAFKA_SSL_ENABLE` | `false` | Enables the TLS settings below. | | `KAFKA_SSL_KEYSTORE_TYPE` | `PKCS12` | `PKCS12`, `JKS` or `PEM`. | | `KAFKA_SSL_KEYSTORE_LOCATION` | *(empty)* | Path to the keystore (mount it, e.g. under `/opt/containers/config`). | | `KAFKA_SSL_KEYSTORE_PASSWORD` | *(empty)* | Keystore password. | | `KAFKA_SSL_KEY_PASSWORD` | *(empty)* | Private key password. | | `KAFKA_SSL_TRUSTSTORE_TYPE` | `PKCS12` | Truststore type. | | `KAFKA_SSL_TRUSTSTORE_LOCATION` | *(empty)* | Path to the truststore. | | `KAFKA_SSL_TRUSTSTORE_PASSWORD` | *(empty)* | Truststore password. | | `KAFKA_SSL_CLIENT_AUTH` | `none` | `none`, `required` or `requested` (mTLS). | | `KAFKA_SSL_PROTOCOL` | `TLSv1.3` | TLS protocol. | | `KAFKA_SSL_ENABLED_PROTOCOLS` | `TLSv1.3,TLSv1.2` | Allowed protocol list. | For PEM mode (`KAFKA_SSL_KEYSTORE_TYPE=PEM`) use `KAFKA_SSL_KEYSTORE_KEY`, `KAFKA_SSL_KEYSTORE_CERTIFICATE_CHAIN` and `KAFKA_SSL_TRUSTSTORE_CERTIFICATES` instead of the store files. ### SASL | Variable | Default | Description | | --- | --- | --- | | `KAFKA_SASL_ENABLED_MECHANISMS` | `PLAIN` | Comma-separated mechanisms (`PLAIN`, `SCRAM-SHA-256`, `SCRAM-SHA-512`). | | `KAFKA_SASL_MECHANISM_INTER_BROKER_PROTOCOL` | `PLAIN` | Mechanism for broker-to-broker traffic. | | `KAFKA_SASL_JAAS_CONFIG` | *(empty)* | Global JAAS config applied to every SASL listener; per-listener overrides use `KAFKA_SASL_JAAS_CONFIG_`. | | `KAFKA_SASL_SCRAM_USERS` | *(empty)* | `user:pass[,user:pass...]`; SCRAM credentials are created via `kafka-configs.sh` once the broker is ready. | > [!TIP] > A typical SASL_SSL listener is three variables: add > `SASL_SSL://:9094` to `KAFKA_LISTENERS`, map it with > `KAFKA_LISTENER_SECURITY_PROTOCOL_MAP`, and advertise it. The security map > already ships the standard entries. ## Health The broker is combined-mode KRaft by default, so the health endpoint composites an Apache ZooKeeper-free broker check: the ezx `/health` API listens on port 8080 and reports the broker listener TCP probe plus KRaft readiness. ```bash curl -s http://localhost:8080/health ``` > [!NOTE] > If `KAFKA_CONTROLLER_QUORUM_VOTERS` points at other nodes, they must all be > reachable before the quorum elects a leader — health stays degraded until > then. See [Health & readiness](/health) for the endpoint contract. ## KRaft bootstrap On first boot the entrypoint resolves a cluster id in this order: `KAFKA_CLUSTER_ID`, then `meta.properties` in the data directory, then the persisted `cluster.id` file, and finally a freshly generated `kafka-storage.sh random-uuid`. Storage is then formatted with `--ignore-formatted`, so restarts are a no-op. Two safety rails matter when mounting volumes: - An explicit `KAFKA_CLUSTER_ID` that disagrees with `meta.properties` in the data directory is fatal — unset it or use a fresh volume. - A non-empty data directory that is not KRaft-formatted refuses to format, so a mis-mounted volume never gets silently wiped. ## Clustering A three-node combined cluster only needs per-node identity and the shared quorum definition: ```yaml services: kafka-1: &kafka image: ghcr.io/supanadit/containers/apache-kafka environment: KAFKA_NODE_ID: "1" KAFKA_CONTROLLER_QUORUM_VOTERS: "1@kafka-1:9093,2@kafka-2:9093,3@kafka-3:9093" KAFKA_ADVERTISED_LISTENERS: "PLAINTEXT://kafka-1:9092" KAFKA_LISTENERS: "PLAINTEXT://:9092,CONTROLLER://:9093" volumes: [kafka-1-data:/opt/containers/data] kafka-2: <<: *kafka environment: KAFKA_NODE_ID: "2" KAFKA_CONTROLLER_QUORUM_VOTERS: "1@kafka-1:9093,2@kafka-2:9093,3@kafka-3:9093" KAFKA_ADVERTISED_LISTENERS: "PLAINTEXT://kafka-2:9092" volumes: [kafka-2-data:/opt/containers/data] kafka-3: <<: *kafka environment: KAFKA_NODE_ID: "3" KAFKA_CONTROLLER_QUORUM_VOTERS: "1@kafka-1:9093,2@kafka-2:9093,3@kafka-3:9093" KAFKA_ADVERTISED_LISTENERS: "PLAINTEXT://kafka-3:9092" volumes: [kafka-3-data:/opt/containers/data] ``` In a multi-node cluster also raise `KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR`, the transaction-state replication factor and `KAFKA_TRANSACTION_STATE_LOG_MIN_ISR` (typically to 3 and 2). ## Metadata backup KRaft removes ZooKeeper but the metadata log still deserves backups. Enable the scheduled snapshot with `KAFKA_METADATA_BACKUP_ENABLE=true`; it runs via the ezx scheduler into `/opt/containers/backup` with `KAFKA_METADATA_BACKUP_SCHEDULE` (cron, default `0 3 * * *`), `KAFKA_METADATA_BACKUP_TIMEZONE` (default `UTC`) and `KAFKA_METADATA_BACKUP_RETENTION_DAYS` (default `7`). ## Next steps - [Configuration](/configuration) — how env vars become config files. - [Volumes](/volumes) — the `/opt/containers/*` layout. - [Apache ZooKeeper](/apache-zookeeper) — only needed for legacy, non-KRaft clients. --- title: Apache ZooKeeper group: Messaging & coordination url: https://docs.containers.supanadit.com/apache-zookeeper.md --- # Apache ZooKeeper Apache ZooKeeper 3.9, env-var driven like every container in this collection: the entrypoint renders `zoo.cfg` from typed variables, supervises the server with [ezx](https://github.com/supanadit/ezx), and exposes the shared composite health endpoint. Use it when you need ZooKeeper itself (legacy Kafka, other coordinators); Kafka 3.9+ images here run KRaft and do not need it. ## Quickstart ```bash docker run -d \ --name zookeeper \ -v zookeeper-data:/opt/containers/data \ -p 2181:2181 \ ghcr.io/supanadit/containers/apache-zookeeper ``` Verify from another container: ```bash echo ruok | nc zookeeper 2181 # imok curl -s http://localhost:8080/health ``` ## Configuration | Variable | Default | Description | | --- | --- | --- | | `ZOOKEEPER_CLIENT_PORT` | `2181` | Client-facing port. | | `ZOOKEEPER_TICK_TIME` | `2000` | Base tick unit in ms; heartbeats and timeouts are multiples of it. | | `ZOOKEEPER_MAX_CLIENT_CNXNS` | `60` | Max concurrent connections per client IP. | | `ZOOKEEPER_ADMIN_ENABLE_SERVER` | `false` | Enables the Jetty admin server (`ZOOKEEPER_ADMIN_SERVER_PORT`). | | `ZOOKEEPER_ADMIN_SERVER_PORT` | `8081` | Admin server port — moved off 8080, which belongs to the ezx health API. | | `ZOOKEEPER_DATA_DIR` | `/opt/containers/data` | Data dir; the transaction log lives in its `log/` subdirectory. | | `ZOOKEEPER_CONFIG_DIR` | `/opt/containers/config` | Where `zoo.cfg` is generated (also honours `ZOOCFGDIR`). | | `ZOOKEEPER_LOG_DIR` | `/opt/containers/logs` | Rotated application logs. | | `ZOOKEEPER_USER` / `ZOOKEEPER_GROUP` | `zookeeper` | Account the server drops to. | | `SLEEP_MODE` | `false` | Start the supervisor but not the server (debugging). | The container owns the data and log directories; ZooKeeper's own `ZOO_*` tuning knobs stay in `zoo.cfg` and are rendered from the variables above. ## Health The health check probes TCP on the client port, so `/health` reports healthy as soon as ZooKeeper accepts client connections. The ezx API listens on port 8080: ```bash curl -s http://localhost:8080/health ``` See [Health & readiness](/health) for the full endpoint contract. ## Ensemble (clustering) A three-node ensemble is the smallest fault-tolerant configuration. The container has no dedicated clustering variables: ZooKeeper ensembles are declared with standard `server.N` lines, so append them by mounting a drop-in fragment alongside the generated config in `/opt/containers/config`: ```yaml services: zk-1: image: ghcr.io/supanadit/containers/apache-zookeeper volumes: - zk-1-data:/opt/containers/data - ./zk-1.cfg:/opt/containers/config/zoo.cfg.d/server.cfg:ro ``` > [!TIP] > Keep an odd number of nodes (3 or 5). The quorum needs a majority to elect a > leader, and the health endpoint of every node stays degraded until one is > elected. ## Next steps - [Configuration](/configuration) — how env vars become config files. - [Volumes](/volumes) — the `/opt/containers/*` layout. - [Apache Kafka](/apache-kafka) — runs KRaft, no ZooKeeper required. --- title: etcd group: Messaging & coordination url: https://docs.containers.supanadit.com/etcd.md --- # etcd etcd 3.6, built from source and supervised by [ezx](https://github.com/supanadit/ezx). Every etcd flag this image exposes is set through environment variables — the entrypoint maps them to command-line flags, keeps `ETCD_*` variables out of the child environment, and serves the shared composite health endpoint. It runs as a dedicated non-root `etcd` user. ## Quickstart ```bash docker run -d \ --name etcd \ -v etcd-data:/opt/containers/data \ -p 2379:2379 \ ghcr.io/supanadit/containers/etcd ``` Write and read a key: ```bash docker exec -it etcd etcdctl put /greeting hello docker exec -it etcd etcdctl get /greeting curl -s http://localhost:8080/health ``` ## Configuration | Variable | Default | Description | | --- | --- | --- | | `ETCD_NAME` | `etcd-node` | Node name; must be unique in a cluster. | | `ETCD_LISTEN_CLIENT_URLS` | `http://0.0.0.0:2379` | URLs clients connect to. | | `ETCD_LISTEN_PEER_URLS` | `http://0.0.0.0:2380` | URLs peers connect to. | | `ETCD_ADVERTISE_CLIENT_URLS` | `http://:2379` | Empty means the container's own IP, resolved at startup. | | `ETCD_INITIAL_ADVERTISE_PEER_URLS` | `http://:2380` | Peer URL advertised to the cluster. | | `ETCD_INITIAL_CLUSTER` | *(this node)* | `name=peerURL` list; set for multi-node clusters. | | `ETCD_INITIAL_CLUSTER_STATE` | `new` | `new` for a fresh cluster, `existing` when joining one. | | `ETCD_INITIAL_CLUSTER_TOKEN` | `etcd-cluster` | Unique cluster token; keeps two clusters from cross-talking. | | `ETCD_HEARTBEAT_INTERVAL` | `1000` | Leader heartbeat in ms. | | `ETCD_ELECTION_TIMEOUT` | `5000` | Follower election timeout in ms. | | `ETCD_SNAPSHOT_COUNT` | `5000` | Raft entries before a backend snapshot. | | `ETCD_AUTO_COMPACTION_RETENTION` | `1` | Auto-compaction retention (passed straight to etcd). | | `ETCD_MAX_REQUEST_BYTES` | `10485760` | Max request size (10 MiB). | | `ETCD_DATA_DIR` | `/opt/containers/data` | Raft log and MVCC store. | | `SLEEP_MODE` | `false` | Start the supervisor but not etcd (debugging). | ### TLS TLS is opt-in per listener; enable `ETCD_CLIENT_CERT_AUTH` (mTLS) to require client certificates on the client port. | Variable | Default | Description | | --- | --- | --- | | `ETCD_CERT_FILE` | *(empty)* | Server certificate for the client listener. | | `ETCD_KEY_FILE` | *(empty)* | Server key for the client listener. | | `ETCD_CLIENT_CERT_AUTH` | `false` | Require client certificates. | | `ETCD_TRUSTED_CA_FILE` | *(empty)* | CA verifying client certificates. | | `ETCD_PEER_CERT_FILE` | *(empty)* | Certificate for the peer listener. | | `ETCD_PEER_KEY_FILE` | *(empty)* | Key for the peer listener. | | `ETCD_PEER_CLIENT_CERT_AUTH` | `false` | Require peer certificates (peer mTLS). | | `ETCD_PEER_TRUSTED_CA_FILE` | *(empty)* | CA verifying peer certificates. | Mount certificates somewhere readable (e.g. `/opt/containers/config/certs`) and reference the paths. ## Health The health check verifies etcd answers on its client port, and the ezx API on port 8080 serves the composite `/health` endpoint: ```bash curl -s http://localhost:8080/health ``` > [!TIP] > Inside the cluster you can also use the native endpoint: > `curl http://localhost:2379/health`. Orchestrators should probe the > composite endpoint — see [Health & readiness](/health). ## Clustering A static three-node cluster sets the initial cluster explicitly. Advertising URLs matter: each node must be reachable from its peers, so fix them instead of relying on the container-IP default. ```yaml services: etcd-1: image: ghcr.io/supanadit/containers/etcd environment: ETCD_NAME: infra1 ETCD_INITIAL_ADVERTISE_PEER_URLS: "http://etcd-1:2380" ETCD_ADVERTISE_CLIENT_URLS: "http://etcd-1:2379" ETCD_INITIAL_CLUSTER: "infra1=http://etcd-1:2380,infra2=http://etcd-2:2380,infra3=http://etcd-3:2380" ETCD_INITIAL_CLUSTER_STATE: "new" volumes: [etcd-1-data:/opt/containers/data] etcd-2: image: ghcr.io/supanadit/containers/etcd environment: ETCD_NAME: infra2 ETCD_INITIAL_ADVERTISE_PEER_URLS: "http://etcd-2:2380" ETCD_ADVERTISE_CLIENT_URLS: "http://etcd-2:2379" ETCD_INITIAL_CLUSTER: "infra1=http://etcd-1:2380,infra2=http://etcd-2:2380,infra3=http://etcd-3:2380" ETCD_INITIAL_CLUSTER_STATE: "new" volumes: [etcd-2-data:/opt/containers/data] ``` > [!NOTE] > The `ETCD_*` variables are stripped from the etcd process environment at > spawn time, so the explicit flags are the single source of truth and a > stray `ETCD_*` variable cannot conflict with them. ## Next steps - [Configuration](/configuration) — how env vars become flags. - [Volumes](/volumes) — the `/opt/containers/*` layout. - [Apache Kafka](/apache-kafka) — KRaft replaces ZooKeeper for Kafka metadata; etcd is not part of the Kafka stack. --- title: Prometheus group: Observability url: https://docs.containers.supanadit.com/prometheus.md --- # Prometheus Prometheus is the metrics collector and TSDB at the heart of most monitoring stacks. This image wraps the upstream binary in the same bootstrap as every container here: you configure it with `PROMETHEUS_*` environment variables, the entrypoint seeds a config file if you have not mounted one, and [ezx](https://github.com/supanadit/ezx) supervises the process, rotates the logs and probes readiness. ## Quickstart ```bash docker run -d \ --name prometheus \ -v prometheus-data:/opt/containers/data \ -v prometheus-config:/opt/containers/config \ -p 9090:9090 \ ghcr.io/supanadit/containers/prometheus ``` With no mounted config, the image seeds the bundled default `prometheus.yml` into `/opt/containers/config` so the container starts out of the box. To bring your own scrape configuration, mount a file at `/opt/containers/config/prometheus.yml`. ```yaml services: prometheus: image: ghcr.io/supanadit/containers/prometheus volumes: - prometheus-data:/opt/containers/data - ./prometheus.yml:/opt/containers/config/prometheus.yml:ro ports: - "9090:9090" volumes: prometheus-data: ``` ## Configuration | Variable | Default | Description | | ----------------------------------------- | -------------------------------------- | ------------------------------------------------------ | | `PROMETHEUS_PORT` | `9090` | Web UI and API listen port. | | `PROMETHEUS_CONFIG_FILE` | `/opt/containers/config/prometheus.yml`| Path to the scrape config. | | `PROMETHEUS_DATA_DIR` | `/opt/containers/data` | TSDB data directory. | | `PROMETHEUS_CONFIG_DIR` | `/opt/containers/config` | Configuration directory. | | `PROMETHEUS_STORAGE_TSDB_RETENTION_TIME` | *(unset)* | TSDB time-based retention (e.g. `15d`). | | `PROMETHEUS_STORAGE_TSDB_RETENTION_SIZE` | *(unset)* | TSDB size-based retention (e.g. `10GB`). | | `PROMETHEUS_STORAGE_TSDB_MIN_BLOCK_DURATION` | *(unset)* | Minimum TSDB block duration. | | `PROMETHEUS_STORAGE_TSDB_MAX_BLOCK_DURATION` | *(unset)* | Maximum TSDB block duration. | | `PROMETHEUS_WEB_CONFIG_FILE` | *(unset)* | TLS/basic-auth web config file (`--web.config.file`). | | `LOG_LEVEL` | `info` | Log verbosity. | Retention and block-duration flags are only added to the command line when their variable is set — unset never becomes an empty `--flag=`. ### Feature toggles Web endpoints and `--enable-feature` gates are plain booleans (`true`/`false`/`1`/`0`/`yes`/`no`): | Variable | Enables | | ------------------------------------------ | ----------------------------------- | | `PROMETHEUS_ENABLE_WEB_LIFECYCLE` | `/-/reload` and `/-/quit` endpoints | | `PROMETHEUS_ENABLE_WEB_REMOTE_WRITE_RECEIVER` | Remote-write receiver API | | `PROMETHEUS_ENABLE_WEB_OTLP_RECEIVER` | OTLP ingestion receiver | | `PROMETHEUS_ENABLE_NATIVE_HISTOGRAM` | `native-histograms` | | `PROMETHEUS_ENABLE_EXEMPLAR_STORAGE` | `exemplar-storage` | | `PROMETHEUS_ENABLE_PROMQL_FUNCTIONS` | `promql-experimental-functions` | | `PROMETHEUS_ENABLE_AUTO_RELOAD_CONFIG` | `auto-reload-config` | That is a selection — the full set of `PROMETHEUS_ENABLE_*` flags lives in `entrypoint.d/bootstrap/env.js` in the [repository](https://github.com/supanadit/containers). ### Readiness tuning | Variable | Default | Description | | ----------------------------------- | ------- | --------------------------------- | | `PROMETHEUS_READY_MAX_ATTEMPTS` | `30` | Readiness probe attempts. | | `PROMETHEUS_READY_ATTEMPT_INTERVAL` | `1` | Seconds between probe attempts. | ## Custom scrape configuration The config file is the only thing Prometheus needs beyond env vars. When the path is the container default and nothing is mounted, the entrypoint copies the bundled default config into place; a custom path you set with `PROMETHEUS_CONFIG_FILE` is never fabricated — if it does not exist, the container fails fast with a validation error instead of guessing. > [!TIP] > Enable `PROMETHEUS_ENABLE_WEB_LIFECYCLE=true` and reload after editing a > mounted config with `curl -X POST http://localhost:9090/-/reload` — no > restart needed. ## Health The composite health endpoint runs on the shared health server (default port `8080`). It reports healthy when Prometheus answers `/-/ready`, the supervised process is alive, the data directory has free space and there are no zombie children. ```bash curl -s http://localhost:8080/health curl -s http://localhost:8080/health/comprehensive | jq ``` ## Next steps - Ship metrics long-term with [Mimir](/mimir) or [Thanos](/thanos). - Visualize them with [Grafana](/grafana). - [Configuration](/configuration) explains the env-var conventions. --- title: Grafana group: Observability url: https://docs.containers.supanadit.com/grafana.md --- # Grafana Grafana is the dashboard and visualization layer for your metrics and logs. This image keeps Grafana's own `GF_*` variables working, normalizes the data, logs and provisioning paths onto the standard `/opt/containers/*` volumes, and lets the [ezx](https://github.com/supanadit/ezx) bootstrap supervise the server, rotate logs and probe `/api/health`. ## Quickstart ```bash docker run -d \ --name grafana \ -v grafana-data:/opt/containers/data \ -p 3000:3000 \ ghcr.io/supanadit/containers/grafana ``` Log in at `http://localhost:3000`. The container seeds a default `grafana.ini` into `/opt/containers/config` when you have not mounted one, so a bare `docker run` works. ```yaml services: grafana: image: ghcr.io/supanadit/containers/grafana environment: GF_SERVER_HTTP_PORT: "3000" GF_SECURITY_ADMIN_PASSWORD: secret volumes: - grafana-data:/opt/containers/data ports: - "3000:3000" volumes: grafana-data: ``` Because `GF_*` variables pass straight through to Grafana, anything Grafana documents natively — including `GF_SECURITY_ADMIN_PASSWORD` — works as-is. ## Configuration | Variable | Default | Description | | ------------------------------- | ---------------------------------------- | -------------------------------------------------- | | `GRAFANA_PORT` | `3000` (or `GF_SERVER_HTTP_PORT`) | HTTP port; also used by the readiness probe. | | `GF_PATHS_DATA` | `/opt/containers/data` | Grafana database and data. | | `GF_PATHS_LOGS` | `/opt/containers/logs` | Log directory. | | `GF_PATHS_CONFIG` | `/opt/containers/config/grafana.ini` | Main config file. | | `GF_PATHS_PROVISIONING` | `/opt/containers/config/provisioning` | Provisioning directory (dashboards, datasources). | | `GF_PATHS_PLUGINS` | `/var/lib/grafana/plugins` | Plugin directory. | | `GF_PATHS_HOME` | `/usr/share/grafana` | Grafana home. | | `GRAFANA_CONFIG_DIR` | `/opt/containers/config` | Configuration volume. | | `GRAFANA_USER` / `GRAFANA_GROUP`| `grafana` | The unprivileged account the server runs as. | | `GRAFANA_PACKAGING` | `docker` | Passed as `--packaging`. | | `GRAFANA_READY_MAX_ATTEMPTS` | `30` | Readiness probe attempts. | | `GRAFANA_READY_ATTEMPT_INTERVAL`| `1` | Seconds between probe attempts. | As with every container here, `env.get` treats unset and empty the same, and booleans accept `true`/`false`/`1`/`0`/`yes`/`no`. ## Provisioning datasources Grafana reads datasources, dashboards and alerting resources from the provisioning directory. Mount your files under `/opt/containers/config/provisioning`: ```yaml volumes: - ./provisioning:/opt/containers/config/provisioning:ro ``` > [!TIP] > Point a datasource at [Prometheus](/prometheus), > [Loki](/loki) or [Mimir](/mimir) running beside it on the same > compose network — e.g. `http://prometheus:9090` — and Grafana picks it up on > boot, no UI clicking required. ## Custom configuration If you mount your own `grafana.ini` at the default path it is used unchanged. If you repoint `GF_PATHS_CONFIG` to a custom path, that file must exist — the container never invents a config at a path you chose, it fails fast instead. ## Health The composite health endpoint runs on the shared health server (default port `8080`): Grafana must answer `/api/health`, the supervised process must be alive, the data directory must have free space and there must be no zombie children. ```bash curl -s http://localhost:8080/health curl -s http://localhost:8080/grafana/health ``` ## Next steps - [Configuration](/configuration) explains the env-var conventions. - Collect logs with [Loki](/loki) and metrics with [Prometheus](/prometheus). --- title: Grafana Loki group: Observability url: https://docs.containers.supanadit.com/loki.md --- # Loki Grafana Loki is a log aggregation system that indexes labels instead of full log lines, making it cheap to run. This image reduces Loki's large YAML configuration to a handful of `GRAFANA_LOKI_*` environment variables: at boot the entrypoint renders the full `loki.yaml` (schema, storage, ring, compactor, ruler) deterministically and starts Loki under [ezx](https://github.com/supanadit/ezx) supervision. ## Quickstart Single-node filesystem Loki: ```bash docker run -d \ --name loki \ -v loki-data:/opt/containers/data \ -p 3100:3100 \ ghcr.io/supanadit/containers/grafana-loki ``` Query it and check readiness: ```bash curl -s http://localhost:3100/ready curl -s http://localhost:8080/health ``` ## Configuration | Variable | Default | Description | | ----------------------------------- | -------------------------------------- | ------------------------------------------------- | | `GRAFANA_LOKI_CONFIG` | `/opt/containers/config/loki.yaml` | Where the generated config is written. | | `GRAFANA_LOKI_DATA_DIR` | `/opt/containers/data` | Chunks, rules and retention data. | | `GRAFANA_LOKI_TARGET` | *(unset = `all`)* | Component target: `read`, `write` or `backend`. | | `GRAFANA_LOKI_LISTEN_PORT_HTTP` | `3100` | HTTP API port. | | `GRAFANA_LOKI_LISTEN_PORT_GRPC` | `9096` | gRPC port. | | `GRAFANA_LOKI_STORAGE_BACKEND` | `filesystem` | `filesystem` or `s3`. | | `GRAFANA_LOKI_MEMBERLIST` | *(unset)* | Comma-separated memberlist peers; enables clustering. | | `GRAFANA_LOKI_INSTANCE_ADDRESS` | *(unset)* | Advertised instance address. | | `GRAFANA_LOKI_COMPACTOR_ADDRESS` | *(unset)* | Compactor address for the cluster. | | `GRAFANA_LOKI_USER` / `_GROUP` | `loki` | Unprivileged account the process runs as. | | `GRAFANA_LOKI_READY_MAX_ATTEMPTS` | `30` | Readiness probe attempts. | | `GRAFANA_LOKI_READY_ATTEMPT_INTERVAL`| `1` | Seconds between probe attempts. | The config is regenerated on every start from these variables — mount a file at `GRAFANA_LOKI_CONFIG`'s path only if you want to inspect it, not to edit it, because Loki is configured from the environment, not the file. ### S3 storage Set `GRAFANA_LOKI_STORAGE_BACKEND=s3` and the object storage moves from the local disk to S3 (or any S3-compatible store such as [MinIO](https://github.com/supanadit/containers)): | Variable | Default | | ----------------------------------- | -------------------- | | `GRAFANA_LOKI_S3_BUCKET` | `loki` | | `GRAFANA_LOKI_S3_ENDPOINT` | `s3.amazonaws.com` | | `GRAFANA_LOKI_S3_ACCESS_KEY` | `your-access-key` | | `GRAFANA_LOKI_S3_SECRET_KEY` | `your-secret-key` | | `GRAFANA_LOKI_S3_REGION` | `us-east-1` | | `GRAFANA_LOKI_S3_FORCE_PATH_STYLE` | `true` | | `GRAFANA_LOKI_S3_INSECURE` | `false` | ```yaml environment: GRAFANA_LOKI_STORAGE_BACKEND: s3 GRAFANA_LOKI_S3_ENDPOINT: minio:9000 GRAFANA_LOKI_S3_BUCKET: loki GRAFANA_LOKI_S3_ACCESS_KEY: minio GRAFANA_LOKI_S3_SECRET_KEY: minio123 GRAFANA_LOKI_S3_INSECURE: "true" ``` ## Clustering Set `GRAFANA_LOKI_MEMBERLIST` to a comma-separated list of peer addresses and the entrypoint renders a `memberlist:` block (port 7946, gossip) and switches the ring KV store from `inmemory` to `memberlist`. Split roles across replicas with `GRAFANA_LOKI_TARGET=read` / `write` / `backend`. ```yaml environment: GRAFANA_LOKI_MEMBERLIST: loki-1:7946,loki-2:7946,loki-3:7946 ``` > [!NOTE] > The generated config uses the TSDB store with schema v13, a single > replication factor by default, an embedded query-results cache and pattern > ingestion — a sane single-binary setup that still scales out with memberlist. ## Health The composite health endpoint runs on the shared health server (default port `8080`): Loki must answer `/ready`, the process must be alive, the data directory must have free space and there must be no zombie children. ```bash curl -s http://localhost:8080/health curl -s http://localhost:8080/loki/ready ``` ## Next steps - Visualize logs with [Grafana](/grafana). - Collect metrics with [Prometheus](/prometheus). - [Volumes](/volumes) explains what to persist. --- title: Grafana Mimir group: Observability url: https://docs.containers.supanadit.com/mimir.md --- # Mimir Grafana Mimir is a horizontally scalable, long-term store for Prometheus metrics. Mimir's full YAML is notoriously long; this image collapses it to a few `MIMIR_*` / `GRAFANA_MIMIR_*` environment variables, renders `mimir.yaml` at boot, and supervises the server with [ezx](https://github.com/supanadit/ezx). Point [Prometheus](/prometheus) remote-write at it and you have durable metrics with a PromQL API. ## Quickstart ```bash docker run -d \ --name mimir \ -v mimir-data:/opt/containers/data \ -p 8080:8080 \ ghcr.io/supanadit/containers/grafana-mimir ``` The generated config lands at `/opt/containers/config/mimir.yaml`, and all state directories (TSDB, bucket-store sync, compactor, ruler) live in subdirectories of the data volume — Mimir refuses overlapping directories, so each gets its own. ## Configuration | Variable | Default | Description | | ------------------------------------ | ------------------------------------ | ------------------------------------------------- | | `MIMIR_TARGET` | `all` | Component set: `all`, `distributor`, `ingester`, `querier`, `query-frontend`, `compactor`, `store-gateway`, `ruler`, ... | | `MIMIR_HTTP_PORT` | `8080` | HTTP API port. | | `GRAFANA_MIMIR_CONFIG` | `/opt/containers/config/mimir.yaml` | Generated config file. | | `GRAFANA_MIMIR_DATA_DIR` | `/opt/containers/data` | State root for all Mimir directories. | | `MIMIR_TSDB_DIR` | `/tsdb` | Local TSDB (ingester) directory. | | `MIMIR_SYNC_DIR` | `/tsdb-sync` | Bucket-store sync directory. | | `MIMIR_COMPACTOR_DIR` | `/compactor` | Compactor data directory. | | `MIMIR_RULER_DIR` | `/ruler` | Ruler data directory. | | `MIMIR_STORAGE_PREFIX` | `blocks` | Prefix for block storage. | | `MIMIR_MEMBER_LIST` | *(unset)* | Comma-separated memberlist peers; enables clustering. | | `MIMIR_INGESTER_REPLICATION_FACTOR` | number of members (or `1`) | Ingester ring replication factor. | | `GRAFANA_MIMIR_EXTRA_ARGS` | *(unset)* | Extra CLI arguments. | | `MIMIR_ACTIVITY_FILE` | `/metrics-activity.log` | Activity tracker file. | | `GRAFANA_MIMIR_READY_MAX_ATTEMPTS` | `30` | Readiness probe attempts. | | `GRAFANA_MIMIR_READY_ATTEMPT_INTERVAL`| `1` | Seconds between probe attempts. | The directory defaults exist because Mimir's own defaults (`./tsdb-sync`, `./data-compactor`, `./metrics-activity.log`) are relative to a working directory the unprivileged `mimir` user cannot write — the container pins them onto the data volume so the process starts first try. ## Clustering Set `MIMIR_MEMBER_LIST` to enable memberlist gossip between replicas, and split components across containers with `MIMIR_TARGET`: ```yaml services: mimir: image: ghcr.io/supanadit/containers/grafana-mimir environment: MIMIR_MEMBER_LIST: mimir-1,mimir-2,mimir-3 ``` The ingester replication factor defaults to the number of members, so a three-node ring replicates each series three times unless you override `MIMIR_INGESTER_REPLICATION_FACTOR`. ## Remote write from Prometheus Prometheus needs its remote-write receiver enabled; then aim it at Mimir: ```yaml services: prometheus: image: ghcr.io/supanadit/containers/prometheus environment: PROMETHEUS_ENABLE_WEB_REMOTE_WRITE_RECEIVER: "true" mimir: image: ghcr.io/supanadit/containers/grafana-mimir ``` In `prometheus.yml`: ```yaml remote_write: - url: http://mimir:8080/api/v1/push ``` > [!TIP] > Query Mimir from [Grafana](/grafana) as a Prometheus datasource at > `http://mimir:8080/prometheus` — Mimir speaks PromQL, so no extra setup. ## Health The composite health endpoint runs on the shared health server. Mimir itself must answer `/ready` on `MIMIR_HTTP_PORT`; the composite also checks the process, free disk on the data volume and zombie reaping. ```bash curl -s http://localhost:8080/ready curl -s http://localhost:8080/health curl -s http://localhost:8080/mimir/ready ``` (When Mimir and the health server share port 8080, the last two URLs resolve to the same server.) ## Next steps - [Prometheus](/prometheus) scrapes and pushes into Mimir. - Prefer Thanos' object-storage approach? See [Thanos](/thanos). --- title: Thanos group: Observability url: https://docs.containers.supanadit.com/thanos.md --- # Thanos Thanos turns a set of Prometheus servers into a globally scalable, highly available metrics system backed by object storage. Thanos ships seven components in one binary, and this image runs any of them: pick one with `THANOS_COMPONENT`, configure it with plain env vars, and the [ezx](https://github.com/supanadit/ezx) bootstrap supervises it — including rendering the object-store config to a `0600` file so credentials never appear in the process list. ## Quickstart A Thanos **query** frontend over one store: ```bash docker run -d \ --name thanos-query \ -e THANOS_COMPONENT=query \ -e THANOS_QUERY_STORE_ADDRESSES=thanos-sidecar:10901 \ -p 9090:10902 \ ghcr.io/supanadit/containers/thanos ``` ## Choosing a component `THANOS_COMPONENT` selects what runs (default `query`): | Value | Role | | ----------------- | --------------------------------------------------------- | | `query` | PromQL federation across sidecars/stores/receivers. | | `query-frontend` | Caching, splitting front end in front of query. | | `sidecar` | Sits next to [Prometheus](/prometheus), ships blocks. | | `store` | Serves object-storage blocks as a store API. | | `receive` | Accepts Prometheus remote write. | | `rule` | Runs recording and alerting rules. | | `compact` | Compacts and downsamples blocks in object storage. | All components listen on `THANOS_HTTP_ADDRESS` (default `0.0.0.0:10902`); the gRPC-serving components (query, sidecar, store, receive, rule) also bind `THANOS_GRPC_ADDRESS` (default `0.0.0.0:10901`). Run one component per container and scale them independently. ## Configuration Common: | Variable | Default | Description | | ------------------------- | ----------------------- | ------------------------------- | | `THANOS_COMPONENT` | `query` | The component to run. | | `THANOS_HTTP_ADDRESS` | `0.0.0.0:10902` | HTTP address. | | `THANOS_GRPC_ADDRESS` | `0.0.0.0:10901` | gRPC address. | | `LOG_LEVEL` | `info` | Log verbosity. | Per component (selected rows): | Variable | Default | Applies to | | --------------------------------------- | ------------------------ | --------------- | | `THANOS_QUERY_STORE_ADDRESSES` | *(unset)* | `query` | | `THANOS_QUERY_ENDPOINTS` | *(unset)* | `query`, `rule` | | `THANOS_REPLICA_LABEL` | *(unset)* | `query` | | `THANOS_QUERY_FRONTEND_DOWNSTREAM_URL` | *(unset)* | `query-frontend`| | `THANOS_SIDECAR_PROMETHEUS_URL` | `http://localhost:9090` | `sidecar` | | `THANOS_SIDECAR_SHIPPER_UPLOAD_COMPACTED` | `false` | `sidecar` | | `THANOS_RECEIVE_HASHRING_FILE` | *(unset)* | `receive` | | `THANOS_RECEIVE_REPLICATION_FACTOR` | `1` | `receive` | | `THANOS_REMOTE_WRITE_ADDRESS` | `0.0.0.0:10903` | `receive` | | `THANOS_COMPACT_RETENTION_RAW` / `_5M` / `_1H` | *(unset)* | `compact` | | `THANOS_COMPACT_DOWNSAMPLING_DISABLE` | `false` | `compact` | | `THANOS_RULE_FILES` | *(unset)* | `rule` | | `THANOS_ALERTMANAGERS_URL` | *(unset)* | `rule` | Comma-separated values (`THANOS_QUERY_STORE_ADDRESSES`, `THANOS_RULE_FILES`, `THANOS_ALERTMANAGERS_URL`, `THANOS_QUERY_ENDPOINTS`) are split into repeated flags. The `receive` component also picks up external labels from `THANOS_RECEIVE_LABELS_` variables. ## Object storage (S3) The sidecar, store, receive, rule and compact components read an objstore config. The simplest path is the S3 shorthand — set all four and the entrypoint renders a `0600` config file at `/opt/containers/config/thanos-objstore.yaml`: | Variable | Default | Description | | -------------------------- | --------- | ------------------------------------ | | `THANOS_S3_BUCKET` | *(unset)* | Bucket name. | | `THANOS_S3_ENDPOINT` | *(unset)* | S3 endpoint (e.g. `minio:9000`). | | `THANOS_S3_ACCESS_KEY` | *(unset)* | Access key. | | `THANOS_S3_SECRET_KEY` | *(unset)* | Secret key. | | `THANOS_S3_INSECURE` | `false` | Use plain HTTP (MinIO on plain HTTP).| | `THANOS_S3_SIGNATURE_V2` | `false` | V2 signatures. | | `THANOS_S3_CA_FILE` | *(unset)* | Custom CA bundle. | ```yaml environment: THANOS_COMPONENT: sidecar THANOS_SIDECAR_PROMETHEUS_URL: http://prometheus:9090 THANOS_S3_BUCKET: thanos THANOS_S3_ENDPOINT: minio:9000 THANOS_S3_ACCESS_KEY: minio THANOS_S3_SECRET_KEY: minio123 THANOS_S3_INSECURE: "true" ``` For non-S3 stores, pass a full config inline via `THANOS_OBJSTORE_CONFIG` or point `THANOS_OBJSTORE_CONFIG_FILE` at a mounted file — both win over the shorthand. > [!NOTE] > Because the S3 credentials land in a `0600` file read with > `--objstore.config-file`, they never appear in `docker inspect` or `ps`. ## A minimal end-to-end stack ```yaml services: prometheus: image: ghcr.io/supanadit/containers/prometheus environment: PROMETHEUS_ENABLE_WEB_LIFECYCLE: "true" sidecar: image: ghcr.io/supanadit/containers/thanos environment: THANOS_COMPONENT: sidecar THANOS_SIDECAR_PROMETHEUS_URL: http://prometheus:9090 THANOS_S3_ENDPOINT: minio:9000 THANOS_S3_BUCKET: thanos THANOS_S3_ACCESS_KEY: minio THANOS_S3_SECRET_KEY: minio123 THANOS_S3_INSECURE: "true" volumes: - prom-data:/opt/containers/data # sidecar reads Prometheus' TSDB query: image: ghcr.io/supanadit/containers/thanos environment: THANOS_COMPONENT: query THANOS_QUERY_STORE_ADDRESSES: sidecar:10901 volumes: prom-data: ``` ## Health The composite health endpoint runs on the shared health server (default port `8080`). The selected component must answer `/-/ready` on its HTTP address; the composite also checks the process, free disk and zombie reaping. ```bash curl -s http://localhost:10902/-/ready curl -s http://localhost:8080/health ``` ## Next steps - [Prometheus](/prometheus) — the scraper the sidecar partners with. - [Mimir](/mimir) — the push-based alternative for long-term storage. - [Grafana](/grafana) — query Thanos as a Prometheus datasource. --- title: WordPress group: Applications url: https://docs.containers.supanadit.com/wordpress.md --- # WordPress WordPress on Apache with PHP, hardened and env-var driven: the entrypoint generates `wp-config.php`, tunes `php.ini`, configures Apache (MPM, security includes, compression) and supervises everything with [ezx](https://github.com/supanadit/ezx). WordPress itself is the immutable image content; your `wp-content` lives on the data volume. ## Quickstart WordPress needs a database. Pair it with [MariaDB](/mariadb) or the upstream `mariadb` image: ```yaml services: db: image: mariadb:11.8 environment: MARIADB_ROOT_PASSWORD: root MARIADB_DATABASE: wordpress MARIADB_USER: wordpress MARIADB_PASSWORD: wordpress volumes: [db-data:/var/lib/mysql] wordpress: image: ghcr.io/supanadit/containers/wordpress depends_on: [db] environment: WORDPRESS_DB_NAME: wordpress WORDPRESS_DB_USER: wordpress WORDPRESS_DB_PASSWORD: wordpress WORDPRESS_DB_HOST: db:3306 volumes: - wordpress-data:/opt/containers/data ports: - "80:80" volumes: db-data: wordpress-data: ``` ## Configuration | Variable | Default | Description | | --- | --- | --- | | `WORDPRESS_DB_NAME` | *(required)* | Database name. | | `WORDPRESS_DB_USER` | *(required)* | Database user. | | `WORDPRESS_DB_PASSWORD` | *(required)* | Database password. | | `WORDPRESS_DB_HOST` | *(required)* | Database host, e.g. `db:3306`. | | `WORDPRESS_DB_CHARSET` | *(empty)* | DB charset (e.g. `utf8mb4`). | | `CUSTOM_TABLE_PREFIX` | *(empty)* | `wp_` table prefix override. | | `IS_HTTPS` | `false` | Site is served over HTTPS behind a proxy. | | `IS_STATELESS` | `false` | Stateless mode: only `wp-content/uploads` is persisted. | | `IS_PROTECT_XMLRPC` | `false` | Block `xmlrpc.php`. | | `IS_PROTECT_WPCONFIG` | `false` | Deny direct access to `wp-config.php`. | | `IS_SECURITY_BASELINE` | `false` | Umbrella for the safe hardening set (see below). | | `IS_COMPRESSION` | `false` | gzip (mod_deflate) response compression. | | `IS_EXPIRES_CACHING` | `false` | mod_expires browser caching for static assets. | | `PHP_MEMORY_LIMIT` | *(default)* | `memory_limit` in `php.ini`, e.g. `256M`. | | `PHP_UPLOAD_MAX_FILESIZE` | *(default)* | e.g. `64M`. | | `APACHE_MPM` | `event` | MPM selection (`event`, `worker`, `prefork`). | | `APACHE_LOW_MEMORY_MODE` | `false` | Conservative MPM defaults for small instances. | | `APACHE_STATUS` | `false` | Enable `mod_status` on the Apache port. | | `APACHE_EXPORTER` | `false` | Run apache_exporter for Prometheus metrics. | | `APACHE_EXPORTER_PORT` | `9117` | apache_exporter listen port. | | `SLEEP_MODE` | `false` | Start the supervisor but not Apache (debugging). | ### Security baseline `IS_SECURITY_BASELINE=true` turns on the whole safe set; each flag can also be toggled independently: | Variable | Default | Description | | --- | --- | --- | | `IS_HIDE_SERVER_INFO` | = baseline | Hide `Server` version tokens. | | `IS_DISABLE_DIRLIST` | = baseline | Disable directory listings. | | `IS_DISABLE_TRACE` | = baseline | Reject HTTP TRACE. | | `IS_SECURITY_HEADERS` | = baseline | Send standard security headers. | | `IS_BLOCK_SENSITIVE_FILES` | = baseline | Block access to dotfiles and sensitive paths. | | `IS_BLOCK_PHP_IN_UPLOADS` | = baseline | Refuse to execute PHP under `uploads/`. | | `IS_SLOWLORIS_PROTECT` | = baseline | RequestTimeout/KeepAlive limits against slow-loris. | | `IS_PROTECT_WPLOGIN` | `false` | Restrict `wp-login.php` to `WP_LOGIN_ALLOW_IPS`. | | `IS_HSTS` | `false` | HSTS header — always explicit opt-in, never part of the umbrella. | | `TRUSTED_PROXY_IPS` | *(empty)* | Proxy IPs trusted for `X-Forwarded-*` handling. | ## Health The container's HEALTHCHECK probes the ezx health API on loopback port 8080 — `/health/comprehensive` must report `"healthy":true`, which composites a TCP check on Apache's port. The API is intentionally loopback-only; publish port 80 for the site itself. ```bash curl -s http://localhost:80/ # the site docker exec wordpress curl -s http://127.0.0.1:8080/health/comprehensive ``` See [Health & readiness](/health) for the composite endpoint contract. ## Stateless mode By default the whole `wp-content` tree is persisted to `/opt/containers/data`. With `IS_STATELESS=true` only `wp-content/uploads` is persisted, so the image can be replaced freely (immutable-content deploys, multiple replicas). Extra files (drop-in plugins, mu-plugins) can be injected at boot with `STATELESS_FILE_=` variables: the entrypoint copies `/content/stateless/` into `wp-content` when it exists. ```yaml wordpress: environment: WORDPRESS_DB_HOST: db:3306 # ... IS_STATELESS: "true" volumes: - wordpress-uploads:/opt/containers/data ``` > [!TIP] > Keep `/opt/containers/data` on a volume in every mode — in stateless mode it > holds the uploads; in stateful mode it holds the whole `wp-content`. ## Performance tuning Compression and browser caching are opt-in so existing deployments are unaffected: ```yaml environment: IS_COMPRESSION: "true" IS_EXPIRES_CACHING: "true" APACHE_BROTLI: "true" # better ratio, more CPU PHP_OPCACHE_MEMORY: "192" # opcache shm size in MB PHP_UPLOAD_MAX_FILESIZE: "64M" ``` `APACHE_EXPIRES_ASSETS` and `APACHE_EXPIRES_HTML` accept Apache `mod_expires` syntax (e.g. `access plus 1 year`); `APACHE_COMPRESS_TYPES` overrides the compressed MIME-type list. ## Monitoring Set `APACHE_STATUS=true` for `mod_status` (server-status on the Apache port) and `APACHE_EXPORTER=true` to run apache_exporter on port 9117 for scraping by [Prometheus](/prometheus). Keep `mod_status` internal unless you also set `APACHE_STATUS_PUBLIC=true`. ## Next steps - [Configuration](/configuration) — how env vars become `wp-config.php`, `php.ini` and `httpd.conf`. - [Volumes](/volumes) — the `/opt/containers/*` layout. - [MariaDB](/mariadb) — the database this image pairs with. --- title: MinIO group: Applications url: https://docs.containers.supanadit.com/minio.md --- # MinIO S3-compatible object storage, built from source because MinIO no longer publishes community-edition binaries — this image compiles the community edition and supervises it with [ezx](https://github.com/supanadit/ezx). The S3 API, the web console, and the shared composite health endpoint are all exposed; everything is configured through environment variables. ## Quickstart ```bash docker run -d \ --name minio \ -e MINIO_ROOT_USER=superadmin \ -e MINIO_ROOT_PASSWORD=supersecretpassword \ -v minio-data:/opt/containers/data \ -p 9000:9000 \ -p 9001:9001 \ ghcr.io/supanadit/containers/minio ``` Point any S3 client at `http://localhost:9000` and the console at `http://localhost:9001`. > [!TIP] > Prefer the file-based variants `MINIO_ROOT_USER_FILE` and > `MINIO_ROOT_PASSWORD_FILE`: an inline `MINIO_ROOT_PASSWORD` is visible to > every process in the container (and in `docker inspect`). When both are set, > the `*_FILE` variables win. ## Configuration | Variable | Default | Description | | --- | --- | --- | | `MINIO_ADDRESS` | `:9000` | S3 API listen address. | | `MINIO_CONSOLE_ADDRESS` | `:9001` | Web console listen address. | | `MINIO_ROOT_USER` | *(empty)* | Root access key (required, inline). | | `MINIO_ROOT_PASSWORD` | *(empty)* | Root secret key (required, inline). | | `MINIO_ROOT_USER_FILE` | *(empty)* | File containing the root access key — preferred over the inline variable. | | `MINIO_ROOT_PASSWORD_FILE` | *(empty)* | File containing the root secret key. | | `MINIO_BROWSER_REDIRECT_URL` | *(derived)* | Console redirect URL; derived from the container IP standalone, must be set explicitly in distributed mode. | | `MINIO_DISTRIBUTED_MODE_ENABLED` | `false` | Enables distributed mode. | | `MINIO_DISTRIBUTED_NODES` | *(empty)* | Drive expansion string, e.g. `http://minio{1...4}/data{1...4}`. | | `MINIO_EXTRA_DRIVES` | *(empty)* | Extra drive paths for single-node multi-drive, e.g. `/opt/containers/data/disk{1...4}`. | | `MINIO_EXTRA_ARGS` | *(empty)* | Comma-separated extra `server` flags, e.g. `--certs-dir,/opt/containers/config/certs`. | | `MINIO_DATA_DIR` | `/opt/containers/data` | Holds one or more drive directories. | | `LOG_LEVEL` | `info` | Container-wide (ezx) log level. | | `MINIO_LOG_LEVEL` | *(empty)* | MinIO's own log level, kept distinct from `LOG_LEVEL`. | | `SLEEP_MODE` | `false` | Start the supervisor but not the server (debugging). | ## Health The composite endpoint proxies MinIO's own readiness and liveness probes (`/minio/health/ready`, `/minio/health/live`) plus a TCP check on the console port, and the ezx API serves it on port 8080: ```bash curl -s http://localhost:8080/health ``` See [Health & readiness](/health) for the endpoint contract. ## TLS MinIO enables TLS when it finds certificates in its certs directory. Point the server at your mounted certificates with the escape hatch: ```bash docker run -d \ --name minio \ -e MINIO_ROOT_USER_FILE=/run/secrets/minio-user \ -e MINIO_ROOT_PASSWORD_FILE=/run/secrets/minio-password \ -e MINIO_EXTRA_ARGS=--certs-dir,/opt/containers/config/certs \ -v minio-certs:/opt/containers/config/certs:ro \ -v minio-data:/opt/containers/data \ -p 9000:9000 -p 9001:9001 \ ghcr.io/supanadit/containers/minio ``` `MINIO_EXTRA_ARGS` (comma-separated) is the only supported way to add MinIO server flags — ezx owns the argv, so there is no shell to append to. ## Distributed mode For erasure-coded, multi-node storage enable distributed mode and describe the pool with a drive expansion string: ```yaml services: minio-1: image: ghcr.io/supanadit/containers/minio environment: MINIO_ROOT_USER: superadmin MINIO_ROOT_PASSWORD: supersecretpassword MINIO_DISTRIBUTED_MODE_ENABLED: "true" MINIO_DISTRIBUTED_NODES: "http://minio{1...4}/opt/containers/data" volumes: [minio-1-data:/opt/containers/data] ``` Run the same configuration on every node (`minio-1` … `minio-4`); each node discovers the others through the expansion string. Set `MINIO_BROWSER_REDIRECT_URL` explicitly to a load-balanced console address so all nodes redirect to the same console. > [!NOTE] > Use at least 4 drives per erasure set for tolerable fault tolerance, and pin > the same `MINIO_ROOT_USER`/`MINIO_ROOT_PASSWORD` across all nodes — they are > the cluster-wide credentials. ## Next steps - [Configuration](/configuration) — how env vars become arguments. - [Volumes](/volumes) — the `/opt/containers/*` layout. --- title: Troubleshooting group: Reference url: https://docs.containers.supanadit.com/troubleshooting.md --- # Troubleshooting ## The container exits immediately Validation runs before any side effect. If `validation.js` rejects the environment — a missing password, contradictory replication settings — the container exits on purpose. Read the first log lines: ```bash docker logs ``` The error names the exact variable that failed. ## The app is up but `/health` is not healthy The composite endpoint only flips to healthy when the application answers on its own protocol. Common causes: - Slow first boot (e.g. initializing a data directory). Give the health check more `retries`/`interval` before assuming failure. - A config value mismatch: the probe speaks the app's protocol, so a bad port or credentials in the environment show up here. - If `EZX_HEALTH_ADDR` is unset, the health server never started — set it and check the startup warning. ## Permission errors on mounted volumes Volumes are chowned best-effort. A `:ro` or foreign-owner bind mount logs a warning instead of aborting the container, but the app inside may not be able to write. Fix the mount owner, or mount at a path the entrypoint does not recurse into. See [Volumes & backups](/volumes). ## Checking what the entrypoint decided The entrypoint writes the generated config under `/opt/containers/config` before starting the app. Diff what is on disk against what you intended: ```bash docker exec -it ls -R /opt/containers/config docker exec -it cat /opt/containers/config/ ``` Each container page names its config file (for example `postgresql.conf`, `server.properties`, `prometheus.yml`). ## Logs rotate away too fast Log rotation is handled by ezx in `/opt/containers/logs`. If you need raw output for a crash, `docker logs` still carries stdout — configure your orchestrator to persist it. ## Still stuck Open a [GitHub issue](https://github.com/supanadit/containers/issues) with: 1. The `docker logs` output from boot. 2. The compose file or `docker run` command (redact secrets). 3. The image tag — `-rX.Y.Z` format.