diff --git a/dist/.htaccess b/dist/.htaccess
index 5169852..ac79df4 100644
--- a/dist/.htaccess
+++ b/dist/.htaccess
@@ -13,9 +13,58 @@ DirectoryIndex index.php index.html
SetHandler application/x-httpd-php
+# Second line of defence for hidden FILES (.env, .htpasswd, …), in case
+# mod_rewrite is not loaded. This does not cover hidden directories such as
+# /.git/config — see the mod_rewrite block below, which does.
+# Both Apache 2.4 and 2.2 syntaxes are given so this works either way.
+#
+# Access control runs before mod_rewrite, so a hidden file answers 403 here
+# while a hidden directory answers 404 from the rewrite below. Both refuse to
+# serve the content, which is the point; the differing codes are cosmetic.
+
+
+ Require all denied
+
+
+ Order allow,deny
+ Deny from all
+
+
+
RewriteEngine On
+ # ------------------------------------------------------------------
+ # Block hidden paths (.git, .env, .svn, …) — MUST stay first
+ # ------------------------------------------------------------------
+ # Scanners routinely fetch /.env and /.git/config, and if /.git/index
+ # plus /.git/objects/… can be read the whole repository — including its
+ # history and any credential ever committed — can be reconstructed.
+ #
+ # Two things make this easy to get wrong:
+ #
+ # 1. does NOT stop /.git/config. FilesMatch tests
+ # the basename only, and the basename there is "config". Blocking a
+ # hidden *directory* needs a rule that sees the whole path, which in
+ # .htaccess means mod_rewrite ( is server-config only).
+ #
+ # 2. The SPA fallback further down only rewrites paths that do NOT
+ # exist (!-f / !-d). A real .env on disk therefore skips the
+ # fallback and gets served as a plain file. So this rule has to come
+ # before it, not after.
+ #
+ # .well-known is deliberately exempt: blocking it breaks Let's Encrypt
+ # (ACME) certificate issuance and renewal.
+ #
+ # 404 rather than 403, so a probe learns nothing about what exists.
+ RewriteCond %{REQUEST_URI} !^/\.well-known/
+ RewriteRule (^|/)\. - [R=404,L]
+
+ # Files that are never meant to be fetched over HTTP, if one is ever
+ # deployed into the document root by mistake.
+ RewriteRule \.(sql|sqlite|sqlite3|db|bak|backup|old|orig|save|swp|log|ini|conf|key|pem|crt|p12|pfx)$ - [R=404,L,NC]
+ RewriteRule (^|/)(composer\.(json|lock)|package(-lock)?\.json|yarn\.lock|Dockerfile|docker-compose\.ya?ml|\.dockerignore)$ - [R=404,L,NC]
+
# Clean URL for the iframe embed endpoint: /embed → embed.php
# Must come BEFORE the SPA fallback so /embed doesn't get routed to
# index.php. Query strings (?mode=permit&...) pass through automatically.
diff --git a/docs/apache-hardening.conf b/docs/apache-hardening.conf
new file mode 100644
index 0000000..3a61980
--- /dev/null
+++ b/docs/apache-hardening.conf
@@ -0,0 +1,142 @@
+# ============================================================================
+# LUPMIS2 — Apache hardening against source-code and secret disclosure
+# ============================================================================
+#
+# WHY THIS EXISTS
+# ---------------
+# This is preventive hardening. No LUPMIS host is known to have been
+# compromised — these rules are here so that the most common automated attack
+# against a PHP deployment cannot succeed.
+#
+# Scanners continuously probe every public host for two things:
+#
+# /.env — application secrets: database passwords, API keys
+# /.git/… — the repository, if a working copy was deployed
+#
+# The second is the more damaging of the two. Given /.git/index together with
+# /.git/objects, an attacker can reconstruct the entire repository: every file
+# and every past commit. That means any credential ever committed is exposed,
+# even if it has since been changed, because it remains in the history.
+#
+# That last point is directly relevant to LUPMIS. The minio-uploads
+# integration has had access keys written into upload.php, download.php and
+# get_file_url.php, and those files are tracked in Gitea — so the keys are in
+# the history regardless of later rotation. The exposure is only theoretical
+# while that repository is private and its .git directory is not web-served;
+# these rules keep the second half of that sentence true.
+#
+# WHICH HOSTS
+# -----------
+# Apply to EVERY vhost. The risk is not equal across them:
+#
+# • The PWA is not currently exposed — its document root is dist/, which
+# contains neither .env nor .git.
+# • Any app deployed by copying a working directory (a git clone) into the
+# document root is exposed by construction. The minio-uploads project, for
+# example, carries a .git directory and composer files.
+#
+# Checking is quick, from outside the network:
+# curl -sS -o /dev/null -w '%{http_code}\n' https://HOST/.env
+# curl -sS -o /dev/null -w '%{http_code}\n' https://HOST/.git/config
+# Anything other than 403 or 404 needs attention.
+#
+# HOW TO USE
+# ----------
+# Preferred — in the vhost or server config, where it cannot be disabled by a
+# stray AllowOverride and applies to the whole tree:
+#
+#
+# ...
+# Include /etc/apache2/conf-available/lupmis-hardening.conf
+#
+#
+# Then: a2enconf lupmis-hardening && apachectl configtest && systemctl reload apache2
+#
+# If you can only use .htaccess, the equivalent rules are already in the PWA's
+# public/.htaccess — but note .htaccess needs AllowOverride to be enabled, so
+# the vhost form is the safer of the two.
+#
+# THIS IS DEFENCE IN DEPTH, NOT THE FIX
+# -------------------------------------
+# The real fix is that .git and .env must not be inside a document root at all.
+# Deploy build output (or `git archive`), not a working copy, and keep secrets
+# in environment variables outside the served tree. These rules are what
+# protects you when that goes wrong again.
+# ============================================================================
+
+
+# ---------------------------------------------------------------------------
+# 1. Hidden directories and files (.git, .env, .svn, .hg, .DS_Store, …)
+# ---------------------------------------------------------------------------
+# is server-config only, which is exactly the context this
+# file is meant for, and it covers the whole path — so unlike it
+# does stop /.git/config.
+#
+# .well-known is exempt on purpose: blocking it breaks Let's Encrypt (ACME)
+# certificate issuance and renewal.
+
+
+ Require all denied
+
+
+
+ Require all denied
+
+
+# mod_rewrite equivalent — also covers odd forms such as /./.env, which the
+# captured log shows being used, and returns 404 rather than 403 so a probe
+# learns nothing about what is present.
+
+ RewriteEngine On
+ RewriteCond %{REQUEST_URI} !^/\.well-known/
+ RewriteRule (^|/)\. - [R=404,L]
+
+
+
+# ---------------------------------------------------------------------------
+# 2. Files that should never be served, if one lands in the document root
+# ---------------------------------------------------------------------------
+
+ Require all denied
+
+
+
+ Require all denied
+
+
+
+# ---------------------------------------------------------------------------
+# 3. Do not advertise the software version, and do not list directories
+# ---------------------------------------------------------------------------
+# ServerTokens / ServerSignature are global directives — put them in the main
+# server config, not inside a .
+#
+# ServerTokens Prod
+# ServerSignature Off
+
+Options -Indexes
+
+
+# ---------------------------------------------------------------------------
+# 4. Proxy — make the logs usable
+# ---------------------------------------------------------------------------
+# Where Apache sits behind the openresty reverse proxy, it logs the proxy's
+# Docker-internal address rather than the real caller. If that is the case
+# here, an access log cannot tell you who probed the site, and the traffic
+# cannot be blocked or reported. Have the proxy pass the caller through:
+#
+# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+# proxy_set_header X-Real-IP $remote_addr;
+#
+# and have Apache trust it, so %a in the log is the real address:
+#
+# RemoteIPHeader X-Forwarded-For
+# RemoteIPInternalProxy 172.20.0.0/16
+# LogFormat "%a %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\"" combined
+#
+# (RemoteIPHeader needs mod_remoteip: a2enmod remoteip)
+#
+# Blocking at the proxy as well as here is worthwhile — it stops the request
+# before it reaches the application at all:
+#
+# location ~ /\.(?!well-known) { return 404; }
diff --git a/public/.htaccess b/public/.htaccess
index 5169852..ac79df4 100644
--- a/public/.htaccess
+++ b/public/.htaccess
@@ -13,9 +13,58 @@ DirectoryIndex index.php index.html
SetHandler application/x-httpd-php
+# Second line of defence for hidden FILES (.env, .htpasswd, …), in case
+# mod_rewrite is not loaded. This does not cover hidden directories such as
+# /.git/config — see the mod_rewrite block below, which does.
+# Both Apache 2.4 and 2.2 syntaxes are given so this works either way.
+#
+# Access control runs before mod_rewrite, so a hidden file answers 403 here
+# while a hidden directory answers 404 from the rewrite below. Both refuse to
+# serve the content, which is the point; the differing codes are cosmetic.
+
+
+ Require all denied
+
+
+ Order allow,deny
+ Deny from all
+
+
+
RewriteEngine On
+ # ------------------------------------------------------------------
+ # Block hidden paths (.git, .env, .svn, …) — MUST stay first
+ # ------------------------------------------------------------------
+ # Scanners routinely fetch /.env and /.git/config, and if /.git/index
+ # plus /.git/objects/… can be read the whole repository — including its
+ # history and any credential ever committed — can be reconstructed.
+ #
+ # Two things make this easy to get wrong:
+ #
+ # 1. does NOT stop /.git/config. FilesMatch tests
+ # the basename only, and the basename there is "config". Blocking a
+ # hidden *directory* needs a rule that sees the whole path, which in
+ # .htaccess means mod_rewrite ( is server-config only).
+ #
+ # 2. The SPA fallback further down only rewrites paths that do NOT
+ # exist (!-f / !-d). A real .env on disk therefore skips the
+ # fallback and gets served as a plain file. So this rule has to come
+ # before it, not after.
+ #
+ # .well-known is deliberately exempt: blocking it breaks Let's Encrypt
+ # (ACME) certificate issuance and renewal.
+ #
+ # 404 rather than 403, so a probe learns nothing about what exists.
+ RewriteCond %{REQUEST_URI} !^/\.well-known/
+ RewriteRule (^|/)\. - [R=404,L]
+
+ # Files that are never meant to be fetched over HTTP, if one is ever
+ # deployed into the document root by mistake.
+ RewriteRule \.(sql|sqlite|sqlite3|db|bak|backup|old|orig|save|swp|log|ini|conf|key|pem|crt|p12|pfx)$ - [R=404,L,NC]
+ RewriteRule (^|/)(composer\.(json|lock)|package(-lock)?\.json|yarn\.lock|Dockerfile|docker-compose\.ya?ml|\.dockerignore)$ - [R=404,L,NC]
+
# Clean URL for the iframe embed endpoint: /embed → embed.php
# Must come BEFORE the SPA fallback so /embed doesn't get routed to
# index.php. Query strings (?mode=permit&...) pass through automatically.