From fce71bb946be432d1d10564ef25b01652807e4d1 Mon Sep 17 00:00:00 2001 From: Masahiko AMANO Date: Thu, 11 Jun 2026 11:26:25 +0300 Subject: [PATCH] feat(project): add Docker Compose with flexible storage and DB modes Bundle the app + Postgres into a compose stack on top of the existing image. - app: builds the image, publishes ${APP_PORT:-42776}, reads .env, pins STATIC_DIR so SPA serving can't be disabled by an empty value - db: postgres:14-alpine under the "with-db" profile; toggle it off via COMPOSE_PROFILES to point the app at a Postgres on the host instead (host.docker.internal), with depends_on required:false so it stays optional Storage and the DB data dir each default to a named volume but can be bind mounted to a host folder via FILES_DIR / THUMBS_DIR / IMPORT_DIR / DB_DIR. Add PUID/PGID (via user:) so bind-mounted folders are writable by the non-root container. Run the container as a dedicated non-root user "tanabata" with uid/gid 42776, reusing the project's signature number (also the default port). Document every variable in .env.example. Co-Authored-By: Claude Opus 4.8 --- .dockerignore | 4 ++ .env.example | 71 +++++++++++++++++++++++++++---- Dockerfile | 12 +++--- docker-compose.yml | 103 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 175 insertions(+), 15 deletions(-) create mode 100644 docker-compose.yml diff --git a/.dockerignore b/.dockerignore index 94f1424..feada8e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,6 +6,10 @@ .gitignore **/.DS_Store +# Compose file — used to build/run, not needed inside the image context +docker-compose.yml +docker-compose.*.yml + # Secrets and local env .env .env.* diff --git a/.env.example b/.env.example index 9bffdc1..b830272 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,46 @@ # ============================================================================= # Tanabata File Manager — environment variables -# Copy to .env and fill in the values. +# +# Copy to .env and fill in the secrets: +# cp .env.example .env +# docker compose up -d --build # ============================================================================= +# --------------------------------------------------------------------------- +# Docker Compose (read by the compose CLI, ignored by the app) +# --------------------------------------------------------------------------- +# Profiles to enable. "with-db" runs the bundled Postgres container. Leave +# EMPTY to skip it and use a Postgres running on the host instead — then point +# DATABASE_URL at host.docker.internal (see the Database section below). +COMPOSE_PROFILES=with-db + +# Host port the app is published on. The container always listens on 42776. +APP_PORT=42776 + +# --------------------------------------------------------------------------- +# Volume mounts (Docker Compose; ignored by the app) +# --------------------------------------------------------------------------- +# By default the app's data and the database live in named Docker volumes +# (app_files, app_thumbs, app_import, db_data). To keep them in specific folders +# on the host instead, point any of these at a host path — absolute, or relative +# to this file (e.g. ./data/files). Unset = named volume. +# FILES_DIR=/var/lib/tanabata/files +# THUMBS_DIR=/var/lib/tanabata/thumbs +# IMPORT_DIR=/var/lib/tanabata/import +# DB_DIR=/var/lib/tanabata/db + +# When bind-mounting the app folders above, the container must be able to write +# to them. Set PUID/PGID to the owner of those folders and create them with +# matching ownership first, e.g.: +# sudo mkdir -p /var/lib/tanabata/{files,thumbs,import} +# sudo chown -R 1000:1000 /var/lib/tanabata +# PUID=1000 +# PGID=1000 +# Defaults match the image's tanabata user (42776), which owns the named volumes. The +# DB folder is handled by Postgres itself and needs no PUID/PGID. +# PUID=42776 +# PGID=42776 + # --------------------------------------------------------------------------- # Server # --------------------------------------------------------------------------- @@ -21,10 +59,25 @@ ADMIN_PASSWORD=change-me-before-first-run # --------------------------------------------------------------------------- # Database # --------------------------------------------------------------------------- -DATABASE_URL=postgres://tanabata:password@localhost:5432/tanabata?sslmode=disable +# Credentials for the bundled Postgres container (the "with-db" profile). +# Keep these in sync with DATABASE_URL below. +POSTGRES_DB=tanabata +POSTGRES_USER=tanabata +POSTGRES_PASSWORD=password + +# Connection string the app uses. Pick ONE to match your database mode: +# +# • Bundled container DB (COMPOSE_PROFILES=with-db) — host is the "db" service: +DATABASE_URL=postgres://tanabata:password@db:5432/tanabata?sslmode=disable +# +# • Postgres on the host (COMPOSE_PROFILES empty): +# DATABASE_URL=postgres://tanabata:password@host.docker.internal:5432/tanabata?sslmode=disable +# +# • Bare-metal `go run` (no Docker): +# DATABASE_URL=postgres://tanabata:password@localhost:5432/tanabata?sslmode=disable # --------------------------------------------------------------------------- -# Storage +# Storage (paths inside the container; backed by named volumes in compose) # --------------------------------------------------------------------------- FILES_PATH=/data/files THUMBS_CACHE_PATH=/data/thumbs @@ -48,9 +101,9 @@ IMPORT_PATH=/data/import # --------------------------------------------------------------------------- # Static SPA # --------------------------------------------------------------------------- -# Directory of the built frontend (index.html, _app/, fonts, service worker). -# When set, the server serves the SPA and the API on the same port, with a -# fallback to index.html for client-side routes. Leave empty in local -# development — the Vite dev server serves the UI separately. The Docker image -# sets this to /app/static. -STATIC_DIR= +# Leave UNSET here. The Docker image already serves the built SPA from +# /app/static and compose pins STATIC_DIR for the container — an empty value in +# .env would be injected into the container and disable SPA serving. Set this +# only for a bare-metal deploy where the Go server serves a built SPA; leave it +# unset in local dev, where the Vite dev server serves the UI. +# STATIC_DIR=/path/to/frontend/build diff --git a/Dockerfile b/Dockerfile index c6eaeca..094abef 100644 --- a/Dockerfile +++ b/Dockerfile @@ -58,18 +58,18 @@ FROM alpine:3.21 AS runtime RUN apk add --no-cache ffmpeg ca-certificates tzdata # Run as an unprivileged user. -RUN addgroup -S app && adduser -S -G app -u 10001 app +RUN addgroup -S -g 42776 tanabata && adduser -S -G tanabata -u 42776 tanabata WORKDIR /app # The built SPA, served by the Go binary (matches STATIC_DIR below). -COPY --from=frontend --chown=app:app /src/frontend/build /app/static +COPY --from=frontend --chown=tanabata:tanabata /src/frontend/build /app/static # The server binary. -COPY --from=backend --chown=app:app /out/server /app/server +COPY --from=backend --chown=tanabata:tanabata /out/server /app/server # Data directories (overridable via FILES_PATH/THUMBS_CACHE_PATH/IMPORT_PATH). -# Created and owned by the app user so a fresh named volume inherits write access. -RUN mkdir -p /data/files /data/thumbs /data/import && chown -R app:app /data +# Created and owned by the tanabata user so a fresh named volume inherits write access. +RUN mkdir -p /data/files /data/thumbs /data/import && chown -R tanabata:tanabata /data # Non-secret defaults mirroring .env.example. Secrets (JWT_SECRET, ADMIN_PASSWORD, # DATABASE_URL) are intentionally NOT baked in — pass them at `docker run`. @@ -81,7 +81,7 @@ ENV LISTEN_ADDR=:42776 \ EXPOSE 42776 VOLUME ["/data"] -USER app +USER tanabata HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ CMD wget -qO- http://127.0.0.1:42776/health >/dev/null 2>&1 || exit 1 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1d12cc5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,103 @@ +# ============================================================================= +# Tanabata File Manager — Docker Compose +# +# Quick start: +# cp .env.example .env # then edit the secrets +# docker compose up -d --build +# +# Database — two supported modes, selected in .env: +# +# 1. Bundled Postgres container (default). +# COMPOSE_PROFILES=with-db +# DATABASE_URL=postgres://tanabata:password@db:5432/tanabata?sslmode=disable +# +# 2. Postgres already running on the host. +# COMPOSE_PROFILES= # empty → the db container is not started +# DATABASE_URL=postgres://tanabata:password@host.docker.internal:5432/tanabata?sslmode=disable +# +# Requires Docker Compose v2.20+ (for depends_on.required). +# ============================================================================= + +services: + app: + build: + context: . + dockerfile: Dockerfile + restart: unless-stopped + + # All application config (secrets, DATABASE_URL, tunables) comes from .env. + env_file: .env + + # Pin STATIC_DIR to the path baked into the image. .env intentionally leaves + # it unset; pinning here guarantees in-container SPA serving can't be + # disabled by an empty value leaking in through env_file. + environment: + STATIC_DIR: /app/static + + # The container always listens on 42776 (Dockerfile default); APP_PORT only + # changes the host-published port. + ports: + - "${APP_PORT:-42776}:42776" + + # Wait for the bundled DB when the with-db profile is active. When using a + # host Postgres the db service is disabled, and required:false keeps this + # dependency from erroring or auto-starting it. + depends_on: + db: + condition: service_healthy + required: false + + # Lets DATABASE_URL reach a Postgres on the host via host.docker.internal + # (needed on Linux; harmless elsewhere). + extra_hosts: + - "host.docker.internal:host-gateway" + + # Run as this uid:gid. Relevant when the mounts below are bind-mounted to + # host folders: set PUID/PGID (in .env) to the owner of those folders so the + # container can write to them. Defaults to the image's tanabata user + # (42776), which owns the named volumes. + user: "${PUID:-42776}:${PGID:-42776}" + + # Storage for originals, the thumbnail cache, and the import drop folder. + # Each source defaults to a named volume but can be pointed at a specific + # host folder via FILES_DIR / THUMBS_DIR / IMPORT_DIR in .env (a path turns + # the mount into a host bind mount; a bare name stays a named volume). + volumes: + - "${FILES_DIR:-app_files}:/data/files" + - "${THUMBS_DIR:-app_thumbs}:/data/thumbs" + - "${IMPORT_DIR:-app_import}:/data/import" + + db: + image: postgres:14-alpine + restart: unless-stopped + + # Only started when COMPOSE_PROFILES includes "with-db". Disable it to point + # the app at a Postgres running on the host instead. + profiles: ["with-db"] + + environment: + POSTGRES_DB: ${POSTGRES_DB:-tanabata} + POSTGRES_USER: ${POSTGRES_USER:-tanabata} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-password} + + # Defaults to a named volume; set DB_DIR in .env to a host folder to bind + # mount it instead. Postgres fixes the folder's ownership itself, so DB_DIR + # needs no PUID/PGID. + volumes: + - "${DB_DIR:-db_data}:/var/lib/postgresql/data" + + # Uncomment to reach the DB from the host (e.g. with psql) for debugging. + # ports: + # - "5432:5432" + + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-tanabata} -d ${POSTGRES_DB:-tanabata}"] + interval: 5s + timeout: 5s + retries: 10 + +volumes: + app_files: + app_thumbs: + app_import: + db_data: