Dockerfile
Rama main · réplica de lectura del repositorio original.
# SPDX-License-Identifier: AGPL-3.0-only
# SPDX-FileCopyrightText: 2026 Andrea A. Venti Fuentes
# syntax=docker/dockerfile:1
# =============================================================================
# Houndarr: production Docker image
# Base: python:3.14-slim (Debian trixie slim)
# =============================================================================
# -----------------------------------------------------------------------------
# Stage 1: css-build
# Compile Tailwind v4 + Houndarr custom CSS into a single static file. Node
# lives only in this stage; the final runtime image stays Python-only.
# -----------------------------------------------------------------------------
FROM node:24.22.2-alpine@sha256:a34ab6d7f6f4813f4d0c5469d74685604b962fbdbf5793a7bc7b4998ecb867c8 AS css-build
WORKDIR /build
# Enable pnpm via corepack, which the Node 24 LTS image still bundles.
# Node 25 dropped it, so a major bump here needs pnpm installed another way.
# The packageManager field in package.json pins the exact pnpm version.
RUN corepack enable
# Copy manifest + lockfile + workspace config first for better layer caching.
# pnpm 11 reads `allowBuilds` and `overrides` only from pnpm-workspace.yaml.
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
# Copy only the inputs Tailwind needs to scan and import.
COPY src/houndarr/static/css/ ./src/houndarr/static/css/
COPY src/houndarr/templates/ ./src/houndarr/templates/
RUN pnpm run build-css
# -----------------------------------------------------------------------------
# Stage 2: runtime
# -----------------------------------------------------------------------------
FROM python:3.14-slim@sha256:9aaa474097d094040649f2162a627c0049b408f52138d980916788d80f04802f
ARG HOUNDARR_VERSION=dev
# OCI labels
LABEL org.opencontainers.image.title="Houndarr" \
org.opencontainers.image.description="Focused self-hosted companion for Radarr, Sonarr, Lidarr, Readarr, and Whisparr" \
org.opencontainers.image.url="https://github.com/av1155/houndarr" \
org.opencontainers.image.source="https://github.com/av1155/houndarr" \
org.opencontainers.image.licenses="AGPL-3.0-only"
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PYTHONPATH=/app/src \
HOUNDARR_VERSION="${HOUNDARR_VERSION}" \
HOUNDARR_DATA_DIR=/data
WORKDIR /app
# Apply base-image security patches and install gosu for privilege dropping.
# tzdata-legacy carries the backward-compatible zone names (US/Eastern, Japan,
# GB); without it glibc cannot open those files and silently uses UTC, which
# shifts the allowed search window by the operator's real offset.
# hadolint ignore=DL3008,DL3009
RUN apt-get update \
&& apt-get upgrade -y \
&& apt-get install -y --no-install-recommends gosu curl tzdata-legacy \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies before copying source (better layer caching).
# pyproject.toml + uv.lock are the single source of truth; uv exports a
# pinned requirements list at build time, no static requirements.txt needed.
# VERSION + hatch_build.py are required by the hatchling metadata hook that
# reads the project version; copy them too so `uv export` can resolve the
# build backend without the rest of the source tree.
COPY pyproject.toml uv.lock VERSION hatch_build.py ./
# pip vendors its own copies of msgpack and setuptools (see
# pip/_vendor/vendor.txt), which the image scanner reports as CVEs even though
# no runtime code imports them. Neither pip nor uv is needed once the
# dependencies are installed, so drop both in the same layer that used them.
# hadolint ignore=DL3013
RUN pip install --no-cache-dir --upgrade pip uv \
&& uv export --frozen --no-hashes --no-emit-project --no-dev -o /tmp/requirements.txt \
&& pip install --no-cache-dir -r /tmp/requirements.txt \
&& rm /tmp/requirements.txt \
&& pip uninstall -y uv pip
# Copy application source
COPY src/ ./src/
COPY VERSION ./
COPY CHANGELOG.md ./
# Overlay the compiled Tailwind CSS from the css-build stage.
COPY --from=css-build /build/src/houndarr/static/css/app.built.css \
./src/houndarr/static/css/app.built.css
# Create non-root runtime user and data directory
RUN groupadd -g 1000 appgroup \
&& useradd -u 1000 -g appgroup -m -s /sbin/nologin appuser \
&& mkdir -p /data \
&& chown -R appuser:appgroup /app /data
# Copy and make entrypoint executable
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Expose web UI port
EXPOSE 8877
# Data volume for persistent state
VOLUME ["/data"]
# Health check: poll the unauthenticated /api/health endpoint.
# `|| exit 1` collapses curl's own exit codes (22 on HTTP error, 7 on
# connection refused) into the single status Docker reads as unhealthy.
# That needs shell form, so DL3025's JSON notation cannot express it.
# hadolint ignore=DL3025
HEALTHCHECK --interval=60s --timeout=10s --start-period=10s --retries=3 \
CMD curl --fail --silent http://localhost:8877/api/health || exit 1
# -----------------------------------------------------------------------------
# Privilege-drop strategy
# -----------------------------------------------------------------------------
# This image deliberately does NOT end with `USER appuser`. The image starts as
# root, then the entrypoint (`entrypoint.sh`) handles three modes:
#
# 1. Container started non-root (Kubernetes runAsUser, `docker run --user`):
# preflights /data, then exec the app as the supplied UID.
# 2. PUID=0 (LXC/Proxmox where isolation is at the hypervisor level):
# stays as root.
# 3. Default: remaps `appuser` to PUID/PGID, chowns /data, then drops
# privileges via `gosu appuser` before exec-ing the app.
#
# Hard-coding `USER appuser` would silently break mode 3 (the most common one),
# because `id -u` would return 1000 and the entrypoint would skip the
# UID-remap branch. This is the canonical linuxserver.io PUID/PGID pattern
# used by hundreds of self-hosted images. See `entrypoint.sh` for details.
#
# Static analyzers (Semgrep `last-user-is-root`, etc.) flag the absence of a
# trailing `USER` directive without considering the entrypoint's runtime drop;
# the suppressions below acknowledge that and point reviewers to entrypoint.sh.
# nosemgrep: dockerfile.security.last-user-is-root.last-user-is-root
ENTRYPOINT ["/entrypoint.sh"]
# nosemgrep: dockerfile.security.last-user-is-root.last-user-is-root
CMD ["python", "-m", "houndarr", "--data-dir", "/data"]