John O'Keefe 4ab4f9fa90
Release / build-and-push (push) Successful in 3m6s
docs(readme): reconcile quickstart block post-inline-bootstrap
The 12:21 sync (cf73eec2) made the README block a byte-copy of
docker-compose.yml as it stood before the inline db-init landed;
reconcile to the current file: initdb.d mount dropped, inline
hBcescaped bootstrap, PGPORT in db-init env (DB_PORT is a variable in
every connection path). Block verified byte-identical to
docker-compose.yml; no secrets in either version (placeholders only).
2026-10-29 13:12:36 -04:00

Bookhoard logo Bookhoard

A modern self-hosted media library system built with Go, PostgreSQL, HTMX, and Tailwind CSS featuring universal cross-device sync, beautiful dark themes, and comprehensive media management.

✨ Why Bookhoard?

🔄 Universal Sync: Your reading position, bookmarks, highlights, and notes sync automatically between KOReader and the web - with native Kobo sync and mobile apps coming later.

📱 Multi-Library: Organize your ebooks, comics, and manga with per-library folders and smart collections.

🎨 Beautiful UI: 11 gorgeous dark themes with responsive design that works on any device.

🔒 Secure: JWT authentication, bcrypt password hashing, rate limiting, and no passwords on devices.


🚀 Quick Start

Prerequisites

  • Podman (recommended) or Docker
  • 5 minutes of your time

Installation (no source checkout needed)

Create a directory on the Docker host. You only need the compose file, an .env, and an uploads folder — everything else rides the published image.

mkdir bookhoard && cd bookhoard
mkdir uploads

docker-compose.yml — copy this wholesale:

