From 4c115ccec66472dc633ca3610fe970e42a169a90 Mon Sep 17 00:00:00 2001 From: Nelson Dominguez Date: Tue, 3 Mar 2026 14:51:16 +0100 Subject: [PATCH 1/3] Update README.md file --- README.md | 165 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 162 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0a2a324..3acf2c0 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,167 @@ # minikv -Minimal, S3-compatible distributed key-value store. +Minimal, S3-compatible distributed key-value store. Keys and metadata are stored in LevelDB; values (object bytes) live on nginx WebDAV volume servers. + +## Architecture + +```txt +Client + │ + ▼ +frontend nginx (port 8080) <== X-Accel-Redirect proxy + │ proxy_pass => + ▼ +minikv coordinator (port 3000) <== metadata, routing, replication + │ replicates to => + ├── volume1 nginx (port 8080) <== nginx DAV object storage + ├── volume2 nginx (port 8080) + └── volume3 nginx (port 8080) +``` + +**GET/HEAD flow:** The coordinator looks up the key in LevelDB, probes volume servers to find a live replica, then returns `X-Accel-Redirect` to the frontend nginx. nginx fetches the object body directly from the volume server and streams it to the client - the coordinator is never in the data path. Response headers (`Content-Type`, `Content-Blake3`, `Key-Balance`) come from coordinator metadata. + +**PUT flow:** The coordinator writes a soft-delete sentinel to LevelDB, replicates the object body to all replica volumes, optionally computes a BLAKE3 checksum, then marks the key as fully present. + +## Hashing + +This project uses **BLAKE3** for all content-addressing and volume selection. + +> ⚠️ The hash function used for `key_to_path` and `key_to_volume` determines the physical layout of all stored data. Changing it after data is written is a **breaking change** requiring a full rebalance. + +## Record Wire Format + +Each LevelDB value encodes object metadata as a compact byte string: + +```txt +[DELETED][HASH<64hex>][TYPE|],,... +``` + +- `DELETED` - present if soft-deleted (UNLINK has been called) +- `HASH<64hex>` - BLAKE3-256 hex digest, present when `--checksum` is enabled +- `TYPE|` - MIME type terminated by `|`, present when `Content-Type` was supplied on PUT +- Remaining bytes - comma-separated volume addresses (`host:port/svXX`) + +## HTTP API + +| Method | Path | Description | +| ----------- | -------------------- | ------------------------------------------------------------------------ | +| `PUT` | `/` | Store an object. Supply `Content-Type` header for correct MIME metadata. | +| `GET` | `/` | Retrieve an object (via X-Accel-Redirect or 302). | +| `HEAD` | `/` | Returns metadata headers without body. | +| `DELETE` | `/` | Hard delete. Requires prior `UNLINK` when `--protect` is set. | +| `UNLINK` | `/` | Soft delete. | +| `REBALANCE` | `/` | Move a single key to its ideal volume set. | +| `GET` | `/?list` | List active keys under prefix. | +| `GET` | `/?unlinked` | List soft-deleted keys under prefix. | +| `POST` | `/?uploads` | Initiate S3-style multipart upload. | +| `POST` | `/?uploadId=X` | Complete multipart upload. | +| `POST` | `/?delete` | Batch delete. | + +### Response Headers + +| Header | Present on | Description | +| ---------------- | ---------- | --------------------------------------------------------- | +| `Content-Type` | GET, HEAD | MIME type from stored metadata | +| `Content-Blake3` | GET, HEAD | BLAKE3-256 hex digest of object body | +| `Key-Balance` | GET, HEAD | `balanced` or `unbalanced` | +| `Key-Volumes` | GET, HEAD | Comma-separated list of volume addresses holding replicas | + +## Running with Docker Compose + +```bash +docker compose up --build +``` + +All services start in dependency order: volume nodes => coordinator => frontend nginx. + +The only externally exposed port is **8080** (frontend nginx). Volume nodes and the coordinator are internal to the Docker network. + +### PUT an object + +```bash +curl -X PUT -H "Content-Type: image/png" \ + --data-binary @photo \ + http://localhost:8080/mybucket/photo +``` + +> Always supply `Content-Type` on PUT. It is stored in LevelDB and returned on all subsequent GET/HEAD requests. Objects stored without `Content-Type` will be served as `application/octet-stream`. + +### GET an object + +```bash +curl http://localhost:8080/mybucket/photo -o photo +``` + +### Inspect metadata + +```bash +curl -I http://localhost:8080/mybucket/photo +``` + +### Soft delete then hard delete + +```bash +curl -X UNLINK http://localhost:8080/mybucket/photo +curl -X DELETE http://localhost:8080/mybucket/photo +``` + +## CLI Reference + +```txt +minikv + +Commands: + server Run the HTTP metadata coordinator + rebuild Reconstruct LevelDB from volume server autoindex + rebalance Move all keys to their ideal volume set +``` + +### server + +```txt +--db LevelDB directory [env: MINIKV_DB] +--volumes Volume server addresses [env: MINIKV_VOLUMES] +--replicas Replica count (default: 3) [env: MINIKV_REPLICAS] +--subvolumes Shard count (default: 10) [env: MINIKV_SUBVOLUMES] +--voltimeout Volume probe timeout [env: MINIKV_VOLTIMEOUT] +--port Listen port (default: 3000) [env: MINIKV_PORT] +--public-volumes External volume addresses [env: MINIKV_PUBLIC_VOLUMES] +--fallback Fallback for missing keys [env: MINIKV_FALLBACK] +--protect Require UNLINK before DELETE [env: MINIKV_PROTECT] +--checksum Store BLAKE3 digest on PUT [env: MINIKV_CHECKSUM] +--accel-redirect Use X-Accel-Redirect mode [env: MINIKV_ACCEL_REDIRECT] +-v, --verbose Structured debug logging [env: MINIKV_VERBOSE] +``` + +All flags can be set via environment variables. Duration values accept `1s`, `500ms`. + +### rebuild + +Reconstructs LevelDB by scanning nginx autoindex JSON listings on all volume servers. This is a **destructive** operation. It clears the existing DB before scanning. Use when LevelDB is lost but volume data is intact. + +> `Content-Type` metadata cannot be recovered during rebuild. It exists only in LevelDB, never on volume servers. Objects will be served as `application/octet-stream` until re-PUT. + +### rebalance + +Moves all keys to their ideal volume set as computed by the current `--volumes` list. Run after adding or removing volume servers. + +## Consistency Model + +- **PUT** is atomic at the record level. The key is marked soft-deleted (in-progress sentinel) before any volume write and marked fully present only after all replicas succeed. A crash mid-write leaves a soft-deleted key that can be cleaned up manually. +- **No read-after-write guarantee across replicas.** GET probes volumes in random order and returns the first live replica. +- **Rebalance** clears the stored hash for the moved object. The body is not re-verified during rebalance. +- **Soft delete (UNLINK)** removes the key from client visibility immediately. The object bytes remain on volume servers until a hard DELETE is issued. + +## Content-Type and X-Accel-Redirect + +When `--accel-redirect` is enabled the coordinator returns `X-Accel-Redirect` instead of `302`. The frontend nginx intercepts this, fetches the object body from the volume server internally, and sends it to the client. Because the body comes from nginx's internal subrequest (not the coordinator response), headers are injected via nginx variable persistence: + +1. Coordinator sets `X-Content-Type: image/png` on its response. +2. nginx captures this as `$upstream_http_x_content_type` - a variable that persists across the internal redirect. +3. The `/accel/` location suppresses the volume's `Content-Type` and replaces it with `$upstream_http_x_content_type`. + +In plain `302` mode, the coordinator sets `Content-Type` directly and the client receives it on the HEAD response. The GET redirect goes to the volume server which returns `application/octet-stream` - this is a known limitation of redirect mode. ## License -This project is licensed under the **GNU General Public License v2 (GPLv2)**. -See the full license text in [`LICENSE`](./LICENSE). +This project is licensed under the **GNU General Public License v2 (GPLv2)**. See [`LICENSE`](./LICENSE). From 6609dd1efc2a3278b1273ed28166e412fc0d928c Mon Sep 17 00:00:00 2001 From: Nelson Dominguez Date: Tue, 3 Mar 2026 14:52:05 +0100 Subject: [PATCH 2/3] Add nginx config and setup script --- config/nginx-frontend.conf | 136 +++++++++++++++++++++++++++++++++++++ config/nginx-volume.conf | 57 ++++++++++++++++ volume | 42 ++++++++++++ 3 files changed, 235 insertions(+) create mode 100644 config/nginx-frontend.conf create mode 100644 config/nginx-volume.conf create mode 100644 volume diff --git a/config/nginx-frontend.conf b/config/nginx-frontend.conf new file mode 100644 index 0000000..dc3ca75 --- /dev/null +++ b/config/nginx-frontend.conf @@ -0,0 +1,136 @@ +# ============================================================================= +# X-Accel-Redirect reverse proxy for minikv +# +# DNS RESOLUTION NOTE: +# nginx resolves upstream hostnames at *startup* by default. If the upstream +# (coordinator, volume servers) is not yet in DNS, nginx refuses to start. +# +# To overcome this, we use `resolver` + a variable for every upstream. +# When the upstream is stored in a variable, nginx defers DNS resolution +# to *request time*, so startup succeeds even if backends aren't running yet. +# +# Docker's internal DNS resolver is always at 127.0.0.11. +# ============================================================================= + +worker_processes auto; +error_log /dev/stderr warn; +pid /tmp/nginx-frontend.pid; + +events { + worker_connections 4096; + multi_accept on; +} + +http { + sendfile on; + tcp_nopush on; + tcp_nodelay on; + server_tokens off; + default_type application/octet-stream; + + # Docker's internal DNS — required for runtime upstream resolution. + # `valid=5s` re-resolves every 5 seconds so container restarts are + # picked up quickly without reloading nginx. + resolver 127.0.0.11 valid=5s ipv6=off; + + server { + listen 8080 default_server; + server_name _; + + # ------------------------------------------------------------------ + # Coordinator upstream as a variable — defers DNS to request time. + # Service name matches docker-compose: "minikv" + # ------------------------------------------------------------------ + set $coordinator_upstream "minikv:3000"; + + # ------------------------------------------------------------------ + # Main proxy: all client requests go to the coordinator. + # + # On GET/HEAD the coordinator returns: + # X-Accel-Redirect: /accel/volume1:8080/sv09/a2/38/... + # Content-Type: image/jpeg + # Content-Blake3: + # Key-Balance: balanced + # + # nginx intercepts X-Accel-Redirect and performs an internal + # subrequest, streaming the object body to the client with the + # coordinator's headers intact. + # ------------------------------------------------------------------ + location / { + proxy_pass http://$coordinator_upstream; + + proxy_redirect off; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + # Disable request buffering — required for streaming PUT uploads. + proxy_request_buffering off; + + # Disable response buffering — stream GET bodies directly. + proxy_buffering off; + + # Pass all coordinator metadata headers through to client. + proxy_pass_header Content-Type; + proxy_pass_header Content-Blake3; + proxy_pass_header Key-Balance; + proxy_pass_header Key-Volumes; + } + + # ------------------------------------------------------------------ + # Internal X-Accel-Redirect handler. + # + # URI format: /accel// + # Example: /accel/volume1:8080/sv09/a2/38/bXlib... + # + # `internal` makes this location unreachable by direct client + # requests — only X-Accel-Redirect from the coordinator can + # trigger it. Direct requests return 404. + # + # The upstream is captured into a variable ($vol_upstream) so + # DNS resolution is deferred to request time (same pattern as above). + # ------------------------------------------------------------------ + location ~ ^/accel/([^/]+)/(.*)$ { + internal; + + # Capture volume host:port and path into variables for runtime DNS. + set $vol_upstream $1; + set $vol_path $2; + + proxy_pass http://$vol_upstream/$vol_path; + + # Do not forward client request headers to volume servers. + proxy_pass_request_headers off; + + # --------------------------------------------------------------- + # Content-Type injection via variable persistence. + # + # The coordinator sets X-Content-Type on its response. + # nginx stores this as $upstream_http_x_content_type — a variable + # that persists across the X-Accel-Redirect internal redirect + # (same ngx_http_request_t context). + # + # If the coordinator has no stored Content-Type for this object + # (object was PUT without a Content-Type header, or rebuilt from + # volume data), $upstream_http_x_content_type will be empty. + # In that case we fall back to application/octet-stream rather + # than emitting an empty Content-Type header. + # + # Objects can be re-PUT with Content-Type to populate the field. + # --------------------------------------------------------------- + proxy_hide_header Content-Type; + + # Resolve effective Content-Type: coordinator metadata wins, + # fall back to octet-stream when metadata is absent. + set $effective_ct $upstream_http_x_content_type; + if ($effective_ct = "") { + set $effective_ct "application/octet-stream"; + } + add_header Content-Type $effective_ct always; + + proxy_connect_timeout 5s; + proxy_read_timeout 30s; + proxy_send_timeout 10s; + } + } +} \ No newline at end of file diff --git a/config/nginx-volume.conf b/config/nginx-volume.conf new file mode 100644 index 0000000..8f8eae2 --- /dev/null +++ b/config/nginx-volume.conf @@ -0,0 +1,57 @@ +# Volume server configuration for minikv +# +# Requires: nginx-mod-http-dav-ext (installed via apk in Dockerfile.volume) +# Module path on Alpine 3.19: /usr/lib/nginx/modules/ngx_http_dav_ext_module.so +# +# All volume containers listen on 8080 internally. +# docker-compose maps volume1 => 8001, volume2 => 8002, volume3 => 8003 on the host. +# +# daemon off is passed via CMD in Dockerfile.volume, not here, to avoid +# the duplicate-directive fatal error from some nginx base images. + +load_module /usr/lib/nginx/modules/ngx_http_dav_ext_module.so; + +worker_processes auto; +pcre_jit on; +error_log /dev/stderr error; +pid /tmp/nginx-volume.pid; + +events { + multi_accept on; + accept_mutex off; + worker_connections 4096; +} + +http { + sendfile on; + sendfile_max_chunk 1024k; + tcp_nopush on; + tcp_nodelay on; + + open_file_cache off; + server_tokens off; + default_type application/octet-stream; + + client_max_body_size 0; + client_body_temp_path /tmp/nginx-client-body; + + server { + listen 8080 default_server backlog=4096; + server_name _; + root /data; + + location / { + disable_symlinks off; + + dav_methods PUT DELETE; + dav_access group:rw all:r; + + # Auto-creates parent shard directories on first PUT. + create_full_put_path on; + + # JSON directory listing — required by the rebuild subcommand. + autoindex on; + autoindex_format json; + } + } +} \ No newline at end of file diff --git a/volume b/volume new file mode 100644 index 0000000..b28c004 --- /dev/null +++ b/volume @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Launch a single nginx DAV volume server. +# +# Usage: +# ./scripts/start-volume +# +# Example: +# ./scripts/start-volume /data/disk0 8001 +# ./scripts/start-volume /data/disk1 8002 +# +# The script: +# 1. Creates the volume directory if it doesn't exist. +# 2. Renders the nginx config template with VOLUME_PATH and VOLUME_PORT. +# 3. Launches nginx in the foreground (daemon off). + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CONFIG_TEMPLATE="${SCRIPT_DIR}/../config/nginx-volume.conf" + +if [[ $# -ne 2 ]]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +export VOLUME_PATH="$1" +export VOLUME_PORT="$2" + +if [[ ! -d "${VOLUME_PATH}" ]]; then + echo "Creating volume directory: ${VOLUME_PATH}" + mkdir -p "${VOLUME_PATH}" +fi + +# Create required nginx temp directories. +mkdir -p /tmp/nginx-client-body + +# Render config template into a temp file (envsubst replaces ${VAR}). +RENDERED_CONFIG="$(mktemp /tmp/nginx-volume-XXXX.conf)" +envsubst '${VOLUME_PATH} ${VOLUME_PORT}' < "${CONFIG_TEMPLATE}" > "${RENDERED_CONFIG}" + +echo "Starting nginx volume server on port ${VOLUME_PORT}, serving ${VOLUME_PATH}" +exec nginx -c "${RENDERED_CONFIG}" \ No newline at end of file From 20017deb5179d1fd3ba60e2c8a2084081ea653ff Mon Sep 17 00:00:00 2001 From: Nelson Dominguez Date: Tue, 3 Mar 2026 14:52:50 +0100 Subject: [PATCH 3/3] Add support for Docker --- .dockerignore | 35 +++++++++++ Dockerfile | 105 +++++++++++++++++++++++++++++++ Dockerfile.volume | 18 ++++++ docker-compose.yml | 154 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 312 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 Dockerfile.volume create mode 100644 docker-compose.yml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8532421 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,35 @@ +# Rust build artifacts — never send to Docker daemon +target/ +**/*.rs.bk + +# Git internals +.git/ +.gitignore +.gitattributes + +# Development tooling +.vscode/ +.idea/ +*.code-workspace + +# CI/CD artifacts +.github/ +.gitlab-ci.yml + +# Documentation (not needed in image) +docs/ +*.md +!README.md + +# Test fixtures (not needed in production image) +**/tests/fixtures/ +**/tests/snapshots/ + +# Local dev overrides +docker-compose.override.yml +.env +.env.* + +# Temporary files +*.tmp +*.log \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6340013 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,105 @@ +# ============================================================================= +# minikv - Multi-Stage Dockerfile +# +# Stages: +# 1. chef - installs cargo-chef for dependency caching +# 2. planner - computes the dependency recipe +# 3. builder - compiles dependencies (cached), then the binary +# 4. runtime - minimal distroless image with only the binary +# +# BLAKE3 hashing requires no external C libs - fully pure Rust. +# rusty-leveldb is pure Rust - no libleveldb.so dependency. +# Final image has zero shell, zero package manager, zero attack surface. +# ============================================================================= + +# ----------------------------------------------------------------------------- +# Stage 1: chef +# Installs cargo-chef for layer-cached dependency compilation. +# ----------------------------------------------------------------------------- +FROM rust:1.88-slim-bookworm AS chef + +# Install cargo-chef for reproducible dependency caching +RUN cargo install cargo-chef --locked + +WORKDIR /build + +# ----------------------------------------------------------------------------- +# Stage 2: planner +# Computes the dependency recipe from Cargo.toml + Cargo.lock. +# This layer only re-runs when dependencies change. +# ----------------------------------------------------------------------------- +FROM chef AS planner + +COPY Cargo.toml Cargo.lock ./ +COPY minikv ./minikv +COPY minikv-core ./minikv-core + +RUN cargo chef prepare --recipe-path recipe.json + +# ----------------------------------------------------------------------------- +# Stage 3: builder +# Compiles dependencies first (cached layer), then the application. +# ----------------------------------------------------------------------------- +FROM chef AS builder + +# Build-time dependencies only - no runtime C libs needed. +# rusty-leveldb and blake3 are both pure Rust. +RUN apt-get update && apt-get install -y --no-install-recommends \ + pkg-config \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=planner /build/recipe.json recipe.json + +# Compile dependencies - this layer is cached unless Cargo.toml/lock changes +RUN cargo chef cook --release --recipe-path recipe.json + +# Copy full source and compile the application binary +COPY Cargo.toml Cargo.lock ./ +COPY minikv ./minikv +COPY minikv-core ./minikv-core +COPY config ./config + +# Build release binary +# RUSTFLAGS for correctness: deny unused, warn on unsafe +ENV RUSTFLAGS="-D warnings -D unsafe_code" + +RUN cargo build --release --locked \ + && strip target/release/minikv + +# ----------------------------------------------------------------------------- +# Stage 4: Distroless image runtime +# ----------------------------------------------------------------------------- +FROM gcr.io/distroless/cc-debian12:nonroot AS runtime + +# Metadata +LABEL org.opencontainers.image.title="minikv" +LABEL org.opencontainers.image.description="Tiny distributed key value store in pure Rust" +LABEL org.opencontainers.image.source="https://github.com/ekkolon/minikv" +LABEL org.opencontainers.image.licenses="MIT" + +# Copy the stripped binary from builder +COPY --from=builder /build/target/release/minikv /usr/local/bin/minikv + +# Copy nginx config (used by operators, not the binary itself) +COPY --from=builder /build/config/nginx-volume.conf /etc/minikv/nginx-volume.conf + +# Data directory for LevelDB - must be mounted as a volume in production +# The nonroot user (uid=65532) must own this path +WORKDIR /data + +# Expose the default server port +# Override with: minikv server --port +EXPOSE 3000 + +# Run as nonroot (distroless nonroot image sets this by default) +# UID 65532 - no shell, no sudo, no privilege escalation possible +USER nonroot + +# Default entrypoint - subcommand must be passed at runtime: +# docker run minikv server --port 3000 --db /data --volumes ... +# docker run minikv rebuild ... +# docker run minikv rebalance ... +ENTRYPOINT ["/usr/local/bin/minikv"] + +# No default CMD - operator must provide subcommand explicitly. +# This prevents accidental runs with wrong configuration. \ No newline at end of file diff --git a/Dockerfile.volume b/Dockerfile.volume new file mode 100644 index 0000000..5b8498f --- /dev/null +++ b/Dockerfile.volume @@ -0,0 +1,18 @@ +# ============================================================================= +# nginx volume node for minikv +# +# Uses alpine base so nginx and nginx-mod-http-dav-ext are installed from +# the same apk repo and are guaranteed version-matched. +# +# Package name on Alpine 3.19: nginx-mod-http-dav-ext (NOT nginx-mod-dav-ext) +# ============================================================================= +FROM alpine:3.19 + +RUN apk add --no-cache nginx nginx-mod-http-dav-ext + +RUN mkdir -p /data /tmp/nginx-client-body /tmp/nginx-volume \ + && chown -R nginx:nginx /data /tmp/nginx-client-body /tmp/nginx-volume + +EXPOSE 8080 + +CMD ["nginx", "-c", "/etc/nginx/nginx.conf", "-g", "daemon off;"] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3dcf47e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,154 @@ +# ============================================================================= +# minikv-rs — Docker Compose +# +# All three nginx volume containers listen on port 8080 internally. +# docker-compose maps them to distinct host ports (8001/8002/8003). +# The minikv coordinator addresses them by their container hostnames on 8080. +# +# Usage: +# docker compose up --build # build + start everything +# docker compose run minikv rebuild +# docker compose run minikv rebalance +# ============================================================================= + +services: + # --------------------------------------------------------------------------- + # Frontend nginx — public entry point, handles X-Accel-Redirect + # --------------------------------------------------------------------------- + frontend: + image: nginx:1.25-alpine + ports: + - "8080:8080" + volumes: + - ./config/nginx-frontend.conf:/etc/nginx/nginx.conf:ro + depends_on: + minikv: + condition: service_started + networks: + - minikv-net + restart: unless-stopped + + minikv-init: + image: busybox:1.36 + command: [ "sh", "-c", "chown -R 65532:65532 /data" ] + volumes: + - minikv-db:/data + restart: "no" + minikv: + build: + context: . + dockerfile: Dockerfile + target: runtime + image: minikv:local + container_name: minikv + command: + - server + - --port=3000 + - --db=/data/db + # Internal addresses — used by coordinator to replicate object writes + - --volumes=volume1:8080,volume2:8080,volume3:8080 + # Public addresses — returned in Location headers to clients + # Must map 1:1 to --volumes in the same order + - --public-volumes=localhost:8001,localhost:8002,localhost:8003 + - --replicas=3 + - --subvolumes=10 + - --voltimeout=1s + - --protect + - --checksum + # Enable X-Accel-Redirect: GET/HEAD returns X-Accel-Redirect header + # instead of 302. The frontend nginx intercepts it and streams the + # object body with the correct Content-Type from stored metadata. + - --accel-redirect + ports: + - "3000:3000" + volumes: + - minikv-db:/data/db + depends_on: + minikv-init: + condition: service_completed_successfully + volume1: + condition: service_started + volume2: + condition: service_started + volume3: + condition: service_started + environment: + RUST_LOG: "minikv=info,tower_http=debug" + restart: unless-stopped + networks: + - minikv-net + + volume1: + build: + context: . + dockerfile: Dockerfile.volume + image: minikv-volume:local + container_name: volume1 + ports: + - "8001:8080" # localhost:8001 → volume1:8080 (matches --public-volumes[0]) + volumes: + - ./config/nginx-volume.conf:/etc/nginx/nginx.conf:ro + - volume1-data:/data + networks: + - minikv-net + healthcheck: + # Lightweight TCP check — confirms the coordinator is accepting connections. + test: [ "CMD-SHELL", "wget -qO- http://localhost:3000/ 2>&1 | grep -qv 'Connection refused'" ] + interval: 5s + timeout: 2s + retries: 5 + restart: unless-stopped + + volume2: + build: + context: . + dockerfile: Dockerfile.volume + image: minikv-volume:local + container_name: volume2 + ports: + - "8002:8080" # localhost:8002 → volume2:8080 (matches --public-volumes[1]) + volumes: + - ./config/nginx-volume.conf:/etc/nginx/nginx.conf:ro + - volume2-data:/data + networks: + - minikv-net + healthcheck: + test: [ "CMD", "wget", "-qO-", "http://localhost:8080/" ] + interval: 5s + timeout: 2s + retries: 5 + restart: unless-stopped + + volume3: + build: + context: . + dockerfile: Dockerfile.volume + image: minikv-volume:local + container_name: volume3 + ports: + - "8003:8080" # localhost:8003 → volume3:8080 (matches --public-volumes[2]) + volumes: + - ./config/nginx-volume.conf:/etc/nginx/nginx.conf:ro + - volume3-data:/data + networks: + - minikv-net + healthcheck: + test: [ "CMD", "wget", "-qO-", "http://localhost:8080/" ] + interval: 5s + timeout: 2s + retries: 5 + restart: unless-stopped + +volumes: + minikv-db: + driver: local + volume1-data: + driver: local + volume2-data: + driver: local + volume3-data: + driver: local + +networks: + minikv-net: + driver: bridge \ No newline at end of file