# Nix-built shower app container — Adelaide / Heidi / Addie baby shower. # # The app is published as a wheel to the Forgejo PyPI index at # https://forge.eblu.me/api/packages/eblume/pypi/. The wheel + its # transitive Python deps are baked in at build time via a fixed-output # derivation that runs `pip install --target` against forge PyPI (proxied # through pypi.ops.eblu.me for upstream packages). Build runs on the # nix-container-builder runner (ringtail, amd64) so the image is native. # # Going through pip-install-target rather than nixpkgs Python packages # sidesteps two issues we hit going through `python.pkgs.buildPythonPackage`: # 1. python314Packages.django still aliases to Django 4.2 LTS, which # doesn't support Python 3.14 at all. # 2. django-axes pulls selenium + browser fonts into its check phase # and the nix sandbox can't provide those. # # To bump the version: # 1. Update `version` below. # 2. Set `outputHash` to `pkgs.lib.fakeHash`, run the build, copy the # real hash out of the error, and commit it. { pkgs ? import { } }: let version = "1.0.0"; python = pkgs.python314; # Fixed-output derivation: pip-installs the app wheel + every transitive # dep into a single target dir. FODs get network access in exchange for # a pinned output hash, which means the whole dependency closure is # immutable across rebuilds. pyDeps = pkgs.stdenv.mkDerivation { pname = "shower-python-deps"; inherit version; dontUnpack = true; nativeBuildInputs = [ python pkgs.cacert ]; buildPhase = '' runHook preBuild export HOME=$TMPDIR export SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt export PIP_DISABLE_PIP_VERSION_CHECK=1 # Install into a venv first so pip's bytecode-compile + entry-point # generation pick up the right interpreter, then copy site-packages # + bin into $out at a stable layout. ${python}/bin/python -m venv "$TMPDIR/venv" "$TMPDIR/venv/bin/pip" install --upgrade pip "$TMPDIR/venv/bin/pip" install \ --no-cache-dir \ --index-url=https://pypi.ops.eblu.me/root/pypi/+simple/ \ --extra-index-url=https://forge.eblu.me/api/packages/eblume/pypi/simple/ \ "adelaide-baby-shower-app==${version}" \ gunicorn runHook postBuild ''; installPhase = '' runHook preInstall mkdir -p $out/lib/python3.14 $out/bin cp -r "$TMPDIR/venv/lib/python3.14/site-packages" $out/lib/python3.14/site-packages # Copy console scripts (gunicorn, django-admin, etc.) but drop the # venv-specific shebang prefix that points at $TMPDIR/venv/bin/python. # Rewrite shebangs to the eventual on-image python path. for script in "$TMPDIR/venv/bin/"*; do [ -f "$script" ] || continue name=$(basename "$script") case "$name" in python*|pip*|activate*) continue ;; esac # Replace the venv python shebang with a path that resolves inside # the docker image (where ${python} ends up in /nix/store). sed -e "1 s|^#!.*python.*|#!${python}/bin/python3.14|" "$script" > "$out/bin/$name" chmod +x "$out/bin/$name" done runHook postInstall ''; # Bytecode files embed absolute paths; deletion forces re-compile inside # the image at first run, with paths matching the image filesystem. postInstall = '' find $out -type f -name '*.pyc' -delete find $out -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true ''; outputHashMode = "recursive"; outputHashAlgo = "sha256"; # Computed by setting to pkgs.lib.fakeHash and reading the failure. # Pin the dep closure — rebuilds are reproducible until the version bumps. outputHash = pkgs.lib.fakeHash; dontFixup = true; }; sitePackages = "${pyDeps}/lib/python3.14/site-packages"; # Settings shim — config/settings.py's `BASE_DIR = parent.parent` would # otherwise resolve to site-packages, scattering db.sqlite3 / media / # staticfiles into the venv. Pin them to /app/{data,media,data/staticfiles}. localSettings = pkgs.writeText "local_settings.py" '' from config.settings import * # noqa: F401,F403 DATABASES["default"]["NAME"] = "/app/data/db.sqlite3" MEDIA_ROOT = "/app/media" STATIC_ROOT = "/app/data/staticfiles" ''; entrypoint = pkgs.writeShellScript "shower-entrypoint" '' set -eu export HOME=/app/data export PATH=${pyDeps}/bin:${python}/bin:/bin export PYTHONPATH=/app:${sitePackages} export DJANGO_SETTINGS_MODULE=local_settings cd /app mkdir -p /app/data /app/media echo "shower: running migrations" ${python}/bin/python -m django migrate --noinput echo "shower: collecting static files" ${python}/bin/python -m django collectstatic --noinput --clear echo "shower: starting gunicorn" exec ${pyDeps}/bin/gunicorn \ --bind 0.0.0.0:8000 \ --workers 2 \ --forwarded-allow-ips='*' \ config.wsgi:application ''; in pkgs.dockerTools.buildLayeredImage { name = "blumeops/shower"; contents = [ python pyDeps pkgs.cacert pkgs.tzdata pkgs.bashInteractive pkgs.coreutils ]; extraCommands = '' mkdir -p app/data app/media tmp chmod 1777 tmp cp ${localSettings} app/local_settings.py ''; fakeRootCommands = '' chown -R 1000:1000 app ''; enableFakechroot = true; config = { Entrypoint = [ "${entrypoint}" ]; Env = [ "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" "TZDIR=${pkgs.tzdata}/share/zoneinfo" "TZ=America/Los_Angeles" "TMPDIR=/tmp" "LANG=C.UTF-8" "LC_ALL=C.UTF-8" "PYTHONDONTWRITEBYTECODE=1" ]; ExposedPorts = { "8000/tcp" = { }; }; User = "1000"; WorkingDir = "/app"; }; }