diff --git a/.claude/settings.json b/.claude/settings.json index 9e00e0d..b682447 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -8,7 +8,9 @@ "PowerShell(try { $r = Invoke-WebRequest -Uri \"http://127.0.0.1:42000/pinokio/path/pterm\" -TimeoutSec 5 -UseBasicParsing; $r.StatusCode; $r.Content } catch { \"LOOPBACK FAILED: $\\($_.Exception.Message\\)\" })", "PowerShell(Get-ChildItem \"C:\\\\Users\\\\User\\\\AppData\\\\Local\\\\Programs\\\\Pinokio\" | Select-Object Name, Length, LastWriteTime; \"---\"; Get-ChildItem \"$env:APPDATA\\\\Pinokio\" -ErrorAction SilentlyContinue | Select-Object Name, LastWriteTime)", "PowerShell(php artisan tinker --execute=\"`$s = App\\\\Models\\\\SesiTaklimat::create\\(['dun_id'=>1,'tarikh'=>now\\(\\)->toDateString\\(\\),'nama'=>'Taklimat Petugas Sesi 1','lokasi'=>'Dewan Serbaguna Gelang Patah','masa'=>'9:00 pagi - 12:00 tengah hari','created_by_user_id'=>1]\\); echo 'SESI ID: '.`$s->id.PHP_EOL.'TOKEN: '.`$s->qr_token.PHP_EOL; `$p = App\\\\Models\\\\Sppm\\\\PetugasPermohonan::assignedUntukDun\\(1\\)->get\\(['id','nama_ic','no_kp']\\); echo `$p->toJson\\(JSON_PRETTY_PRINT\\);\")", - "PowerShell(php artisan serve --port=8000)" + "PowerShell(php artisan serve --port=8000)", + "Bash(docker version *)", + "Bash(docker run *)" ] } } diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ee5a69c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,34 @@ +# Version control / tooling +.git +.gitignore +.gitattributes +.editorconfig +.claude +.vscode +.idea + +# Local env (provide env at runtime via .env.docker / compose) +.env +.env.backup +.env.production + +# Dependencies & build artifacts (rebuilt inside the image) +vendor +node_modules +public/build +public/hot +public/storage + +# Runtime / logs / caches +storage/logs/* +storage/framework/cache/data/* +storage/framework/sessions/* +storage/framework/views/* +storage/pail +*.log + +# Docs / tests not needed in the image +README.md +DOCKER.md +phpunit.xml +tests diff --git a/.gitignore b/.gitignore index 7be55e2..d1c232a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ .env .env.backup .env.production +.env.docker .phpactor.json .phpunit.result.cache /.codex diff --git a/DOCKER.md b/DOCKER.md new file mode 100644 index 0000000..f5d4f5d --- /dev/null +++ b/DOCKER.md @@ -0,0 +1,161 @@ +# Running with Docker + +This app is packaged as a small Docker stack and is designed to run **behind +the host's existing Nginx**, connecting to the **host's existing MySQL** (where +both `sppm_taklimat` and the external `sppm` database live). + +``` +Internet + │ + ▼ +host nginx (taklimatspr.apps.mbip.my, port 80) ← reverse proxy + │ proxy_pass 127.0.0.1:8080 + ▼ +web container (nginx) ──fastcgi──► app container (php-fpm 8.4) + │ + ▼ host.docker.internal:3306 + host MySQL (sppm_taklimat, sppm) +``` + +Containers: + +| Service | Image | Role | +|-------------|--------------------|-------------------------------------------------| +| `web` | `taklimatspr-web` | nginx, serves static assets, proxies PHP. Binds `127.0.0.1:8080`. | +| `app` | `taklimatspr-app` | PHP 8.4-FPM. Runs migrations on boot. | +| `queue` | `taklimatspr-app` | `queue:work` (database queue). | +| `scheduler` | `taklimatspr-app` | `schedule:work` (Laravel scheduler). | + +--- + +## Prerequisites on the host (Ubuntu 24.04) + +- Docker Engine + Compose plugin: + ```bash + sudo apt-get update + sudo apt-get install -y docker.io docker-compose-v2 + sudo systemctl enable --now docker + ``` +- The existing Nginx and MySQL services already installed on the host. + +--- + +## 1. Configure host MySQL to accept connections from containers + +On Linux, `host.docker.internal` resolves to the Docker bridge gateway (e.g. +`172.17.0.1`) via the `host-gateway` mapping already set in +`docker-compose.yml`. The host MySQL must accept connections on that interface. + +**a) Listen on all interfaces** — edit +`/etc/mysql/mysql.conf.d/mysqld.cnf`: + +```ini +bind-address = 0.0.0.0 +``` +Then restart: `sudo systemctl restart mysql`. + +**b) Allow the app user from the Docker subnet.** Prefer a dedicated user over +`root` (and then set the credentials in `.env.docker`): + +```sql +CREATE USER 'taklimat'@'172.%' IDENTIFIED BY 'CHANGE_ME_STRONG'; +GRANT ALL PRIVILEGES ON sppm_taklimat.* TO 'taklimat'@'172.%'; +GRANT SELECT ON sppm.* TO 'taklimat'@'172.%'; +FLUSH PRIVILEGES; +``` + +Make sure the `sppm_taklimat` database exists: +```sql +CREATE DATABASE IF NOT EXISTS sppm_taklimat + CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +``` + +**c) Firewall** — if `ufw` is active, allow MySQL from the Docker subnet only: +```bash +sudo ufw allow from 172.16.0.0/12 to any port 3306 proto tcp +``` + +--- + +## 2. Set the container environment + +`.env.docker` holds the runtime config (it is git-ignored because it contains +credentials). Review it and update at least: + +- `DB_USERNAME` / `DB_PASSWORD` and `SPPM_DB_*` to match step 1b. +- `APP_KEY` is pre-filled; to rotate it run + `docker compose run --rm app php artisan key:generate --show` and paste the + result. + +`DB_HOST` / `SPPM_DB_HOST` are already set to `host.docker.internal` — leave them. + +--- + +## 3. Build and start + +```bash +docker compose build +docker compose up -d +``` + +On boot the `app` container waits for MySQL, caches config/routes/views, runs +`php artisan migrate --force`, and starts php-fpm. Check it: + +```bash +docker compose ps +docker compose logs -f app +curl -I http://127.0.0.1:8080 # should return HTTP 200/302 +``` + +--- + +## 4. Point host Nginx at the stack + +```bash +sudo cp docker/host-nginx/taklimatspr.apps.mbip.my.conf \ + /etc/nginx/sites-available/taklimatspr.apps.mbip.my +sudo ln -s /etc/nginx/sites-available/taklimatspr.apps.mbip.my \ + /etc/nginx/sites-enabled/ +sudo nginx -t && sudo systemctl reload nginx +``` + +The app should now be reachable at . + +--- + +## Common operations + +```bash +# Tail logs +docker compose logs -f app web queue + +# Artisan / composer inside the app container +docker compose exec app php artisan about +docker compose exec app php artisan tinker + +# Re-cache config after editing .env.docker +docker compose exec app php artisan config:cache + +# Rebuild & redeploy after pulling new code +docker compose build && docker compose up -d +``` + +--- + +## Notes & limitations + +- **Static uploads:** `web` (nginx) serves a baked copy of `public/`. Files + written at runtime to `storage/app/public` (the `public` disk + `storage:link`) + live only in the `app` container and are **not** served by nginx. The current + app renders QR codes as inline SVG, so this isn't needed today — but if you + later serve uploaded files via `/storage`, switch `web` to share the app's + public/storage via a volume. +- **Persistent data:** the named volume `app_storage` keeps logs, compiled + views, and `storage/app` across restarts. Application/session data lives in + MySQL. +- **TLS:** terminate HTTPS at the host Nginx (e.g. with certbot) if/when the + domain moves to `https://`. Then add `SESSION_SECURE_COOKIE=true` and + `APP_URL=https://...` in `.env.docker`. +- **HEALTHCHECK / scheduler:** there are currently no scheduled commands, but + the `scheduler` service is included so future `app/Console` schedules work + without changing the stack. Remove it if you prefer. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..635a3ba --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,65 @@ +# Docker stack for "Kehadiran Taklimat SPR" (Laravel 13 / PHP 8.4) +# +# Topology: +# web (nginx) -> exposes 127.0.0.1:8080, host nginx reverse-proxies the +# domain http://taklimatspr.apps.mbip.my to it. +# app (fpm) -> PHP 8.4-FPM, runs migrations on boot. +# queue -> database queue worker. +# scheduler -> Laravel scheduler. +# +# Databases (sppm_taklimat + the existing sppm DB) live on the HOST MySQL and +# are reached via host.docker.internal (see extra_hosts below + DOCKER.md). + +x-app-build: &app-build + context: . + dockerfile: docker/Dockerfile + target: app + +x-app-common: &app-common + build: *app-build + image: taklimatspr-app:latest + restart: unless-stopped + env_file: + - .env.docker + extra_hosts: + - "host.docker.internal:host-gateway" + volumes: + - app_storage:/var/www/html/storage + +services: + app: + <<: *app-common + environment: + RUN_MIGRATIONS: "true" + + web: + build: + context: . + dockerfile: docker/Dockerfile + target: web + image: taklimatspr-web:latest + restart: unless-stopped + depends_on: + - app + ports: + # Bind to loopback only; the host nginx terminates the public domain. + - "127.0.0.1:8008:80" + + queue: + <<: *app-common + environment: + RUN_MIGRATIONS: "false" + command: php artisan queue:work --sleep=3 --tries=3 --max-time=3600 + depends_on: + - app + + scheduler: + <<: *app-common + environment: + RUN_MIGRATIONS: "false" + command: php artisan schedule:work + depends_on: + - app + +volumes: + app_storage: diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..8ed4917 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,99 @@ +# syntax=docker/dockerfile:1 + +# --------------------------------------------------------------------------- +# Stage 1: build front-end assets with Vite (Node 22 ~ npm 10.9.x) +# --------------------------------------------------------------------------- +FROM node:22-bookworm-slim AS assets +WORKDIR /app + +# Install JS deps first for better layer caching. No package-lock.json is +# committed, so use `npm install` rather than `npm ci`. +COPY package.json ./ +RUN npm install + +# Only the inputs Vite needs to produce public/build +COPY vite.config.js ./ +COPY resources ./resources +RUN npm run build + + +# --------------------------------------------------------------------------- +# Stage 2: PHP 8.4 dependencies + application code (the "app" / php-fpm image) +# --------------------------------------------------------------------------- +FROM php:8.4-fpm-bookworm AS app + +# System libraries required to build the PHP extensions below. +RUN apt-get update && apt-get install -y --no-install-recommends \ + git \ + unzip \ + rsync \ + libzip-dev \ + libpng-dev \ + libjpeg-dev \ + libfreetype-dev \ + libonig-dev \ + libicu-dev \ + libxml2-dev \ + && docker-php-ext-configure gd --with-freetype --with-jpeg \ + && docker-php-ext-install -j"$(nproc)" \ + pdo_mysql \ + mbstring \ + bcmath \ + gd \ + zip \ + intl \ + exif \ + pcntl \ + opcache \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Composer (pulled from the official image; 2.x line, matches host 2.9.x) +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +# PHP runtime configuration +COPY docker/php/php.ini /usr/local/etc/php/conf.d/zz-app.ini + +WORKDIR /var/www/html + +# Install PHP dependencies first (better caching). Skip scripts/autoloader +# until the full source tree is present. +COPY composer.json composer.lock ./ +RUN composer install \ + --no-dev \ + --no-scripts \ + --no-autoloader \ + --prefer-dist \ + --no-interaction \ + --no-progress + +# Application source + freshly built front-end assets +COPY . . +COPY --from=assets /app/public/build ./public/build + +# Optimised autoloader (package discovery runs at container start instead, +# where the environment is available). +RUN composer dump-autoload --no-dev --optimize --no-interaction \ + && chown -R www-data:www-data storage bootstrap/cache \ + && chmod -R ug+rwX storage bootstrap/cache + +# Entrypoint prepares storage, caches config, runs migrations, then runs the +# given command (php-fpm by default). +COPY docker/php/entrypoint.sh /usr/local/bin/entrypoint +RUN chmod +x /usr/local/bin/entrypoint + +EXPOSE 9000 +ENTRYPOINT ["entrypoint"] +CMD ["php-fpm"] + + +# --------------------------------------------------------------------------- +# Stage 3: nginx serving the static assets + proxying PHP to the app container +# --------------------------------------------------------------------------- +FROM nginx:1.27-alpine AS web + +# Baked copy of the public root so nginx can serve static files directly. +COPY --from=app /var/www/html/public /var/www/html/public +COPY docker/nginx/default.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 diff --git a/docker/host-nginx/taklimatspr.apps.mbip.my.conf b/docker/host-nginx/taklimatspr.apps.mbip.my.conf new file mode 100644 index 0000000..ffb21e4 --- /dev/null +++ b/docker/host-nginx/taklimatspr.apps.mbip.my.conf @@ -0,0 +1,32 @@ +# Host nginx reverse proxy for the Dockerized app. +# Install on the Ubuntu host: +# sudo cp docker/host-nginx/taklimatspr.apps.mbip.my.conf \ +# /etc/nginx/sites-available/taklimatspr.apps.mbip.my +# sudo ln -s /etc/nginx/sites-available/taklimatspr.apps.mbip.my \ +# /etc/nginx/sites-enabled/ +# sudo nginx -t && sudo systemctl reload nginx + +server { + listen 80; + listen [::]:80; + server_name taklimatspr.apps.mbip.my; + + client_max_body_size 21M; + + access_log /var/log/nginx/taklimatspr.access.log; + error_log /var/log/nginx/taklimatspr.error.log; + + location / { + proxy_pass http://127.0.0.1:8008; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + + proxy_connect_timeout 30s; + proxy_read_timeout 60s; + } +} diff --git a/docker/nginx/default.conf b/docker/nginx/default.conf new file mode 100644 index 0000000..577915b --- /dev/null +++ b/docker/nginx/default.conf @@ -0,0 +1,34 @@ +server { + listen 80; + server_name _; + + root /var/www/html/public; + index index.php; + + charset utf-8; + client_max_body_size 21M; + + # Real client IP / forwarded headers come from the host nginx in front. + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + location = /favicon.ico { access_log off; log_not_found off; } + location = /robots.txt { access_log off; log_not_found off; } + + error_page 404 /index.php; + + # Pass PHP requests to the app (php-fpm) container. + location ~ \.php$ { + fastcgi_pass app:9000; + fastcgi_index index.php; + fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; + include fastcgi_params; + fastcgi_hide_header X-Powered-By; + } + + # Deny access to hidden files (e.g. .env) and version control. + location ~ /\.(?!well-known).* { + deny all; + } +} diff --git a/docker/php/entrypoint.sh b/docker/php/entrypoint.sh new file mode 100644 index 0000000..d1bd252 --- /dev/null +++ b/docker/php/entrypoint.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd /var/www/html + +echo "[entrypoint] Preparing storage directories..." +mkdir -p \ + storage/app/public \ + storage/framework/cache/data \ + storage/framework/sessions \ + storage/framework/views \ + storage/framework/testing \ + storage/logs \ + bootstrap/cache +chown -R www-data:www-data storage bootstrap/cache || true +chmod -R ug+rwX storage bootstrap/cache || true + +# Refresh package manifest (env vars are available now via compose env_file). +php artisan package:discover --ansi || true + +# Wait for the database to be reachable before migrating / caching config. +wait_for_db() { + local host="${DB_HOST:-127.0.0.1}" + local port="${DB_PORT:-3306}" + echo "[entrypoint] Waiting for database at ${host}:${port}..." + for i in $(seq 1 30); do + if php -r ' + $h=getenv("DB_HOST")?:"127.0.0.1"; + $p=getenv("DB_PORT")?:"3306"; + $d=getenv("DB_DATABASE")?:""; + $u=getenv("DB_USERNAME")?:"root"; + $w=getenv("DB_PASSWORD")?:""; + try { new PDO("mysql:host=$h;port=$p;dbname=$d", $u, $w, [PDO::ATTR_TIMEOUT=>2]); exit(0); } + catch (Throwable $e) { exit(1); } + ' >/dev/null 2>&1; then + echo "[entrypoint] Database is up." + return 0 + fi + sleep 2 + done + echo "[entrypoint] WARNING: database not reachable after timeout; continuing anyway." + return 0 +} + +# Cache framework config for performance. Runs in every role so all containers +# share the same compiled config. +cache_app() { + php artisan config:cache --ansi || true + php artisan route:cache --ansi || true + php artisan view:cache --ansi || true +} + +# Only the primary (web/app) container runs migrations & storage:link, so the +# queue/scheduler workers don't race on them. Toggle with RUN_MIGRATIONS. +if [ "${RUN_MIGRATIONS:-true}" = "true" ]; then + wait_for_db + cache_app + echo "[entrypoint] Running migrations..." + php artisan migrate --force --ansi || echo "[entrypoint] WARNING: migrate failed." + php artisan storage:link --ansi || true +else + cache_app +fi + +echo "[entrypoint] Starting: $*" +exec "$@" diff --git a/docker/php/php.ini b/docker/php/php.ini new file mode 100644 index 0000000..176e5ae --- /dev/null +++ b/docker/php/php.ini @@ -0,0 +1,20 @@ +; Application PHP settings (production-leaning) +expose_php = Off +memory_limit = 256M +max_execution_time = 60 + +; File uploads (attendance app — keep modest but comfortable) +upload_max_filesize = 20M +post_max_size = 21M + +; Timezone (app uses Asia/Kuala_Lumpur) +date.timezone = Asia/Kuala_Lumpur + +; OPcache — enabled for production performance +opcache.enable = 1 +opcache.enable_cli = 0 +opcache.memory_consumption = 128 +opcache.interned_strings_buffer = 16 +opcache.max_accelerated_files = 20000 +opcache.validate_timestamps = 1 +opcache.revalidate_freq = 60