Merge pull request #127 from copenhagentruckwash/scalability-enhancement

scalability-enhancement
This commit is contained in:
Jeppe B
2026-02-16 13:26:55 +01:00
committed by GitHub
23 changed files with 1147 additions and 1689 deletions
+20
View File
@@ -0,0 +1,20 @@
## Environment overrides for docker-compose
# Path to the host's Let's Encrypt directory that contains live/<domain>/ certs.
# This directory is bind-mounted into the Nginx container at /etc/letsencrypt.
# Linux hosts (default)
LETSENCRYPT_PATH=/etc/letsencrypt
# Windows (Docker Desktop with WSL Ubuntu). Uncomment and adjust distro name if different.
# LETSENCRYPT_PATH=//wsl$/Ubuntu/etc/letsencrypt
# Notes:
# - Ensure the following files exist under the path you set:
# live/api.truckwash.dk/fullchain.pem
# live/api.truckwash.dk/privkey.pem
# live/cloud.truckwash.dk/fullchain.pem
# live/cloud.truckwash.dk/privkey.pem
# - If you do not have certs for cloud.truckwash.dk locally, either comment out that
# TLS server block in services/nginx/nginx.conf or place a temporary self-signed
# cert/key pair at the expected path.
+34 -4
View File
@@ -9,6 +9,10 @@ RUN apt-get update && apt-get install -y \
libpq-dev \
libzip-dev \
libcurl4-openssl-dev \
libpng-dev \
libjpeg62-turbo-dev \
libfreetype6-dev \
libxml2-dev \
default-mysql-client \
redis-server \
nginx \
@@ -20,6 +24,14 @@ RUN apt-get update && apt-get install -y \
bcmath \
sockets
# Build and enable common extensions required by Composer dependencies
RUN docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j$(nproc) gd mbstring dom simplexml
# Install and enable PHP Redis extension for session storage
RUN pecl install redis \
&& docker-php-ext-enable redis
# Set working directory
WORKDIR /var/www/html
@@ -32,8 +44,23 @@ COPY nginx.conf /etc/nginx/nginx.conf
# Install Composer
COPY --from=composer:2.6 /usr/bin/composer /usr/bin/composer
# Install PHP dependencies through Composer
RUN composer install --no-dev --optimize-autoloader
# Runtime bootstrap: lightweight entrypoint to ensure Composer deps exist when app is bind-mounted
COPY services/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Install PHP dependencies through Composer (only where composer.json exists)
# Main app dependencies
RUN set -e \
&& if [ -f /var/www/html/services/nginx/app/composer.json ]; then \
COMPOSER_ALLOW_SUPERUSER=1 composer install --no-dev --optimize-autoloader -d /var/www/html/services/nginx/app; \
else \
echo "No composer.json in /var/www/html/services/nginx/app — skipping"; \
fi \
&& if [ -f /var/www/html/services/nginx/app/modules/washcertificates/composer.json ]; then \
COMPOSER_ALLOW_SUPERUSER=1 composer install --no-dev --optimize-autoloader -d /var/www/html/services/nginx/app/modules/washcertificates; \
else \
echo "No composer.json in washcertificates module — skipping"; \
fi
# Set permissions for the web server
RUN chown -R www-data:www-data /var/www/html && chmod -R 755 /var/www/html
@@ -41,5 +68,8 @@ RUN chown -R www-data:www-data /var/www/html && chmod -R 755 /var/www/html
# Expose port 80 (HTTP) and 443 (HTTPS)
EXPOSE 80 443
# Start services (Nginx and Redis) when the container starts
CMD service redis-server start && nginx -g "daemon off;" && php-fpm
# Default entrypoint ensures vendor/autoload.php exists, then executes the given command (overridden by docker-compose)
ENTRYPOINT ["docker-entrypoint.sh"]
# Start services when no command is provided (docker-compose overrides this with ["php-fpm"])
CMD ["php-fpm"]
+35
View File
@@ -0,0 +1,35 @@
### Local TLS certificates with Docker Compose
Nginx is configured to terminate TLS using certificates mounted at `/etc/letsencrypt` inside the container. To use your real Lets Encrypt certificates on the host, the Compose file mounts a configurable path via `LETSENCRYPT_PATH`.
#### 1) Configure the host certificate path
Copy `.env.example` to `.env` in the project root and adjust the path for your environment:
```
cp .env.example .env
# Edit .env and set one of the following:
# Linux: LETSENCRYPT_PATH=/etc/letsencrypt
# Windows (WSL Ubuntu): LETSENCRYPT_PATH=//wsl$/Ubuntu/etc/letsencrypt
```
Ensure the following files exist under the path you set:
- `live/api.truckwash.dk/fullchain.pem`
- `live/api.truckwash.dk/privkey.pem`
- `live/cloud.truckwash.dk/fullchain.pem`
- `live/cloud.truckwash.dk/privkey.pem`
If you dont have certs for `cloud.truckwash.dk` locally, either comment out that TLS server block in `services/nginx/nginx.conf` or place a temporary selfsigned cert/key at the expected path.
#### 2) Start Nginx
```
docker compose up -d nginx
```
Check logs:
```
docker compose logs -f nginx
```
#### 3) Troubleshooting
- “cannot load certificate … no such file or directory”: verify `LETSENCRYPT_PATH` and that all `live/<domain>/` files exist on the host.
- For purely local testing without HTTPS, you may comment out the HTTPS server blocks and the HTTP→HTTPS redirect in `services/nginx/nginx.conf` and run on port 80.
+30
View File
@@ -0,0 +1,30 @@
services:
traefik:
# Use hardened Traefik config in production (no staging CA, no tracing/metrics)
volumes:
- ./services/traefik/traefik.prod.yml:/etc/traefik/traefik.yml:ro
- ./services/traefik/dynamic.yml:/etc/traefik/dynamic.yml:ro
- ./services/traefik/acme.json:/acme.json
ports:
- "80:80"
- "443:443"
redis:
ports: []
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
## Usage (examples):
## - With explicit files (recommended):
## docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d traefik caddy php1 php2 php3 php4 php5 db redis
##
## - Or set COMPOSE_FILE for the shell session (PowerShell on Windows):
## $env:COMPOSE_FILE = "docker-compose.yml;docker-compose.prod.yml"
## docker compose up -d traefik caddy php1 php2 php3 php4 php5 db redis
##
## Notes:
## - Traefik uses Lets Encrypt production. Ensure DNS A/AAAA records for api.truckwash.dk and traefik.truckwash.dk point to this host and ports 80/443 are reachable.
## - The dashboard is protected by basic auth and an IP allowlist (defined in dynamic.yml). Replace the bcrypt hash before enabling in production.
+27
View File
@@ -0,0 +1,27 @@
{
# Traefik terminates TLS; Caddy should serve plain HTTP internally
auto_https off
}
:80 {
encode gzip
root * /var/www/html
# CORS is handled at the edge by Traefik's headers middleware.
# Do not set or strip Access-Control-* headers here to avoid conflicts.
# PHP handling via FastCGI to php-fpm pool
php_fastcgi php1:9000 php2:9000 php3:9000 php4:9000 php5:9000
try_files {path} {path}/ /index.php
file_server
log {
output file /var/log/caddy/access.log {
roll_size 10MiB
roll_keep 5
roll_keep_for 720h
}
format json
}
}
View File
+39
View File
@@ -0,0 +1,39 @@
strict.perms: false
filebeat.inputs:
- type: filestream
id: nginx-access
paths: ["/var/log/nginx/*.ndjson"]
parsers:
- ndjson:
overwrite_keys: true
add_error_key: true
fields:
service: nginx
log_type: access
fields_under_root: true
- type: filestream
id: nginx-error
paths: ["/var/log/nginx/error.log"]
fields:
service: nginx
log_type: error
fields_under_root: true
- type: filestream
id: php-error
paths: ["/var/log/php/*.log"]
fields:
service: php
log_type: error
fields_under_root: true
setup.kibana.host: "http://kibana:5601"
setup.kibana.username: elastic
setup.kibana.password: ${ELASTIC_PASSWORD}
output.elasticsearch:
hosts: ["http://elasticsearch:9200"]
username: elastic
password: ${ELASTIC_PASSWORD}
+48
View File
@@ -0,0 +1,48 @@
metricbeat.config.modules:
path: ${path.config}/modules.d/*.yml
reload.enabled: false
setup.kibana.host: "http://kibana:5601"
setup.kibana.username: elastic
setup.kibana.password: ${ELASTIC_PASSWORD}
setup.dashboards.enabled: true
output.elasticsearch:
hosts: ["http://elasticsearch:9200"]
username: elastic
password: ${ELASTIC_PASSWORD}
metricbeat.modules:
# System metrics (container-level when running in Docker Desktop)
- module: system
period: 10s
metricsets:
- cpu
- load
- memory
- network
- process
- process_summary
- filesystem
- fsstat
- uptime
processes: ['.*']
# Docker metrics (containers CPU/mem/network, etc.)
- module: docker
hosts: ["unix:///var/run/docker.sock"]
period: 10s
enabled: true
# Common metricsets: container,cpu,diskio,event,healthcheck,info,memory,network
# Use defaults to keep noise reasonable
# Nginx metrics from stub_status (enabled in nginx.dev.conf on port 8080)
- module: nginx
metricsets: ["stubstatus"]
period: 10s
hosts: ["http://nginx:8080/stub_status"]
enabled: true
processors:
- add_host_metadata: ~
- add_docker_metadata: ~
+1 -1
View File
@@ -2,7 +2,7 @@
FROM nginx:1.27.3-alpine
# Import the configuration file for Nginx
COPY /nginx/nginx.conf /etc/nginx/nginx.conf
COPY nginx.conf /etc/nginx/nginx.conf
# Copy the contents of the /app directory to the /var/www/html directory (This is not necessary for this, since we are using PHP and Nginx in separate containers - And we are not using Nginx to serve static files)
#COPY /app /var/www/html
+1 -2
View File
@@ -14,8 +14,7 @@
"php-http/guzzle7-adapter": "^1.1",
"nyholm/psr7": "^1.8",
"mailersend/mailersend": "^0.28.0",
"spipu/html2pdf": "^5.3",
"microsoft/microsoft-graph": "2.7.0"
"spipu/html2pdf": "^5.3"
},
"config": {
"allow-plugins": {
+263 -1646
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -21,7 +21,7 @@ header("Access-Control-Allow-Headers: Content-Type, Authorization, X-Customer-Nu
header("Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS");
/** Autoload */
require_once 'vendor/autoload.php';
require_once __DIR__ . '/vendor/autoload.php';
/** Load all Interfaces */
foreach ( glob(WD . '/interfaces/*.php') as $interface ) {
require_once $interface;
+34 -2
View File
@@ -26,8 +26,8 @@ class exampleRoute
// This is very hack-y, but it works for now
// Check if it's monday
if ((int)date('N') === 1) {
// Check if the time is between 06:00 and 17:00
if (!redis->exists('system:last_sent_monday_message') && (date('H') >= 6 && date('H') < 17)) {
// Check if the time is between 07:00 and 17:00
if (!redis->exists('system:last_sent_monday_message') && (date('H') >= 7 && date('H') < 17)) {
// Set the key to expire in 24 hours
redis->set('system:last_sent_monday_message', date('Y-m-d H:i:s'));
redis->expire('system:last_sent_monday_message', 86400);
@@ -55,6 +55,38 @@ class exampleRoute
$response->success(['message' => 'Hello World!']);
});
$this->get('/tmp-washes-in-time', function () {
global $response;
$response->error(['message' => 'This route is deprecated.']);
$date_from = date('2026-01-01 00:00:00');
$date_to = date('2026-01-31 23:59:59');
// Set the hours on the date range to be from 17:00-23:59
$daily_start_time = '17:00:00';
$daily_end_time = '23:59:59';
// Loop through each day in the date range, and get the washes that were done in the time range
$washes = [];
$current_date = $date_from;
while (strtotime($current_date) <= strtotime($date_to)) {
$daily_start = date('Y-m-d', strtotime($current_date)) . ' ' . $daily_start_time;
$daily_end = date('Y-m-d', strtotime($current_date)) . ' ' . $daily_end_time;
$daily_washes = (new orders_o())->getWashesInTimeRange($daily_start, $daily_end, ['department_id' => 7]);
$washes = array_merge($washes, $daily_washes);
$current_date = date('Y-m-d H:i:s', strtotime($current_date . ' +1 day'));
}
$wash_arrays = array_map(function ($wash) {
return $wash->asArray();
}, $washes);
// Create a CSV file from the washes $csv = "Order ID,Customer ID,Department ID,Created At\n";
foreach ($wash_arrays as $wash) {
$csv .= "{$wash['id']},{$wash['customer_id']},{$wash['department_id']},{$wash['created_at']}\n";
}
// Output the CSV file
header('Content-Type: text/csv');
header('Content-Disposition: attachment; filename="washes_in_time_range.csv"');
echo $csv;
exit;
});
$this->get('/tmp-customer-list-overcharged', function () {
global $response;
$response->error(['message' => 'This route is deprecated.']);
+73 -7
View File
@@ -14,9 +14,13 @@ http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Structured JSON access logs and correlation id
map $http_x_request_id $req_id { default $http_x_request_id; "" $request_id; }
log_format json escape=json '{"time":"$time_iso8601","req_id":"$req_id","remote":"$remote_addr","host":"$host","method":"$request_method","uri":"$request_uri","status":$status,"bytes":$bytes_sent,"referer":"$http_referer","ua":"$http_user_agent","up_addr":"$upstream_addr","up_st":"$upstream_status","up_rt":$upstream_response_time,"rt":$request_time}';
# Log settings
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
access_log /var/log/nginx/access.ndjson json;
error_log /var/log/nginx/error.log warn;
# Gzip settings (optional)
gzip on;
@@ -25,6 +29,17 @@ http {
# Include server block configurations
include /etc/nginx/conf.d/*.conf;
# Upstream pool for PHP-FPM (load-balanced)
upstream php_fpm {
least_conn;
server php1:9000 max_fails=3 fail_timeout=10s;
server php2:9000 max_fails=3 fail_timeout=10s;
server php3:9000 max_fails=3 fail_timeout=10s;
server php4:9000 max_fails=3 fail_timeout=10s;
server php5:9000 max_fails=3 fail_timeout=10s;
keepalive 32;
}
# Redirect HTTP to HTTPS
server {
@@ -34,6 +49,55 @@ http {
return 301 https://$host$request_uri;
}
# Localhost api server block (api.truckwash.dk)
server {
listen 80;
server_name localhost;
# Logging
add_header X-Request-Id $req_id always;
error_log /var/log/nginx/error.log;
access_log /var/log/nginx/access.ndjson json;
# Index file
index index.php index.html index.htm;
# Root directory (public- uses index.php)
root /var/www/html;
# Location block for PHP files
location ^~ / {
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS, PUT, DELETE";
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number";
add_header Access-Control-Allow-Credentials true;
if ($request_method = 'OPTIONS') {
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS, PUT, DELETE";
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number";
return 204;
}
include fastcgi_params;
fastcgi_pass php_fpm;
fastcgi_index index.php;
fastcgi_read_timeout 60s;
fastcgi_param SCRIPT_FILENAME $document_root/index.php;
fastcgi_param X_REQUEST_ID $req_id;
fastcgi_keep_conn on;
}
# Deny access to .htaccess files
location ~ /\.ht {
deny all;
}
# Deny access to hidden files
location ~ /\. {
deny all;
}
}
server {
listen 443 ssl;
server_name api.truckwash.dk;
@@ -48,8 +112,9 @@ http {
ssl_prefer_server_ciphers on;
# Logging
add_header X-Request-Id $req_id always;
error_log /var/log/nginx/error.log;
access_log /var/log/nginx/access.log combined;
access_log /var/log/nginx/access.ndjson json;
# Index file
index index.php index.html index.htm;
@@ -58,7 +123,6 @@ http {
root /var/www/html;
# Location block for PHP files
location ^~ / {
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS, PUT, DELETE";
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number";
@@ -72,10 +136,12 @@ http {
}
include fastcgi_params;
fastcgi_pass php:9000;
fastcgi_pass php_fpm;
fastcgi_index index.php;
fastcgi_read_timeout 60;
fastcgi_param SCRIPT_FILENAME index.php;
fastcgi_read_timeout 60s;
fastcgi_param SCRIPT_FILENAME $document_root/index.php;
fastcgi_param X_REQUEST_ID $req_id;
fastcgi_keep_conn on;
}
# Deny access to .htaccess files
+87
View File
@@ -0,0 +1,87 @@
worker_processes auto;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Structured JSON access logs and correlation id
map $http_x_request_id $req_id { default $http_x_request_id; "" $request_id; }
log_format json escape=json '{"time":"$time_iso8601","req_id":"$req_id","remote":"$remote_addr","host":"$host","method":"$request_method","uri":"$request_uri","status":$status,"bytes":$bytes_sent,"referer":"$http_referer","ua":"$http_user_agent","up_addr":"$upstream_addr","up_st":"$upstream_status","up_rt":$upstream_response_time,"rt":$request_time}';
# Log settings
access_log /var/log/nginx/access.ndjson json; # structured JSON for ELK
access_log /var/log/nginx/access.log; # standard combined for Elastic Agent nginx integration
error_log /var/log/nginx/error.log warn;
# Gzip settings (optional)
gzip on;
gzip_disable "msie6";
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_types text/plain application/xml text/css application/javascript application/json application/x-javascript text/javascript;
# Upstream pool for PHP-FPM (load-balanced)
upstream php_fpm {
least_conn;
server php1:9000 max_fails=3 fail_timeout=10s;
server php2:9000 max_fails=3 fail_timeout=10s;
keepalive 32;
}
# HTTP server (no HTTPS for local development)
server {
listen 80;
server_name _;
# Logging headers
add_header X-Request-Id $req_id always;
# Root and index
root /var/www/html;
index index.php index.html index.htm;
# Main application location
location / {
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS, PUT, DELETE";
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number";
add_header Access-Control-Allow-Credentials true;
if ($request_method = 'OPTIONS') {
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS, PUT, DELETE";
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number";
return 204;
}
include fastcgi_params;
fastcgi_pass php_fpm;
fastcgi_index index.php;
fastcgi_read_timeout 60s;
fastcgi_param SCRIPT_FILENAME $document_root/index.php;
fastcgi_param X_REQUEST_ID $req_id;
fastcgi_keep_conn on;
}
# Deny access to hidden files
location ~ /\. { deny all; }
location ~ /\.ht { deny all; }
}
# Internal metrics endpoint for Metricbeat (not exposed to host)
# Scraped at http://nginx:8080/stub_status by the metricbeat nginx module
server {
listen 8080;
server_name _;
access_log off;
location /stub_status {
stub_status;
allow all; # safe within the docker network; not published to host
}
}
}
+51 -26
View File
@@ -1,6 +1,5 @@
# Use the official PHP 8.2 image with Apache disabled
FROM php:8.2-fpm
FROM php:8.2.15-fpm
# Set the working directory inside the container
WORKDIR /var/www/html
@@ -9,40 +8,66 @@ COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# Install required extensions and dependencies
RUN apt-get update && apt-get install -y \
libpng-dev \
libonig-dev \
libxml2-dev \
libmagickwand-dev \
imagemagick \
pkg-config \
zip \
git \
curl \
libzip-dev \
default-mysql-client \
&& docker-php-ext-install mbstring exif pcntl bcmath gd pdo_mysql mysqli zip \
&& pecl install imagick \
&& docker-php-ext-enable imagick
# Install required extensions and dependencies
# Install required extensions and dependencies
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends \
$PHPIZE_DEPS \
libpng-dev \
libjpeg62-turbo-dev \
libfreetype6-dev \
libxml2-dev \
libonig-dev \
libmagickwand-dev \
libmagickcore-dev \
imagemagick \
pkg-config \
zip \
unzip \
git \
curl \
redis-tools \
libzip-dev \
libssl-dev \
ca-certificates \
mariadb-client; \
update-ca-certificates; \
docker-php-ext-configure gd --with-freetype --with-jpeg; \
docker-php-ext-install -j"$(nproc)" \
mbstring \
exif \
pcntl \
bcmath \
gd \
pdo_mysql \
mysqli \
zip; \
pecl install imagick-3.7.0; \
docker-php-ext-enable imagick; \
apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false $PHPIZE_DEPS; \
rm -rf /var/lib/apt/lists/*
# Copy application files into the container
COPY /nginx/app /var/www/html
## Do not copy application code at build time; it will be bind-mounted by docker-compose
## If you wish to build a self-contained image, uncomment the next line and adjust the path:
## COPY services/nginx/app /var/www/html
# Set the appropriate permissions for the working directory
RUN chown -R www-data:www-data /var/www/html \
&& chmod -R 755 /var/www/html
# Install using Composer
RUN composer install --no-dev
# Install the /var/www/html/modules/washcertificates directory
WORKDIR /var/www/html/modules/washcertificates
RUN composer install --no-dev
## Composer installs are performed at container start by docker-entrypoint.sh
ENV COMPOSER_ALLOW_SUPERUSER=1
## Ensure workdir is correct
WORKDIR /var/www/html
# Copy and enable entrypoint that installs Composer deps on first run
COPY services/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Expose port 9000
EXPOSE 9000
# Start PHP-FPM when the container starts
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["php-fpm"]
+109
View File
@@ -0,0 +1,109 @@
#!/bin/sh
set -e
# Config
APP_DIR="/var/www/html"
MODULE_DIR="$APP_DIR/modules/washcertificates"
LOG_FILE="/var/log/php/composer-install.log"
# Gate auto-install (set to "true" only on one PHP container, e.g. php1)
AUTO_COMPOSER_INSTALL="${AUTO_COMPOSER_INSTALL:-true}"
log() { printf "[entrypoint] %s\n" "$*"; }
wait_for_redis() {
host="${REDIS_CONFIG_HOST:-redis}"
port="${REDIS_CONFIG_PORT:-6379}"
pass="${REDIS_CONFIG_PASSWORD:-}"
timeout="${REDIS_WAIT_TIMEOUT:-60}"
i=0
log "Waiting for Redis at ${host}:${port} (timeout: ${timeout}s) ..."
while [ "$i" -lt "$timeout" ]; do
if [ -n "$pass" ]; then
if redis-cli -h "$host" -p "$port" -a "$pass" PING >/dev/null 2>&1; then
log "Redis is ready"
return 0
fi
else
if redis-cli -h "$host" -p "$port" PING >/dev/null 2>&1; then
log "Redis is ready"
return 0
fi
fi
sleep 1; i=$((i+1))
done
log "ERROR: Timed out waiting for Redis at ${host}:${port}"
return 1
}
wait_for_file() {
target="$1"; timeout="${2:-120}"; i=0
while [ "$i" -lt "$timeout" ]; do
if [ -e "$target" ]; then return 0; fi
sleep 1; i=$((i+1))
done
return 1
}
with_install_lock() {
# Poor-man's lock using a directory
while ! mkdir /tmp/composer-install.lock 2>/dev/null; do
sleep 1
done
trap 'rmdir /tmp/composer-install.lock 2>/dev/null || true' EXIT HUP INT TERM
"$@"
rmdir /tmp/composer-install.lock 2>/dev/null || true
trap - EXIT HUP INT TERM
}
install_if_needed() {
dir="$1"
if [ -f "$dir/composer.json" ]; then
if [ ! -f "$dir/vendor/autoload.php" ]; then
log "Installing Composer deps in $dir ..."
# Ensure log directory exists
mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || true
# Run install and log output
if ! COMPOSER_ALLOW_SUPERUSER=1 composer install \
--no-dev --prefer-dist --optimize-autoloader --no-interaction \
-d "$dir" 2>&1 | tee -a "$LOG_FILE"; then
log "ERROR: composer install failed in $dir. See $LOG_FILE"
exit 1
fi
# Verify autoload was created
if [ ! -f "$dir/vendor/autoload.php" ]; then
log "ERROR: autoload.php still missing after install in $dir. See $LOG_FILE"
exit 1
fi
# Best-effort permissions fix (ignore errors on non-Linux filesystems)
chown -R www-data:www-data "$dir/vendor" 2>/dev/null || true
else
log "vendor already present in $dir — skipping"
fi
fi
}
# Optionally perform auto-install (only on the designated container)
if [ "$AUTO_COMPOSER_INSTALL" = "true" ]; then
# Wait for the bind mount and composer.json to appear (common on Windows/macOS)
if ! wait_for_file "$APP_DIR/composer.json" 120; then
log "WARNING: $APP_DIR/composer.json not found after waiting — skipping auto-install"
else
with_install_lock install_if_needed "$APP_DIR"
fi
# Module (optional)
if [ -f "$MODULE_DIR/composer.json" ]; then
with_install_lock install_if_needed "$MODULE_DIR"
fi
else
log "AUTO_COMPOSER_INSTALL=false — skipping Composer auto-install"
fi
# Wait for Redis before starting PHP-FPM (if host is defined)
if [ -n "${REDIS_CONFIG_HOST:-}" ]; then
wait_for_redis
fi
exec "$@"
+16
View File
@@ -0,0 +1,16 @@
; PHP custom configuration for sessions and logging
; Store sessions in Redis so multiple PHP-FPM replicas can share state
session.save_handler = redis
; Add auth=PASSWORD if you set a password on Redis, and adjust database if needed
session.save_path = "tcp://redis:6379?database=0"
; Logging
log_errors = On
error_log = /var/log/php/error.log
; Elastic APM PHP agent
; The extension is installed via apt (elastic-apm-php)
extension=elastic_apm.so
; Optional bootstrap (enables automatic instrumentation where supported)
;elastic_apm.bootstrap_php_part_file=/opt/elastic/apm-agent-php/src/bootstrap_php_part.php
View File
+102
View File
@@ -0,0 +1,102 @@
http:
routers:
# Nextcloud proxy (directly from Traefik to external server)
cloud:
rule: Host(`cloud.truckwash.dk`)
entryPoints: [websecure]
service: cloud-svc
middlewares: [secure-headers]
tls:
certResolver: le
cloud-http:
rule: Host(`cloud.truckwash.dk`)
entryPoints: [web]
middlewares: [redirect-to-https, secure-headers]
service: cloud-svc
# CORS preflight handlers (Traefik generates the OPTIONS response)
# Return an empty 204 from Traefik itself and attach CORS headers via the middleware.
api-preflight:
rule: Host(`api.truckwash.dk`) && Method(`OPTIONS`)
entryPoints: [websecure]
middlewares: [secure-headers]
service: noop@internal
priority: 1000
tls:
certResolver: le
cloud-preflight:
rule: Host(`cloud.truckwash.dk`) && Method(`OPTIONS`)
entryPoints: [websecure]
middlewares: [secure-headers]
service: noop@internal
priority: 1000
tls:
certResolver: le
# Dashboard routers are declared via labels; middlewares are defined below.
middlewares:
redirect-to-https:
redirectScheme:
scheme: https
dashboard-auth:
basicAuth:
# IMPORTANT: Replace REPLACE_WITH_BCRYPT_HASH with a bcrypt/htpasswd hash for user 'truckwash'.
# Example command to generate (once Docker is available):
# docker run --rm httpd:2.4-alpine htpasswd -nbB truckwash 'w5j9MlEVz62Exm6r'
users:
- "truckwash:$2y$05$DYcMFqMPgEFWAJQioc.F3.v9ppi9bReAi/aQzoOlWqMyhIysKlDCC"
dashboard-allow-local:
ipWhiteList:
sourceRange:
- 127.0.0.1/32
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
secure-headers:
headers:
contentTypeNosniff: true
browserXssFilter: true
referrerPolicy: "strict-origin-when-cross-origin"
customFrameOptionsValue: "SAMEORIGIN"
permissionsPolicy: "geolocation=(), microphone=(), camera=(), payment=()"
addVaryHeader: true
accessControlAllowCredentials: true
accessControlMaxAge: 86400
accessControlAllowOriginList:
- "https://truckwash.io"
accessControlAllowMethods:
- GET
- POST
- PUT
- PATCH
- DELETE
- OPTIONS
accessControlAllowHeaders:
- Authorization
- Content-Type
- X-Customer-Number
api-ratelimit:
rateLimit:
average: 100
burst: 200
services:
cloud-svc:
loadBalancer:
servers:
- url: "http://94.130.142.41:11000"
tls:
options:
default:
minVersion: VersionTLS12
sniStrict: true
cipherSuites:
- TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
- TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
- TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
- TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
- TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305
- TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305
+28
View File
@@ -0,0 +1,28 @@
entryPoints:
web:
address: ":80"
websecure:
address: ":443"
providers:
docker:
exposedByDefault: false
file:
filename: /etc/traefik/dynamic.yml
watch: true
api:
dashboard: true
log:
level: WARN
accessLog: {}
certificatesResolvers:
le:
acme:
email: jb@truckwash.dk
storage: /acme.json
httpChallenge:
entryPoint: web
+49
View File
@@ -0,0 +1,49 @@
entryPoints:
web:
address: ":80"
websecure:
address: ":443"
metrics:
address: ":9100"
providers:
docker:
exposedByDefault: false
file:
filename: /etc/traefik/dynamic.yml
watch: true
api:
dashboard: true
log:
level: INFO
accessLog: {}
metrics:
prometheus:
entryPoint: metrics
addEntryPointsLabels: true
addRoutersLabels: true
addServicesLabels: true
tracing:
serviceName: traefik
jaeger:
samplingServerURL: http://jaeger:5778/sampling
samplingType: const
samplingParam: 1.0
localAgentHostPort: jaeger:6831
traceContextHeaderName: uber-trace-id
certificatesResolvers:
le:
acme:
email: jb@truckwash.dk
storage: /acme.json
httpChallenge:
entryPoint: web
# Using Let's Encrypt STAGING for local runs to avoid rate limits.
# Remove the following caServer line to switch to production.
caServer: https://acme-staging-v02.api.letsencrypt.org/directory
+99
View File
@@ -8,6 +8,105 @@ Content-Type: application/json
"country_code": 45
}
### POST request to /subusers
POST https://api.truckwash.dk:4433/subusers
Accept: application/json
Content-Type: application/json
Authorization: Bearer e9d4359673de64f6a9bc4231bf50ac9f5726ad5b3ee6fcad5f8ebbb20647c3b8
{
"cvr": 41004355,
"phone_country_code": 45,
"phone": 42331128
}
### GET request to /subusers/setup
GET https://api.truckwash.dk:4433/subusers/setup?token=1c1be8280bac3937487e5c77b76bb839
Accept: application/json
Content-Type: application/json
Authorization: Bearer e9d4359673de64f6a9bc4231bf50ac9f5726ad5b3ee6fcad5f8ebbb20647c3b8
### POST request to /subusers/setup
POST https://api.truckwash.dk:4433/subusers/setup
Accept: application/json
Content-Type: application/json
Authorization: Bearer e9d4359673de64f6a9bc4231bf50ac9f5726ad5b3ee6fcad5f8ebbb20647c3b8
{
"token": "1c1be8280bac3937487e5c77b76bb839",
"name": "Test Subuser",
"username": "testsubuser",
"email": "jb@truckwash.dk",
"password": "Test1234"
}
### POST request to /subusers/auth/password
POST https://api.truckwash.dk:4433/subusers/auth/password
Accept: application/json
Content-Type: application/json
{
"username": "testsubuser",
"password": "Test1234"
}
### GET request to /order-bookings (As subuser)
GET https://api.truckwash.dk:4433/order-bookings
Accept: application/json
Content-Type: application/json
Authorization: Bearer 9ed711260d58165ee7601060bf7463f6e201481edd16c8ee4fc4a5f04b79a232
X-Customer-Number: 42331128
### GET request to /order-bookings (As subuser, specific ID)
GET https://api.truckwash.dk:4433/order-bookings?id=76
Accept: application/json
Content-Type: application/json
Authorization: Bearer 9ed711260d58165ee7601060bf7463f6e201481edd16c8ee4fc4a5f04b79a232
X-Customer-Number: 42331128
### GET request to /vehicles (As subuser)
GET https://api.truckwash.dk:4433/vehicles
Accept: application/json
Content-Type: application/json
Authorization: Bearer 9ed711260d58165ee7601060bf7463f6e201481edd16c8ee4fc4a5f04b79a232
X-Customer-Number: 42331128
### GET request to /vehicles (As subuser, specific ID)
GET https://api.truckwash.dk:4433/vehicles?id=384
Accept: application/json
Content-Type: application/json
Authorization: Bearer 9ed711260d58165ee7601060bf7463f6e201481edd16c8ee4fc4a5f04b79a232
X-Customer-Number: 42331128
### GET request to /orders (As subuser)
GET https://api.truckwash.dk:4433/orders
Accept: application/json
Content-Type: application/json
Authorization: Bearer 9ed711260d58165ee7601060bf7463f6e201481edd16c8ee4fc4a5f04b79a232
X-Customer-Number: 42331128
### GET request to /subusers/me (As subuser)
GET https://api.truckwash.dk:4433/subusers/me
Accept: application/json
Content-Type: application/json
Authorization: Bearer 9ed711260d58165ee7601060bf7463f6e201481edd16c8ee4fc4a5f04b79a232
X-Customer-Number: 42331128
### GET request to /subusers/permission-nodes
GET https://api.truckwash.dk:4433/subusers/permission-nodes
Accept: application/json
Content-Type: application/json
Authorization: Bearer e9d4359673de64f6a9bc4231bf50ac9f5726ad5b3ee6fcad5f8ebbb20647c3b8
### GET request to /subusers/grants (As subuser)
GET https://api.truckwash.dk:4433/subusers/grants
Accept: application/json
Content-Type: application/json
Authorization: Bearer 9ed711260d58165ee7601060bf7463f6e201481edd16c8ee4fc4a5f04b79a232
X-Customer-Number: 42331128
### GET request to order bookings (list all)
GET https://api.truckwash.dk:4433/order-bookings
Accept: application/json