# Certulaak in a container, for CI and for machines without PHP.
#
# The image is built for exactly what the tool needs: PHP 8.4 with openssl, zip and gmp,
# the vendor tree, and the compiled front end. Nothing in it is meant to face a network —
# see the note on who is let in, at the bottom.
#
# `docker compose up` builds the last stage. The dev stage is for `docker compose up dev
# vite`, which runs the same base against a bind-mounted working tree.

FROM node:22-alpine AS assets

WORKDIR /build

# The manifest first, so a change to application code does not reinstall node_modules.
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts

COPY vite.config.js ./
COPY resources ./resources
RUN npm run build


FROM php:8.4-cli-alpine AS base

# gmp is optional for the application and worth having: without it phpseclib falls back to
# its own big-number implementation, and RSA key generation becomes noticeably slower.
#
# The `openssl` package is the command line program, and it is no longer only a convenience:
# it is the one thing that can make an ML-DSA or SLH-DSA signature. ext-openssl exposes no
# post-quantum key type, and openssl_sign() always passes a digest, which a one-shot scheme
# refuses — so without this binary the two post-quantum families do not appear. Everything
# else works without it, which is why nothing here checks that it exists.
#
# The package version is deliberately not pinned. Alpine keeps one openssl per release
# branch and drops the old build from the index when it patches it, so `openssl=3.x.y-r0`
# stops installing the day a security fix lands — and a build that breaks on every upstream
# patch is worse than one that quietly picks it up. The php base image tag decides the
# Alpine release, and with it the openssl line; the version line below shows which arrived.
RUN apk add --no-cache libzip-dev gmp-dev openssl \
    && docker-php-ext-install -j"$(nproc)" zip gmp \
    && rm -rf /var/cache/apk/*

# Say what came out, so a base image that quietly drops the algorithms shows up in the build
# log rather than as two menu entries that went missing. It does not fail the build: the
# application is meant to run without them, and turning that into a hard requirement here
# would make the container the one place where the binary is mandatory.
RUN echo "openssl: $(openssl version)" \
    && echo "post-quantum signature algorithms: $(openssl list -signature-algorithms 2>/dev/null | grep -cE 'ML-DSA|SLH-DSA')"

# Pinned to a minor: a major tag follows every release, and composer's own upgrades have
# changed how the lock file is read before now. The patch level is left to float — those
# are the fixes.
COPY --from=composer:2.8 /usr/bin/composer /usr/bin/composer

WORKDIR /app

# The application does not need root, so it does not get it. A CA that can rewrite its own
# runtime is a CA whose keys are worth exactly as much as the first bug in it.
#
# This replaces a `chmod -R 777`, which granted the same thing to everyone in the container
# and to anything that mounted the volume.
RUN adduser -D -u 1000 certulaak \
    && mkdir -p /data \
    && chown certulaak:certulaak /data /app

COPY docker/entrypoint.sh /usr/local/bin/entrypoint
RUN chmod +x /usr/local/bin/entrypoint

# The database lives in /data, not in database/, and that is the whole reason /data exists.
#
# database/ is not a data directory: it holds the migrations, the seeders and the factories,
# which are code and belong to the image. Persisting it means mounting a volume over it —
# and a named volume is filled from the image only while it is empty, so the first run would
# freeze that code forever. Every migration written afterwards would be invisible to the
# container, and `migrate` would report nothing to do while the schema stayed behind.
ENV APP_ENV=local \
    APP_DEBUG=false \
    DB_CONNECTION=sqlite \
    DB_DATABASE=/data/database.sqlite \
    SESSION_DRIVER=file \
    CACHE_STORE=file \
    QUEUE_CONNECTION=sync

# `artisan serve` is PHP's built-in server, which answers one request at a time unless it is
# told otherwise. An Inertia page asking for its own JSON while the assets are still coming
# down deadlocks against itself on a single worker.
#
# The variable alone does nothing: `serve` watches .env and restarts itself when it changes,
# and it cannot do that and run several workers at once, so it drops the workers and says so
# in the log. --no-reload in the CMD below is what makes this take effect. Nothing here needs
# the watching — the image's .env is written once, by the entrypoint, before the server runs.
ENV PHP_CLI_SERVER_WORKERS=4

# A request into a container arrives from the bridge gateway, never from loopback, so the
# guard has to be told about that range or nothing gets in at all.
#
# Deliberately this and not CERTULAAK_ALLOW_REMOTE=true. Turning the guard off inside the
# image would mean the only thing keeping an unauthenticated CA off the network is the
# port binding in compose.yaml — which is not part of the image, and which `docker run -p
# 2000:2000` does not have. Naming the range keeps the guard doing its job either way.
ENV CERTULAAK_ALLOWED_IPS=172.16.0.0/12,192.168.0.0/16,10.0.0.0/8

EXPOSE 2000

# /up is Laravel's health route. The check runs inside the container, so it reaches the
# application over loopback and the guard lets it through without being told anything.
#
# The start period covers the entrypoint: key, composer autoload, caches, migrations and
# the seeder all run before the port opens.
HEALTHCHECK --interval=30s --timeout=3s --start-period=45s --retries=3 \
    CMD php -r 'exit(@file_get_contents("http://127.0.0.1:2000/up") === false ? 1 : 0);'

ENTRYPOINT ["entrypoint"]

CMD ["php", "artisan", "serve", "--host=0.0.0.0", "--port=2000", "--no-reload"]


# The development stage copies no source. It arrives as a bind mount, so an edit on the
# host is the file the container runs, and vendor/ comes with it — the entrypoint installs
# one only when the working tree has none.
FROM base AS dev

ENV APP_DEBUG=true \
    CERTULAAK_OPTIMIZE=0

USER certulaak


FROM base AS final

COPY --chown=certulaak:certulaak composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist --no-interaction

COPY --chown=certulaak:certulaak . .
COPY --from=assets --chown=certulaak:certulaak /build/public/build ./public/build

RUN composer dump-autoload --optimize --no-dev \
    && mkdir -p storage/framework/cache storage/framework/sessions storage/framework/views storage/logs bootstrap/cache \
    && touch /data/database.sqlite \
    && chown -R certulaak:certulaak storage bootstrap/cache /data

USER certulaak
