diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ac70326 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,56 @@ +# Git +.git +.gitignore +.gitattributes + +# IDE +.idea +.vscode +*.swp +*.swo +*~ + +# Node +node_modules +npm-debug.log +yarn-error.log + +# PHP / Laravel +vendor +.env +.env.* +!.env.production.example + +# Build artifacts +frontend/dist +backend/storage/logs/* +backend/storage/framework/cache/* +backend/storage/framework/sessions/* +backend/storage/framework/testing/* +backend/storage/framework/views/* + +# Testing +tests +.phpunit.result.cache +coverage + +# Documentation +*.md +!README.md +docs + +# CI/CD +.github +.gitlab-ci.yml + +# Docker +docker-compose*.yml +!docker-compose.prod.yml +Dockerfile* +!docker/production/Dockerfile + +# Misc +.DS_Store +Thumbs.db +.claude +.trees \ No newline at end of file diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..e600ff6 --- /dev/null +++ b/.env.production.example @@ -0,0 +1,32 @@ +# Trip Planner Production Environment Configuration +# Copy this file to .env and configure for your deployment + +# Port to expose the application on +PORT=80 + +# Application URL (change to your domain) +APP_URL=http://localhost + +# Frontend URL for CORS configuration (usually same as APP_URL) +FRONTEND_URL=${APP_URL} + +# Application name +APP_NAME=Trip Planner + +# Laravel application key (generate with: php artisan key:generate) +# REQUIRED: You must set this before running in production +APP_KEY= + +# Session configuration +SESSION_DRIVER=database +SESSION_LIFETIME=120 +# Set to true if using HTTPS +# SESSION_SECURE_COOKIE=true +SESSION_SAME_SITE=lax + +# Database configuration (optional - defaults are provided) +# Only override these if you need custom credentials +# DB_DATABASE=trip_planner +# DB_USERNAME=trip_planner +# DB_PASSWORD=trip_planner_secret +# DB_ROOT_PASSWORD= \ No newline at end of file diff --git a/.woodpecker.yml b/.woodpecker.yml new file mode 100644 index 0000000..75a532d --- /dev/null +++ b/.woodpecker.yml @@ -0,0 +1,22 @@ +when: + branch: main + event: push + +steps: + build-and-push: + image: woodpeckerci/plugin-docker-buildx + settings: + registry: codeberg.org + repo: codeberg.org/lvl0/trip-planner + dockerfile: docker/production/Dockerfile + context: . + tags: + - latest + - ${CI_COMMIT_SHA:0:8} + username: + from_secret: docker_username + password: + from_secret: docker_password + platforms: + - linux/amd64 + - linux/arm64 \ No newline at end of file diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 1063834..05bd4e8 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,58 +1,35 @@ version: '3.8' services: - frontend: - build: - context: ./frontend - dockerfile: ../docker/frontend/Dockerfile.prod - container_name: trip-planner-frontend + trip-planner: + image: codeberg.org/lvl0/trip-planner:latest + container_name: trip-planner ports: - - "${FRONTEND_PORT:-80}:80" - restart: unless-stopped - networks: - - trip-planner-network - - backend: - build: - context: ./backend - dockerfile: ../docker/backend/Dockerfile.prod - container_name: trip-planner-backend - ports: - - "${BACKEND_PORT:-8080}:80" + - "${PORT:-80}:80" environment: + # Application settings + APP_NAME: "${APP_NAME:-Trip Planner}" APP_ENV: production APP_DEBUG: false - APP_URL: ${APP_URL} + APP_URL: "${APP_URL:-http://localhost}" + APP_KEY: "${APP_KEY}" + FRONTEND_URL: "${FRONTEND_URL:-${APP_URL}}" + + # Database connection settings DB_CONNECTION: mysql - DB_HOST: ${DB_HOST} - DB_PORT: ${DB_PORT:-3306} - DB_DATABASE: ${DB_DATABASE} - DB_USERNAME: ${DB_USERNAME} - DB_PASSWORD: ${DB_PASSWORD} - REDIS_HOST: redis - REDIS_PORT: 6379 - CACHE_DRIVER: redis - QUEUE_CONNECTION: redis - SESSION_DRIVER: redis - depends_on: - - redis - restart: unless-stopped - networks: - - trip-planner-network + DB_HOST: 127.0.0.1 + DB_PORT: 3306 - redis: - image: docker.io/library/redis:alpine - container_name: trip-planner-redis + # Database credentials (optional overrides) + DB_DATABASE: "${DB_DATABASE:-trip_planner}" + DB_USERNAME: "${DB_USERNAME:-trip_planner}" + DB_PASSWORD: "${DB_PASSWORD:-trip_planner_secret}" + DB_ROOT_PASSWORD: "${DB_ROOT_PASSWORD}" volumes: - - redis-data:/data - command: redis-server --appendonly yes + - mariadb-data:/var/lib/mysql-data + - storage-data:/var/www/html/storage restart: unless-stopped - networks: - - trip-planner-network - -networks: - trip-planner-network: - driver: bridge volumes: - redis-data: \ No newline at end of file + mariadb-data: + storage-data: \ No newline at end of file diff --git a/docker/production/Dockerfile b/docker/production/Dockerfile new file mode 100644 index 0000000..9b36c4c --- /dev/null +++ b/docker/production/Dockerfile @@ -0,0 +1,112 @@ +# Multi-stage build for Trip Planner production image +# This creates a single Debian-based image with all services + +# Stage 1: Build Frontend +FROM node:20-bookworm-slim AS frontend-builder + +WORKDIR /app/frontend + +COPY frontend/package*.json ./ +RUN npm ci + +COPY frontend/ ./ +RUN npm run build + +# Stage 2: Build Backend +FROM php:8.3-fpm-bookworm AS backend-builder + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + libpng-dev \ + libonig-dev \ + libxml2-dev \ + zip \ + unzip \ + && rm -rf /var/lib/apt/lists/* + +# Install PHP extensions +RUN docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd opcache + +# Install Composer +COPY --from=composer:latest /usr/bin/composer /usr/bin/composer + +WORKDIR /app/backend + +# Copy composer files +COPY backend/composer.json backend/composer.lock ./ + +# Install dependencies (no dev) +RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist + +# Copy application +COPY backend/ ./ + +# Generate optimized autoloader +RUN composer dump-autoload --optimize --classmap-authoritative + +# Stage 3: Final Production Image +FROM debian:bookworm-slim + +# Install runtime dependencies +RUN apt-get update && apt-get install -y \ + nginx \ + supervisor \ + mariadb-server \ + php8.3-fpm \ + php8.3-mysql \ + php8.3-mbstring \ + php8.3-xml \ + php8.3-bcmath \ + php8.3-gd \ + php8.3-opcache \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Create application directory +WORKDIR /var/www/html + +# Copy backend from builder +COPY --from=backend-builder /app/backend ./ + +# Copy frontend build to Laravel public directory +RUN mkdir -p /var/www/html/public/app +COPY --from=frontend-builder /app/frontend/dist /var/www/html/public/app + +# Copy production configuration files +COPY docker/production/nginx.conf /etc/nginx/sites-available/default +COPY docker/production/supervisord.conf /etc/supervisor/conf.d/trip-planner.conf +COPY docker/production/init-db.sh /usr/local/bin/init-db.sh +COPY docker/production/php-fpm.conf /etc/php/8.3/fpm/pool.d/www.conf + +# Make init script executable +RUN chmod +x /usr/local/bin/init-db.sh + +# Configure PHP for production +RUN echo "opcache.enable=1" >> /etc/php/8.3/fpm/conf.d/99-opcache.ini \ + && echo "opcache.memory_consumption=128" >> /etc/php/8.3/fpm/conf.d/99-opcache.ini \ + && echo "opcache.max_accelerated_files=10000" >> /etc/php/8.3/fpm/conf.d/99-opcache.ini \ + && echo "opcache.validate_timestamps=0" >> /etc/php/8.3/fpm/conf.d/99-opcache.ini + +# Ensure all storage subdirectories exist +RUN mkdir -p /var/www/html/storage/framework/cache/data \ + /var/www/html/storage/framework/sessions \ + /var/www/html/storage/framework/views \ + /var/www/html/storage/logs + +# Set permissions +RUN chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache \ + && chmod -R 775 /var/www/html/storage /var/www/html/bootstrap/cache + +# Create data directory for MariaDB persistence +RUN mkdir -p /var/lib/mysql-data \ + && chown -R mysql:mysql /var/lib/mysql-data + +# Expose port 80 +EXPOSE 80 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \ + CMD curl -f http://localhost/api/health || exit 1 + +# Start supervisord +CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/trip-planner.conf"] \ No newline at end of file diff --git a/docker/production/init-db.sh b/docker/production/init-db.sh new file mode 100644 index 0000000..0a1d942 --- /dev/null +++ b/docker/production/init-db.sh @@ -0,0 +1,110 @@ +#!/bin/bash +set -e + +# Database configuration with defaults +DB_NAME="${DB_DATABASE:-trip_planner}" +DB_USER="${DB_USERNAME:-trip_planner}" +DB_PASS="${DB_PASSWORD:-trip_planner_secret}" +DB_ROOT_PASS="${DB_ROOT_PASSWORD:-$(openssl rand -base64 32)}" + +# Initialize MariaDB data directory if needed +if [ ! -d "/var/lib/mysql-data/mysql" ]; then + echo "Initializing MariaDB data directory..." + mysql_install_db --user=mysql --datadir=/var/lib/mysql-data +fi + +# Check if database is already initialized using marker file +DB_INITIALIZED=false +if [ -f "/var/lib/mysql-data/.initialized" ]; then + DB_INITIALIZED=true + echo "Database already initialized (marker file exists)" +fi + +# If not initialized, set up database +if [ "$DB_INITIALIZED" = false ]; then + echo "Setting up database for the first time..." + + # Validate APP_KEY is set + if [ -z "$APP_KEY" ]; then + echo "ERROR: APP_KEY not set. Generate with: docker run --rm trip-planner php artisan key:generate --show" + exit 1 + fi + + # Start MariaDB + mysqld_safe --datadir=/var/lib/mysql-data --user=mysql & + MYSQL_PID=$! + + # Wait for MariaDB to start + echo "Waiting for MariaDB to be ready..." + for i in {1..30}; do + if mysqladmin ping -h localhost --silent 2>/dev/null; then + echo "MariaDB is ready!" + break + fi + if [ $i -eq 30 ]; then + echo "MariaDB failed to start in time" + exit 1 + fi + sleep 1 + done + + # Set root password and configure database + if ! mysql -u root <<-EOSQL + ALTER USER 'root'@'localhost' IDENTIFIED BY '${DB_ROOT_PASS}'; + DELETE FROM mysql.user WHERE User=''; + DELETE FROM mysql.user WHERE User='root' AND Host NOT IN ('localhost', '127.0.0.1', '::1'); + DROP DATABASE IF EXISTS test; + DELETE FROM mysql.db WHERE Db='test' OR Db='test\\_%'; + + CREATE DATABASE IF NOT EXISTS ${DB_NAME} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + CREATE USER IF NOT EXISTS '${DB_USER}'@'localhost' IDENTIFIED BY '${DB_PASS}'; + GRANT ALL PRIVILEGES ON ${DB_NAME}.* TO '${DB_USER}'@'localhost'; + FLUSH PRIVILEGES; +EOSQL + then + echo "ERROR: Database initialization failed" + exit 1 + fi + + echo "Database created and user configured" + + # Wait for database to be accessible by application user + echo "Verifying database accessibility..." + cd /var/www/html + for i in {1..10}; do + if mysql -u "${DB_USER}" -p"${DB_PASS}" -h localhost "${DB_NAME}" -e "SELECT 1" &>/dev/null; then + echo "Database accessible!" + break + fi + if [ $i -eq 10 ]; then + echo "ERROR: Database not accessible" + exit 1 + fi + sleep 1 + done + + # Run Laravel migrations + echo "Running Laravel migrations..." + if ! php artisan migrate --force; then + echo "ERROR: Migration failed" + exit 1 + fi + + # Optimize Laravel for production + echo "Optimizing Laravel for production..." + php artisan config:cache + php artisan route:cache + php artisan view:cache + + # Create marker file to indicate successful initialization + touch /var/lib/mysql-data/.initialized + echo "Database initialization complete!" + + # Shutdown MariaDB for clean restart + mysqladmin -u root -p"${DB_ROOT_PASS}" shutdown 2>/dev/null || kill $MYSQL_PID + wait $MYSQL_PID 2>/dev/null || true +fi + +# Start MariaDB in foreground +echo "Starting MariaDB..." +exec mysqld --datadir=/var/lib/mysql-data --user=mysql \ No newline at end of file diff --git a/docker/production/nginx.conf b/docker/production/nginx.conf new file mode 100644 index 0000000..8d07794 --- /dev/null +++ b/docker/production/nginx.conf @@ -0,0 +1,76 @@ +# Rate limiting zones +limit_req_zone $binary_remote_addr zone=api_limit:10m rate=60r/m; +limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=5r/m; + +server { + listen 80; + server_name _; + root /var/www/html/public; + index index.php index.html; + + # Upload size limit + client_max_body_size 10M; + + # Disable access log for performance, send errors to stderr for Docker logging + access_log off; + error_log /dev/stderr warn; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml+rss application/rss+xml application/atom+xml image/svg+xml text/javascript application/vnd.ms-fontobject application/x-font-ttf font/opentype; + + # Serve frontend app + location /app { + alias /var/www/html/public/app; + try_files $uri $uri/ /app/index.html; + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + } + + # Laravel API routes with rate limiting + location /api { + # Apply rate limiting with burst allowance + limit_req zone=api_limit burst=10 nodelay; + + # Extra strict rate limiting for auth endpoints + location ~ ^/api/(login|register) { + limit_req zone=auth_limit burst=3 nodelay; + try_files $uri $uri/ /index.php?$query_string; + } + + try_files $uri $uri/ /index.php?$query_string; + } + + # Laravel backend + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + # PHP handler + location ~ \.php$ { + try_files $uri =404; + fastcgi_pass 127.0.0.1:9000; + fastcgi_index index.php; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + include fastcgi_params; + fastcgi_buffer_size 32k; + fastcgi_buffers 8 16k; + fastcgi_read_timeout 240; + } + + # Deny access to hidden files + location ~ /\.(?!well-known).* { + deny all; + } + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; +} \ No newline at end of file diff --git a/docker/production/php-fpm.conf b/docker/production/php-fpm.conf new file mode 100644 index 0000000..5eb0d5f --- /dev/null +++ b/docker/production/php-fpm.conf @@ -0,0 +1,13 @@ +[www] +user = www-data +group = www-data +listen = 127.0.0.1:9000 +listen.owner = www-data +listen.group = www-data +pm = dynamic +pm.max_children = 50 +pm.start_servers = 5 +pm.min_spare_servers = 5 +pm.max_spare_servers = 35 +pm.max_requests = 500 +catch_workers_output = yes \ No newline at end of file diff --git a/docker/production/supervisord.conf b/docker/production/supervisord.conf new file mode 100644 index 0000000..eeeb9dd --- /dev/null +++ b/docker/production/supervisord.conf @@ -0,0 +1,35 @@ +[supervisord] +nodaemon=true +user=root +logfile=/var/log/supervisor/supervisord.log +pidfile=/var/run/supervisord.pid + +[program:mariadb] +command=/usr/local/bin/init-db.sh +autostart=true +autorestart=true +priority=1 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +[program:php-fpm] +command=/usr/sbin/php-fpm8.3 -F +autostart=true +autorestart=true +priority=10 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +[program:nginx] +command=/usr/sbin/nginx -g "daemon off;" +autostart=true +autorestart=true +priority=20 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 \ No newline at end of file