diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..c2e8219
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,25 @@
+.git
+.gitignore
+.agents
+.aws
+.codex
+.local
+.well-known
+DB
+dev
+scan
+node_modules
+.env
+.env.*
+*.sql
+!dashboard/migrations/*.sql
+*.sql.gz
+*.sql.zip
+*.log
+*.zip
+*.pem
+*.key
+*.crt
+includes/conn.php
+koneksi.php
+**/__pycache__
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..9b6928f
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,20 @@
+# Copy to .env for manual Compose use, or paste into Komodo Stack Environment.
+# Never commit actual passwords. Generate each independently: openssl rand -hex 32
+MYSQL_DATABASE=recruitment
+MYSQL_USER=recruitment
+MYSQL_PASSWORD=CHANGE_ME_APP_PASSWORD
+MYSQL_ROOT_PASSWORD=CHANGE_ME_ROOT_PASSWORD
+
+# HTTPS must be provided by your existing reverse proxy.
+# Localhost is safe for a proxy running directly on the Docker host.
+APP_BIND_IP=127.0.0.1
+APP_PORT=8086
+REC_PASS_SCORE=300
+
+# phpMyAdmin: use an SSH tunnel or a restricted HTTPS reverse proxy.
+# Log in manually with MYSQL_USER / MYSQL_PASSWORD (no automatic login).
+PHPMYADMIN_BIND_IP=127.0.0.1
+PHPMYADMIN_PORT=8087
+PHPMYADMIN_UPLOAD_LIMIT=64M
+# Set only for proxy access, e.g. https://db-admin.example.com/
+PMA_ABSOLUTE_URI=
diff --git a/.gitea/workflows/build-image.yaml b/.gitea/workflows/build-image.yaml
new file mode 100644
index 0000000..61dc506
--- /dev/null
+++ b/.gitea/workflows/build-image.yaml
@@ -0,0 +1,40 @@
+# Explicit URLs and commit pins keep action sources predictable on Gitea.
+# yamllint disable rule:line-length
+name: Build and publish Docker image
+
+'on':
+ push:
+ branches: [main]
+ workflow_dispatch:
+
+concurrency:
+ group: ${{ gitea.workflow }}-${{ gitea.ref }}
+ cancel-in-progress: true
+
+env:
+ IMAGE: gitea.cptckp.com/nurinda/recruitment-hc
+
+jobs:
+ build:
+ if: gitea.ref == 'refs/heads/main'
+ runs-on: ubuntu-latest
+ timeout-minutes: 40
+ steps:
+ - name: Checkout
+ uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ with:
+ persist-credentials: false
+
+ - name: Log in to Gitea Container Registry
+ uses: https://github.com/docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
+ with:
+ registry: gitea.cptckp.com
+ username: ${{ secrets.REGISTRY_USERNAME }}
+ password: ${{ secrets.REGISTRY_TOKEN }}
+
+ - name: Build and publish latest
+ shell: bash
+ run: |
+ set -euo pipefail
+ docker build --tag "$IMAGE:latest" .
+ docker push "$IMAGE:latest"
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..13401e8
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,25 @@
+FROM php:8.3-apache-bookworm
+
+RUN docker-php-ext-install mysqli \
+ && a2enmod rewrite headers setenvif \
+ && install -d -o www-data -g www-data -m 0700 /var/lib/php/sessions
+
+WORKDIR /var/www/html
+
+# Deliberately do not copy SQL exports, local tooling, or host credentials.
+COPY *.php favicon.ico ./
+COPY assets/ ./assets/
+COPY includes/ ./includes/
+COPY dashboard/ ./dashboard/
+COPY docker/conn.php ./includes/conn.php
+COPY docker/apache.conf /etc/apache2/conf-available/recruitment.conf
+COPY docker/php.ini /usr/local/etc/php/conf.d/recruitment.ini
+COPY docker/health.php /usr/local/share/recruitment/health.php
+COPY docker/migrate.php /usr/local/share/recruitment/migrate.php
+COPY docker/healthcheck.php /usr/local/bin/recruitment-healthcheck.php
+
+RUN a2enconf recruitment \
+ && find /var/www/html -type d -exec chmod 0755 {} + \
+ && find /var/www/html -type f -exec chmod 0644 {} +
+
+EXPOSE 80
diff --git a/README.md b/README.md
index e6664a5..be7f7a0 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,9 @@ Panduan akun, backup, dan runtime ada di [dev/README.md](dev/README.md). Hentika
## Server
-Salin `includes/conn.example.php` ke `includes/conn.php`, lalu isi konfigurasi database. Impor database recruitment dan jalankan migrasi yang diperlukan di `dashboard/migrations/`. Panduan admin: [dashboard/README.md](dashboard/README.md).
+Untuk deployment Docker Compose melalui Komodo (PHP/Apache + MySQL persisten, di belakang reverse proxy HTTPS), ikuti [panduan deployment](docs/deployment.md). Konfigurasi tersedia di `compose.yaml`, `Dockerfile`, dan `.env.example`; dump SQL diimpor secara eksplisit, bukan setiap redeploy.
+
+Untuk deployment PHP tanpa Docker, salin `includes/conn.example.php` ke `includes/conn.php`, lalu isi konfigurasi database. Impor database recruitment dan jalankan migrasi yang diperlukan di `dashboard/migrations/`. Panduan admin: [dashboard/README.md](dashboard/README.md).
- `assets/`: tampilan dan perilaku tes peserta.
- `includes/`: koneksi, autentikasi peserta, dan layanan sesi tes.
diff --git a/compose.yaml b/compose.yaml
new file mode 100644
index 0000000..f5a8146
--- /dev/null
+++ b/compose.yaml
@@ -0,0 +1,99 @@
+name: ${COMPOSE_PROJECT_NAME:-recruitment-hc}
+
+services:
+ app:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ restart: unless-stopped
+ init: true
+ ports:
+ - "${APP_BIND_IP:-127.0.0.1}:${APP_PORT:-8086}:80"
+ environment:
+ MYSQL_HOST: db
+ MYSQL_PORT: "3306"
+ MYSQL_DATABASE: ${MYSQL_DATABASE:-recruitment}
+ MYSQL_USER: ${MYSQL_USER:-recruitment}
+ MYSQL_PASSWORD: ${MYSQL_PASSWORD:?Set MYSQL_PASSWORD}
+ REC_PASS_SCORE: ${REC_PASS_SCORE:-300}
+ TZ: Asia/Jakarta
+ depends_on:
+ db:
+ condition: service_healthy
+ volumes:
+ - php_sessions:/var/lib/php/sessions
+ healthcheck:
+ test: ["CMD", "php", "/usr/local/bin/recruitment-healthcheck.php"]
+ interval: 30s
+ timeout: 5s
+ start_period: 30s
+ retries: 3
+ logging:
+ driver: json-file
+ options:
+ max-size: "10m"
+ max-file: "3"
+
+ db:
+ image: mysql:8.4
+ restart: unless-stopped
+ environment:
+ MYSQL_DATABASE: ${MYSQL_DATABASE:-recruitment}
+ MYSQL_USER: ${MYSQL_USER:-recruitment}
+ MYSQL_PASSWORD: ${MYSQL_PASSWORD:?Set MYSQL_PASSWORD}
+ MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?Set MYSQL_ROOT_PASSWORD}
+ TZ: Asia/Jakarta
+ volumes:
+ - mysql_data:/var/lib/mysql
+ healthcheck:
+ test:
+ - CMD-SHELL
+ - >-
+ MYSQL_PWD="$${MYSQL_ROOT_PASSWORD}"
+ mysql --protocol=TCP -h 127.0.0.1 -u root
+ -Nse "SELECT 1" >/dev/null 2>&1
+ interval: 10s
+ timeout: 5s
+ start_period: 60s
+ retries: 12
+ logging:
+ driver: json-file
+ options:
+ max-size: "10m"
+ max-file: "3"
+
+ phpmyadmin:
+ image: phpmyadmin:5.2-apache
+ restart: unless-stopped
+ ports:
+ - "${PHPMYADMIN_BIND_IP:-127.0.0.1}:${PHPMYADMIN_PORT:-8087}:80"
+ environment:
+ PMA_HOST: db
+ PMA_PORT: "3306"
+ PMA_ABSOLUTE_URI: ${PMA_ABSOLUTE_URI:-}
+ UPLOAD_LIMIT: ${PHPMYADMIN_UPLOAD_LIMIT:-64M}
+ HIDE_PHP_VERSION: "true"
+ TZ: Asia/Jakarta
+ depends_on:
+ db:
+ condition: service_healthy
+ volumes:
+ - phpmyadmin_sessions:/sessions
+ healthcheck:
+ test:
+ - CMD-SHELL
+ - curl --fail --silent --max-time 3 http://127.0.0.1/ > /dev/null
+ interval: 30s
+ timeout: 5s
+ start_period: 30s
+ retries: 3
+ logging:
+ driver: json-file
+ options:
+ max-size: "10m"
+ max-file: "3"
+
+volumes:
+ mysql_data:
+ php_sessions:
+ phpmyadmin_sessions:
diff --git a/docker/apache.conf b/docker/apache.conf
new file mode 100644
index 0000000..45ce903
--- /dev/null
+++ b/docker/apache.conf
@@ -0,0 +1,42 @@
+ServerName localhost
+ServerTokens Prod
+ServerSignature Off
+TraceEnable Off
+
+# Only expose this HTTP listener to a trusted reverse proxy. The proxy must
+# overwrite X-Forwarded-Proto, not pass a client-supplied value through.
+# PHP uses HTTPS to decide whether participant/admin cookies need Secure.
+SetEnvIf X-Forwarded-Proto "^https$" HTTPS=on
+
+
+ Options -Indexes -MultiViews
+ AllowOverride None
+ DirectoryIndex index.php
+ Require all granted
+
+
+# Internal code is still usable by PHP/CLI, never directly over HTTP.
+
+ Require all denied
+
+
+# Also deny dotfiles and backup/config files if accidentally added later.
+
+ Require all denied
+
+
+ Require all denied
+
+
+Alias /healthz /usr/local/share/recruitment/health.php
+
+ Options -Indexes
+ AllowOverride None
+ Require all denied
+
+ Require all granted
+
+
+
+Header always set X-Content-Type-Options "nosniff"
+Header always set Referrer-Policy "same-origin"
diff --git a/docker/conn.php b/docker/conn.php
new file mode 100644
index 0000000..17c8f3f
--- /dev/null
+++ b/docker/conn.php
@@ -0,0 +1,16 @@
+set_charset('utf8mb4');
diff --git a/docker/health.php b/docker/health.php
new file mode 100644
index 0000000..1baa783
--- /dev/null
+++ b/docker/health.php
@@ -0,0 +1,17 @@
+query("SELECT 1 FROM `$table` LIMIT 0");
+ }
+ $conn->query('SELECT email, nama_de, nama_be, idkar, password_hash FROM rec_admin LIMIT 0');
+ echo "ok\n";
+} catch (Throwable $error) {
+ http_response_code(503);
+ // Never expose connection details, credentials, or schema errors over HTTP.
+ echo "not ready\n";
+}
diff --git a/docker/healthcheck.php b/docker/healthcheck.php
new file mode 100644
index 0000000..5580d11
--- /dev/null
+++ b/docker/healthcheck.php
@@ -0,0 +1,11 @@
+query("SELECT 1 FROM `$table` LIMIT 0");
+ }
+
+ foreach (['001_admin.sql', '002_admin_identity.sql', '003_exam_attempts.sql'] as $file) {
+ // Only migrate the historical rec_admin schema, preserving identities.
+ if ($file === '002_admin_identity.sql'
+ && $conn->query("SHOW COLUMNS FROM rec_admin LIKE 'name'")->num_rows === 0) {
+ continue;
+ }
+ $sql = file_get_contents('/var/www/html/dashboard/migrations/' . $file);
+ $conn->multi_query($sql);
+ do {
+ $result = $conn->store_result();
+ if ($result instanceof mysqli_result) {
+ $result->free();
+ }
+ } while ($conn->more_results() && $conn->next_result());
+ fwrite(STDOUT, "Applied: $file\n");
+ }
+ fwrite(STDOUT, "Database ready. Existing accounts and test data were preserved.\n");
+} catch (Throwable $error) {
+ fwrite(STDERR, 'Migration failed: ' . $error->getMessage() . "\nImport the base database first; see docs/deployment.md.\n");
+ exit(1);
+}
diff --git a/docker/php.ini b/docker/php.ini
new file mode 100644
index 0000000..8266895
--- /dev/null
+++ b/docker/php.ini
@@ -0,0 +1,16 @@
+expose_php = Off
+display_errors = Off
+display_startup_errors = Off
+log_errors = On
+error_log = /proc/self/fd/2
+error_reporting = E_ALL
+date.timezone = Asia/Jakarta
+
+session.use_strict_mode = 1
+session.use_only_cookies = 1
+session.cookie_httponly = 1
+session.cookie_samesite = Lax
+session.save_path = /var/lib/php/sessions
+
+; Secure cookies are set by the application when Apache receives the HTTPS
+; indication from the trusted proxy (see apache.conf).
diff --git a/docs/deployment.md b/docs/deployment.md
new file mode 100644
index 0000000..37c717c
--- /dev/null
+++ b/docs/deployment.md
@@ -0,0 +1,256 @@
+# Server deployment: Komodo + Docker Compose
+
+This stack runs PHP 8.3/Apache, MySQL 8.4 LTS, and phpMyAdmin 5.2. No Node build,
+registry, or separate Komodo Build resource is required. The app image is built
+on your Docker host. MySQL has no published port. Database, application sessions,
+and phpMyAdmin sessions use persistent named volumes.
+
+To build/publish the app in Gitea Actions instead of on the server, see
+[the image build guide](image-build.md), including registry secrets and Komodo
+settings for pulling the published image. Local builds remain the default below.
+
+## 1. Configure the Komodo Stack
+
+Commit/push the deployment files, then create a **Stack** in Komodo:
+
+| Setting | Value |
+| --- | --- |
+| Server | Your server running Komodo Periphery and Docker Compose |
+| Source | This Git repository (configure a Git account if private) |
+| Branch | The branch containing these files |
+| Run directory | `.` (repository root) |
+| Compose file paths | `compose.yaml` |
+| Project name | `recruitment-hc` |
+| Run build (`run_build`) | **Enabled** — rebuilds PHP code on redeploy |
+| Auto pull (`auto_pull`) | **Disabled** — app is built locally |
+| Destroy before deploy | **Disabled** |
+| Automatic image updates | **Disabled** — upgrade deliberately after backup |
+
+Paste the settings from [`.env.example`](../.env.example) into the Stack's
+**Environment** field, replacing both password placeholders with separate random
+secrets. Generate each password with `openssl rand -hex 32`. Use Komodo secrets
+if available; do not commit the populated environment to Git. Leave the environment
+file path at `.env`: Komodo writes it and passes it to Compose.
+
+Deploy the Stack. **The app may initially be unhealthy** until you explicitly
+import the base database and run migrations below. Do not enable a deploy
+`--wait` flag on this first launch: it cannot become ready before that setup.
+
+For a files-on-host Stack instead, put the repository on the server, select
+**Files on host**, set Run directory to its absolute path, and use the same
+Compose/build/environment settings.
+
+Official reference: [Komodo Compose Stacks](https://komo.do/docs/deploy/compose).
+
+## 2. Import the database once
+
+On the Docker server, change to the Stack's checked-out **run directory**. The
+commands below assume the project name `recruitment-hc` and Komodo's default
+`.env` file; if you choose another project name/env path, adjust **every** command
+accordingly. Never run commands against a different project by accident.
+
+Wait for `db` to be healthy:
+
+```sh
+docker compose --env-file .env -p recruitment-hc ps
+```
+
+Obtain a trusted recruitment SQL export containing `in_daper`, `in_soal`, and
+`in_jawab`. Transfer it privately to a path **outside the checkout/document root**,
+for example `/srv/recruitment-private/initial.sql`, with restricted permissions.
+The repository currently also tracks an export under `DB/`; it may contain
+personal data and existing accounts. Keep the repository private, review/sanitize
+any export used in production, and remove unwanted demo/test access before opening
+the site. Neither `DB/` nor any export is included in the image or auto-imported.
+
+**Only import into a new, empty database.** Dumps can contain `DROP TABLE` and
+existing accounts/passwords. Check first; this should return **no table names**:
+
+```sh
+docker compose --env-file .env -p recruitment-hc exec -T db sh -c \
+ 'MYSQL_PWD="$MYSQL_PASSWORD" mysql -u "$MYSQL_USER" "$MYSQL_DATABASE" -e "SHOW TABLES"'
+```
+
+If it is empty, import and apply the migrations:
+
+```sh
+docker compose --env-file .env -p recruitment-hc exec -T db sh -c \
+ 'MYSQL_PWD="$MYSQL_PASSWORD" mysql -u "$MYSQL_USER" "$MYSQL_DATABASE"' \
+ < /srv/recruitment-private/initial.sql
+
+docker compose --env-file .env -p recruitment-hc exec -T app \
+ php /usr/local/share/recruitment/migrate.php
+```
+
+The migration command requires the three base tables, creates the admin and exam
+protection tables, and handles the historical admin identity schema when needed.
+It can be rerun without resetting users or test progress. Migrations are explicit,
+not run automatically on every container restart. Back up before applying them to
+an existing database. Future migrations must be added to the migration runner as
+part of their release.
+
+If you need a new admin account, run this from an **interactive SSH terminal**:
+
+```sh
+docker compose --env-file .env -p recruitment-hc exec app \
+ php dashboard/tools/create-admin.php
+```
+
+Use a unique email and a password of at least 12 characters. There is no built-in
+Docker admin account. Imported accounts remain intact; check who has access.
+
+## 3. Connect your existing HTTPS reverse proxy
+
+Default upstream: **`http://127.0.0.1:8086`**. `APP_PORT` can change the host port.
+This default assumes the reverse proxy runs on the Docker host itself.
+
+Your reverse proxy must:
+
+- Terminate HTTPS and redirect public HTTP to HTTPS.
+- Preserve the original `Host` and **overwrite** `X-Forwarded-Proto` with `https`
+ for HTTPS requests (do not forward an untrusted client header).
+- Forward the original client address for proxy logging/rate limiting.
+- Disable response caching for the application and add appropriate login rate
+ limiting, particularly for `/login.php` and `/dashboard/login.php`.
+
+Apache maps `X-Forwarded-Proto: https` to PHP's HTTPS flag, so both participant and
+admin cookies receive `Secure`. The old cPanel handler/domain redirect in the
+root `.htaccess` is intentionally not used in Docker. Container Apache rules deny
+internal PHP, CLI tools, migrations, dotfiles, and SQL/config/backup downloads.
+
+Example location inside an **existing Nginx HTTPS server block**:
+
+```nginx
+location / {
+ proxy_pass http://127.0.0.1:8086;
+ proxy_set_header Host $host;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Real-IP $remote_addr;
+}
+```
+
+If your proxy is itself in a Docker container, its `127.0.0.1` is **not** the host.
+To keep the host-port approach, set `APP_BIND_IP` to a private host interface
+reachable from that proxy, then use that host address/port as upstream. Restrict
+access to the proxy with firewall rules appropriate to Docker's published-port
+rules. Do not expose this trusted-header HTTP listener directly to the Internet;
+`0.0.0.0` without those restrictions is not a safe default.
+
+### Browser database access with phpMyAdmin
+
+phpMyAdmin is a separate service, bound to **`127.0.0.1:8087`** by default.
+It connects only to this stack's `db` service; automatic login and arbitrary
+server selection are not enabled. Log in with **`MYSQL_USER` / `MYSQL_PASSWORD`**
+from your Komodo environment, not your recruitment dashboard account. Select the
+database named by `MYSQL_DATABASE`. Use the database-scoped user, not root.
+
+For remote browser access without publishing another public endpoint, run this
+on your own computer and keep the SSH connection open:
+
+```sh
+ssh -N -L 8087:127.0.0.1:8087 your-user@your-server
+```
+
+Then open **http://127.0.0.1:8087/** in your local browser. Adjust the right-hand
+port if `PHPMYADMIN_PORT` differs; change the left-hand port if it is busy locally.
+Leave `PMA_ABSOLUTE_URI` empty for this tunnel workflow.
+
+Alternatively, configure a **separate, restricted HTTPS hostname** on your
+existing reverse proxy, with upstream `http://127.0.0.1:8087`, and set
+`PMA_ABSOLUTE_URI=https://db-admin.example.com/` (replace with your actual URL,
+including the trailing slash). Follow the proxy/header guidance above and protect
+this hostname with a VPN, IP allowlist, or additional proxy authentication. Disable
+proxy caching. Keep it separate from the public recruitment site. If your proxy
+runs in Docker, use a private reachable host address and set
+`PHPMYADMIN_BIND_IP` accordingly, with firewall restrictions as described above.
+Never expose phpMyAdmin directly on an unrestricted public HTTP port.
+
+Add these new settings from `.env.example` to the Komodo Stack Environment and
+redeploy. `PHPMYADMIN_PORT` defaults to `8087`; `PHPMYADMIN_UPLOAD_LIMIT` defaults
+to `64M`. For browser imports, adjust your reverse proxy's upload size/timeouts as
+needed; large dumps are better imported using the CLI command in section 2.
+Browser imports can also overwrite data: only import into a verified empty
+database, then run the migration command. No database initialization is automated.
+
+## 4. Verify
+
+```sh
+docker compose --env-file .env -p recruitment-hc ps
+curl --fail http://127.0.0.1:8086/healthz
+curl --fail http://127.0.0.1:8087/ > /dev/null
+docker compose --env-file .env -p recruitment-hc logs --tail=100 app db phpmyadmin
+```
+
+Adjust the curl address if you changed the bind IP or port. `/healthz` returns
+`200 ok` only when Apache/PHP, the DB connection, base tables, and required
+migration tables/admin identity columns are ready; otherwise it returns a generic
+`503 not ready` without database details. Docker health checks use this endpoint.
+phpMyAdmin has its own HTTP health check; it does not depend on application
+migrations being complete. Its login screen is available once MySQL is healthy.
+An unhealthy container does not automatically restart; investigate logs/schema.
+
+Then open the public HTTPS URL:
+
+- Participants: `/`
+- Admin: `/dashboard/login.php`
+
+Verify both session cookies have `Secure`, `HttpOnly`, and `SameSite=Lax` in browser
+devtools. Verify `/includes/conn.php`, `/dashboard/tools/create-admin.php`,
+`/dashboard/migrations/001_admin.sql`, and `/.env` are denied. Run a full test with
+a disposable participant before accepting real participants.
+
+## 5. Backup, redeploy, and recovery
+
+Keep backups outside Git and the document root. Take regular backups and test
+restoring them into an isolated stack. Pause user writes during backups involving
+legacy non-InnoDB tables; `--single-transaction` only guarantees a consistent
+snapshot for InnoDB. Include any active test data when planning maintenance.
+
+```sh
+umask 077
+mkdir -p /srv/recruitment-private/backups
+docker compose --env-file .env -p recruitment-hc exec -T db sh -c \
+ 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysqldump -u root --single-transaction --routines --triggers --no-tablespaces "$MYSQL_DATABASE"' \
+ > "/srv/recruitment-private/backups/recruitment-$(date +%Y%m%d-%H%M%S).sql"
+```
+
+Check the command's exit status and backup contents before relying on the file.
+SQL backups contain personal data and password hashes; restrict and protect them.
+Restore using the import command in section 2 **only after selecting a new empty
+database or explicitly approving replacement of the target data**; migrate after
+restoring if needed.
+
+To update, back up, pull the new commit in Komodo and redeploy with **Run build**
+enabled. The database volume is reused; imports are never repeated automatically.
+Apply migration changes explicitly when a release requires them. Rolling back an
+image does not undo schema changes; use a tested database restore if necessary.
+Image tags track compatible patch releases; consider pinning tested image digests
+in production for reproducible upgrades.
+
+Keep the Compose project name stable: it determines the volume names. Renaming the
+Stack/project can create a new empty database instead of attaching the old volume.
+**Never use `docker compose down -v` or delete `mysql_data` for a normal redeploy.**
+The persistent `php_sessions` volume preserves sessions across app recreations;
+run only one app instance with this file-based session setup.
+
+MySQL image environment variables initialize users/passwords only on the first
+start of an empty volume. Changing `.env` later does **not** rotate database
+passwords: update the actual MySQL accounts and Compose environment together.
+The app receives only its database-scoped user, never the root password.
+
+## Manual Compose deployment (without Komodo)
+
+From the repository root on the server:
+
+```sh
+cp .env.example .env
+chmod 600 .env
+# Edit .env and replace both password placeholders before proceeding.
+docker compose --env-file .env -p recruitment-hc config --quiet
+docker compose --env-file .env -p recruitment-hc up -d --build
+```
+
+Then follow sections 2–5. Docker uses its own environment-backed
+`includes/conn.php` baked into the image; it never reads or overwrites the host's
+private `includes/conn.php`. Standalone PHP/local preview behavior is unchanged.
diff --git a/docs/image-build.md b/docs/image-build.md
new file mode 100644
index 0000000..afa7873
--- /dev/null
+++ b/docs/image-build.md
@@ -0,0 +1,101 @@
+# Gitea Actions image build
+
+Gitea discovers [`.gitea/workflows/build-image.yaml`](../.gitea/workflows/build-image.yaml).
+The workflow builds the root `Dockerfile` for the **Docker daemon's native
+architecture** (typically `linux/amd64`) and publishes `:latest` to:
+
+```text
+gitea.cptckp.com/nurinda/recruitment-hc
+```
+
+Only the PHP/Apache app is built; MySQL and phpMyAdmin retain their official
+images. Database exports and host credentials are excluded by `.dockerignore`.
+
+## Setup
+
+1. Enable **Actions** for the repository and ensure Gitea's package/container
+ registry is enabled.
+2. Register an available runner with the **`ubuntu-latest`** label. Its job
+ environment needs Bash, Git, Node 20 support for these actions, Docker CLI,
+ and access to a Docker daemon with build support. No QEMU or separate Buildx
+ setup is required. Use an appropriately configured runner image or host-mode
+ runner; a bare Ubuntu job image without these tools is insufficient.
+ The runner must reach GitHub (pinned action downloads), Docker Hub (base images),
+ and `gitea.cptckp.com` over trusted HTTPS.
+3. In **Repository Settings → Actions → Secrets**, add:
+
+ | Secret | Value |
+ | --- | --- |
+ | `REGISTRY_USERNAME` | Gitea username allowed to publish packages under `Nurinda` |
+ | `REGISTRY_TOKEN` | That user's personal access token with **Packages: Read & Write** |
+
+ Use a dedicated publishing account if possible. Its account/token must have
+ access to the organization and its packages. The built-in `GITEA_TOKEN` is
+ not used for registry publishing. Never put credentials in the workflow.
+4. Commit and push these files to `main`, or run **Build and publish Docker
+ image** manually from the Actions tab.
+
+Docker daemon access is highly privileged. Use a dedicated CI runner, not your
+production Docker host. Only trusted changes merged/pushed to `main` are built;
+pull requests do not trigger this workflow.
+
+## Triggers and tags
+
+| Event | Behavior |
+| --- | --- |
+| Push to `main` | Build and push `:latest` |
+| Manual run on `main` | Build and push `:latest` |
+| Manual run on another branch or tag | Job skipped |
+| Pull request or tag push | No workflow run |
+
+The workflow has three steps: checkout, registry login, and `docker build` /
+`docker push`. It does not publish version/commit tags or multi-platform images.
+Use a runner with the same architecture as your deployment server.
+
+`latest` is mutable, and rebuilds can differ when upstream base images change.
+Record the pushed image digest and deploy by digest for reproducibility/rollback.
+Third-party actions are pinned to commit hashes with explicit GitHub URLs; review
+and update those pins when upgrading actions.
+
+## Use the published image with Komodo
+
+The workflow only builds/publishes; it does **not** redeploy Komodo. The existing
+`compose.yaml` still builds locally and remains usable without a registry.
+
+To deploy the CI image instead, add a small override file in your server-side
+Stack run directory, for example `compose.image.yaml`:
+
+```yaml
+services:
+ app:
+ image: gitea.cptckp.com/nurinda/recruitment-hc:latest
+```
+
+Prefer replacing `:latest` with the tested image digest (`@sha256:...` instead
+of `:latest`).
+Include both `compose.yaml` and `compose.image.yaml` in the Komodo Stack's Compose
+file paths, in that order. Then:
+
+- Disable **Run build** so Komodo does not build/tag a local image over the CI image.
+- Enable **Auto pull** so the image is pulled before deployment.
+- Configure Komodo's Registry provider/account for `gitea.cptckp.com`; for private
+ packages, use a token with **Packages: Read** on an account allowed to pull them.
+ Keep this separate from the CI write token.
+- Keep the Compose project name and volumes unchanged. Imports/migrations remain
+ explicit; follow [the deployment guide](deployment.md).
+
+Compose retains the base file's `build` declaration in this simple override. Use
+`docker compose ... up -d --no-build` for manual deployments of the CI image
+(after registry login and `docker compose ... pull`); do **not** use `--build`.
+Komodo's Auto pull supplies the image before `up`, with Run build disabled.
+
+For example, after configuring registry authentication on the server:
+
+```sh
+docker pull gitea.cptckp.com/nurinda/recruitment-hc:latest
+```
+
+Verify a successful workflow run and available package before deploying it.
+
+References: [Gitea Actions compatibility](https://docs.gitea.com/usage/actions/comparison/)
+and [Gitea Container Registry](https://docs.gitea.com/usage/packages/container/).