added deployment file
This commit is contained in:
1 parent
061d5fd8a4
commit
125eab3cd4
14 files changed
+704
-1
No files matched your search
@@ -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__
|
||||||
@@ -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=
|
||||||
@@ -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"
|
||||||
+25
@@ -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
|
||||||
@@ -10,7 +10,9 @@ Panduan akun, backup, dan runtime ada di [dev/README.md](dev/README.md). Hentika
|
|||||||
|
|
||||||
## Server
|
## 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.
|
- `assets/`: tampilan dan perilaku tes peserta.
|
||||||
- `includes/`: koneksi, autentikasi peserta, dan layanan sesi tes.
|
- `includes/`: koneksi, autentikasi peserta, dan layanan sesi tes.
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -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
|
||||||
|
|
||||||
|
<Directory /var/www/html>
|
||||||
|
Options -Indexes -MultiViews
|
||||||
|
AllowOverride None
|
||||||
|
DirectoryIndex index.php
|
||||||
|
Require all granted
|
||||||
|
</Directory>
|
||||||
|
|
||||||
|
# Internal code is still usable by PHP/CLI, never directly over HTTP.
|
||||||
|
<DirectoryMatch "^/var/www/html/(?:includes|DB|dev|docker|dashboard/(?:tools|migrations))(?:/|$)">
|
||||||
|
Require all denied
|
||||||
|
</DirectoryMatch>
|
||||||
|
|
||||||
|
# Also deny dotfiles and backup/config files if accidentally added later.
|
||||||
|
<LocationMatch "(?:^|/)\.">
|
||||||
|
Require all denied
|
||||||
|
</LocationMatch>
|
||||||
|
<FilesMatch "(?i)\.(?:sql(?:\.gz|\.zip)?|ini|env|toml|ya?ml|log|bak)$">
|
||||||
|
Require all denied
|
||||||
|
</FilesMatch>
|
||||||
|
|
||||||
|
Alias /healthz /usr/local/share/recruitment/health.php
|
||||||
|
<Directory /usr/local/share/recruitment>
|
||||||
|
Options -Indexes
|
||||||
|
AllowOverride None
|
||||||
|
Require all denied
|
||||||
|
<Files health.php>
|
||||||
|
Require all granted
|
||||||
|
</Files>
|
||||||
|
</Directory>
|
||||||
|
|
||||||
|
Header always set X-Content-Type-Options "nosniff"
|
||||||
|
Header always set Referrer-Policy "same-origin"
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
// Docker-only replacement for includes/conn.php; local/server config is untouched.
|
||||||
|
mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
|
||||||
|
foreach (['MYSQL_HOST', 'MYSQL_DATABASE', 'MYSQL_USER', 'MYSQL_PASSWORD'] as $key) {
|
||||||
|
if (getenv($key) === false || getenv($key) === '') {
|
||||||
|
throw new RuntimeException('Missing database environment variable: ' . $key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$conn = new mysqli(
|
||||||
|
getenv('MYSQL_HOST'),
|
||||||
|
getenv('MYSQL_USER'),
|
||||||
|
getenv('MYSQL_PASSWORD'),
|
||||||
|
getenv('MYSQL_DATABASE'),
|
||||||
|
(int) (getenv('MYSQL_PORT') ?: 3306)
|
||||||
|
);
|
||||||
|
$conn->set_charset('utf8mb4');
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
header('Cache-Control: no-store');
|
||||||
|
try {
|
||||||
|
require '/var/www/html/includes/conn.php';
|
||||||
|
// A database connection alone is insufficient: the initial import and
|
||||||
|
// migrations must also be present before this instance is ready for users.
|
||||||
|
foreach (['in_daper', 'in_soal', 'in_jawab', 'rec_admin', 'rec_exam_attempt', 'rec_exam_event', 'rec_candidate_login_guard'] as $table) {
|
||||||
|
$conn->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";
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
// Check Apache + PHP + database readiness without requiring curl in the image.
|
||||||
|
$socket = @fsockopen('127.0.0.1', 80, $errno, $error, 3);
|
||||||
|
if (!$socket) {
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
stream_set_timeout($socket, 3);
|
||||||
|
fwrite($socket, "GET /healthz HTTP/1.0\r\nHost: localhost\r\nConnection: close\r\n\r\n");
|
||||||
|
$status = fgets($socket);
|
||||||
|
fclose($socket);
|
||||||
|
exit(is_string($status) && preg_match('/^HTTP\/\d\.\d 200\b/', $status) ? 0 : 1);
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
if (PHP_SAPI !== 'cli') {
|
||||||
|
http_response_code(404);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
require '/var/www/html/includes/conn.php';
|
||||||
|
foreach (['in_daper', 'in_soal', 'in_jawab'] as $table) {
|
||||||
|
$conn->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);
|
||||||
|
}
|
||||||
@@ -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).
|
||||||
@@ -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.
|
||||||
@@ -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/).
|
||||||
Reference in new issue
Block a user