services:
  # PostgreSQL Database
  db:
    image: postgres:15-alpine
    container_name: bookhoard_db
    environment:
      POSTGRES_DB: bookhoard
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: ${DBPASS}
      # PGPORT makes Postgres listen on DB_PORT (kept in sync with the host mapping + app's DATABASE_PORT)
      PGPORT: ${DB_PORT:-5432}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    ports:
      - "127.0.0.1:${DB_PORT:-5432}:${DB_PORT:-5432}"
      - "[::1]:${DB_PORT:-5432}:${DB_PORT:-5432}"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s
    env_file:
      - .env

  # One-shot bootstrap: creates the bookhoard_srv runtime role and
  # transfers object ownership to it (RLS prerequisite). Idempotent —
  # safe on every startup; the app waits for its success. Passwords
  # accept inline values or .env, like the rest of this file.
  # NOTE: plpgsql $$ tags are written $$$$ — compose eats one dollar
  # per pair before the container sees the SQL.
  db-init:
    image: postgres:15-alpine
    container_name: bookhoard_db_init
    environment:
      PGHOST: db
      PGPORT: ${DB_PORT:-5432}
      PGUSER: postgres
      PGDATABASE: bookhoard
      PGPASSWORD: ${DBPASS}
      DBSRVPASS: ${DBSRVPASS}
    entrypoint: ["bash", "-c"]
    command:
      - |
        set -e
        : "${DBSRVPASS:?DBSRVPASS required in .env}"
        psql -v ON_ERROR_STOP=1 -v role_pass="$DBSRVPASS" <<'EOSQL'
        DO $$$$
        BEGIN
          IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'bookhoard_srv') THEN
            CREATE ROLE bookhoard_srv LOGIN;
          END IF;
        END $$$$;
        ALTER ROLE bookhoard_srv LOGIN PASSWORD :'role_pass';
        DO $$$$
        DECLARE r RECORD;
        BEGIN
          FOR r IN SELECT tablename FROM pg_tables WHERE schemaname = 'public' LOOP
            EXECUTE format('ALTER TABLE public.%I OWNER TO bookhoard_srv', r.tablename);
          END LOOP;
          FOR r IN SELECT sequencename FROM pg_sequences WHERE schemaname = 'public' LOOP
            EXECUTE format('ALTER SEQUENCE public.%I OWNER TO bookhoard_srv', r.sequencename);
          END LOOP;
          FOR r IN SELECT p.oid::regprocedure AS fn FROM pg_proc p
                   JOIN pg_namespace n ON p.pronamespace = n.oid
                   WHERE n.nspname = 'public' AND p.prokind = 'f' LOOP
            EXECUTE format('ALTER FUNCTION %s OWNER TO bookhoard_srv', r.fn);
          END LOOP;
        END $$$$;
        ALTER SCHEMA public OWNER TO bookhoard_srv;
        ALTER DATABASE bookhoard OWNER TO bookhoard_srv;
        EOSQL
    depends_on:
      db:
        condition: service_healthy
    restart: "no"

  # Bookhoard Application
  # In production this image is pulled from the Gitea container registry.
  # Override IMAGE_TAG in .env to pin or rollback a specific version (defaults to "latest").
  app:
    image: git.linuxhg.com/bookhoard/bookhoard:${IMAGE_TAG:-latest}
    container_name: bookhoard
    restart: unless-stopped
    environment:
      # Database Configuration
      # Runtime role = bookhoard_srv (non-superuser OWNER, subject to RLS
      # via FORCE). POSTGRES_USER above is the bootstrap account ONLY —
      # the app must NEVER connect as it (superusers bypass RLS).
      DATABASE_HOST: db
      DATABASE_PORT: ${DB_PORT:-5432}
      DATABASE_USER: bookhoard_srv
      DATABASE_PASSWORD: ${DBSRVPASS}
      DATABASE_NAME: bookhoard

      # Application Configuration
      JWT_SECRET: ${JWT_SECRET}
      SERVER_PORT: ${SERVER_PORT:-8765}
      # IMPORTANT: Device sync requires full URL with protocol
      # Local: http://localhost:8765
      # Local network: http://192.168.1.X:8765
      # Domain: https://bookhoard.example.com
      BASE_URL: ${BASE_URL:-http://localhost:8765}
      # Mark session cookies Secure; set true behind a TLS-terminating reverse proxy (Caddy/nginx/traefik)
      COOKIE_SECURE: ${COOKIE_SECURE:-false}

      # Rate Limiting Configuration
      TEST_MODE: ${TEST_MODE:-false}
      RATE_LIMIT_ENABLED: ${RATE_LIMIT_ENABLED:-true}
      REQUESTS_PER_MINUTE: ${REQUESTS_PER_MINUTE:-10}

      # Conversion Service Configuration
      BOOKHOARD_CONVERSION_CACHE_DIR: ${BOOKHOARD_CONVERSION_CACHE_DIR:-/app/cache/kepub}
      BOOKHOARD_CONVERSION_TOOL: ${BOOKHOARD_CONVERSION_TOOL:-/usr/bin/kepubify}
      BOOKHOARD_CONVERSION_CACHE_TTL: ${BOOKHOARD_CONVERSION_CACHE_TTL:-24h}

      # Library Maintenance
      # Days an archived item (file missing from disk for 2+ scans) is kept,
      # with its reading history, before library scans purge it for good.
      # Set 0 to keep archived items until purged manually on the library admin page.
      ARCHIVE_RETENTION_DAYS: ${ARCHIVE_RETENTION_DAYS:-90}

      # System timezone (fallback for server-side time operations)
      TZ: ${TZ:-UTC}
    ports:
      - "${SERVER_PORT:-8765}:${SERVER_PORT:-8765}"
    depends_on:
      db:
        condition: service_healthy
      db-init:
        condition: service_completed_successfully
    volumes:
      - ./uploads:/app/uploads
      - bookhoard_conversion_cache:/app/cache/kepub
    healthcheck:
      test: ["CMD-SHELL", "curl -f http://localhost:${SERVER_PORT:-8765}/health || exit 1"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s

# Named Volumes
volumes:
  postgres_data:
  bookhoard_conversion_cache:

📊 Project Status

Version: 1.0
License: AGPL-3.0
Status: Production-ready ✅


🤝 Contributing

We welcome contributions! Please see docs/developer/development.md for guidelines.


📄 License

AGPL-3.0 - See LICENSE file for details.


Built with ❤️ using Go, PostgreSQL, HTMX, and Tailwind CSS

S
Description
A modern self-hosted media library system built with Go, PostgreSQL, HTMX, and Tailwind CSS featuring universal cross-device sync, beautiful dark themes, and comprehensive media management.
Readme AGPL-3.0
32 MiB
0 Stars 0 Watchers 0 Forks
v2.3.0
Latest
2026-10-29 13:32:15 -04:00
Languages
Go 71.3%
TypeScript 16.1%
templ 10.4%
CSS 1.4%
Python 0.2%
Other 0.4%