fix(deploy): improve deployment robustness and reliability
All checks were successful
Test Runner / test-basic (push) Successful in 8s
Test Runner / test-php (push) Successful in 7s
Deploy Application / deploy (push) Successful in 1m35s

- Add docker volume prune to deploy.sh to prevent stale code issues
- Add automatic migrations and cache warmup to staging entrypoint
- Fix nginx race condition by waiting for PHP-FPM before starting
- Improve PHP healthcheck to use php-fpm-healthcheck
- Add curl to production nginx Dockerfile for healthchecks
- Add ensureSeedsTable() to SeedRepository for automatic table creation
- Update SeedCommand to ensure seeds table exists before operations

This prevents 502 Bad Gateway errors during deployment and ensures
fresh code is deployed without volume cache issues.
This commit is contained in:
2025-11-25 17:44:44 +01:00
parent 7785e65d08
commit 85e2360a90
11 changed files with 121 additions and 20 deletions

View File

@@ -121,6 +121,13 @@ docker compose $COMPOSE_FILES pull || print_warning "Failed to pull some images,
print_info "Stopping existing containers..." print_info "Stopping existing containers..."
docker compose $COMPOSE_FILES down --remove-orphans || print_warning "No existing containers to stop" docker compose $COMPOSE_FILES down --remove-orphans || print_warning "No existing containers to stop"
# Staging: Remove named volume to ensure fresh code from image
# This prevents stale code persisting between deployments
if [ "$ENVIRONMENT" = "staging" ]; then
print_info "Removing staging code volume to ensure fresh deployment..."
docker volume rm staging-code 2>/dev/null || print_info "No stale staging volume to remove"
fi
# Remove any orphaned containers with conflicting names # Remove any orphaned containers with conflicting names
for container in nginx php redis scheduler queue-worker; do for container in nginx php redis scheduler queue-worker; do
if docker ps -a --format '{{.Names}}' | grep -q "^${container}$"; then if docker ps -a --format '{{.Names}}' | grep -q "^${container}$"; then

View File

@@ -147,9 +147,10 @@ services:
- app-backend - app-backend
- app-internal - app-internal
healthcheck: healthcheck:
test: ["CMD", "php", "-v"] # Check if PHP-FPM is accepting connections
interval: 30s test: ["CMD-SHELL", "php-fpm-healthcheck || exit 1"]
timeout: 10s interval: 15s
timeout: 5s
retries: 3 retries: 3
start_period: 60s start_period: 60s
deploy: deploy:

View File

@@ -175,6 +175,20 @@ services:
echo "📊 Environment variables:" echo "📊 Environment variables:"
env | grep -E "DB_|APP_" | grep -v "PASSWORD|KEY|SECRET" || true env | grep -E "DB_|APP_" | grep -v "PASSWORD|KEY|SECRET" || true
# Run database migrations
if [ -f /var/www/html/console.php ]; then
echo ""
echo "🗄️ Running database migrations..."
cd /var/www/html
php console.php db:migrate --force || echo "⚠️ Migration warning (may be OK if already migrated)"
fi
# Warm up caches
echo ""
echo "🔥 Warming up caches..."
cd /var/www/html
php console.php cache:warm 2>/dev/null || echo " Cache warmup skipped (command may not exist)"
echo "" echo ""
echo "🛠️ Adjusting filesystem permissions..." echo "🛠️ Adjusting filesystem permissions..."
chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache 2>/dev/null || true chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache 2>/dev/null || true
@@ -191,11 +205,13 @@ services:
echo "REDIS_PASSWORD_FILE: ${REDIS_PASSWORD_FILE:-NOT SET}" echo "REDIS_PASSWORD_FILE: ${REDIS_PASSWORD_FILE:-NOT SET}"
exec php-fpm exec php-fpm
healthcheck: healthcheck:
test: ["CMD-SHELL", "php-fpm-healthcheck || true"] # Use HTTP liveness check via php-fpm (not via nginx)
interval: 30s # This checks if the PHP application is actually responding
timeout: 10s test: ["CMD-SHELL", "php-fpm-healthcheck || exit 1"]
interval: 15s
timeout: 5s
retries: 3 retries: 3
start_period: 40s start_period: 60s
depends_on: depends_on:
redis: redis:
condition: service_started condition: service_started
@@ -265,6 +281,26 @@ services:
fi fi
done done
# Wait for PHP-FPM to be ready before starting nginx
# This prevents 502 Bad Gateway errors during startup
echo "⏳ [staging-nginx] Waiting for PHP-FPM to be ready..."
MAX_WAIT=30
WAITED=0
while [ $$WAITED -lt $$MAX_WAIT ]; do
# Check if PHP-FPM is accepting connections on port 9000
if nc -z php 9000 2>/dev/null; then
echo "✅ [staging-nginx] PHP-FPM is ready on php:9000"
break
fi
echo " [staging-nginx] PHP-FPM not ready yet... ($$WAITED/$$MAX_WAIT)"
sleep 1
WAITED=$$((WAITED + 1))
done
if [ $$WAITED -ge $$MAX_WAIT ]; then
echo "⚠️ [staging-nginx] PHP-FPM did not become ready within $$MAX_WAIT seconds, starting anyway..."
fi
# Start nginx only (no PHP-FPM, no Git clone - php container handles that) # Start nginx only (no PHP-FPM, no Git clone - php container handles that)
echo "🚀 [staging-nginx] Starting nginx..." echo "🚀 [staging-nginx] Starting nginx..."
exec nginx -g "daemon off;" exec nginx -g "daemon off;"
@@ -280,11 +316,12 @@ services:
# Network # Network
- "traefik.docker.network=traefik-public" - "traefik.docker.network=traefik-public"
healthcheck: healthcheck:
test: ["CMD-SHELL", "curl -f http://127.0.0.1/health || exit 1"] # Use /health/live endpoint for lightweight liveness check
interval: 30s test: ["CMD-SHELL", "curl -sf http://127.0.0.1/health/live || exit 1"]
timeout: 10s interval: 15s
timeout: 5s
retries: 3 retries: 3
start_period: 10s start_period: 30s
depends_on: depends_on:
php: php:
condition: service_started condition: service_started

View File

@@ -9,6 +9,7 @@ RUN apk add --no-cache \
certbot-nginx \ certbot-nginx \
su-exec \ su-exec \
netcat-openbsd \ netcat-openbsd \
curl \
openssl \ openssl \
bash bash

View File

@@ -47,4 +47,3 @@ final readonly class CreateSeedsTable implements Migration, SafelyReversible
return 'Create seeds table for tracking executed seeders'; return 'Create seeds table for tracking executed seeders';
} }
} }

View File

@@ -24,6 +24,9 @@ final readonly class SeedCommand
$fresh = $input->hasOption('fresh'); $fresh = $input->hasOption('fresh');
try { try {
// Ensure seeds table exists (auto-create if missing)
$this->seedRepository->ensureSeedsTable();
// Clear seeds table if --fresh option is provided // Clear seeds table if --fresh option is provided
if ($fresh) { if ($fresh) {
echo "⚠️ Clearing seeds table (--fresh option)...\n"; echo "⚠️ Clearing seeds table (--fresh option)...\n";
@@ -38,6 +41,7 @@ final readonly class SeedCommand
if ($seeder === null) { if ($seeder === null) {
echo "❌ Seeder '{$className}' not found or cannot be instantiated.\n"; echo "❌ Seeder '{$className}' not found or cannot be instantiated.\n";
return ExitCode::SOFTWARE_ERROR; return ExitCode::SOFTWARE_ERROR;
} }
@@ -50,6 +54,7 @@ final readonly class SeedCommand
if (empty($seeders)) { if (empty($seeders)) {
echo "No seeders found.\n"; echo "No seeders found.\n";
return ExitCode::SUCCESS; return ExitCode::SUCCESS;
} }
@@ -74,4 +79,3 @@ final readonly class SeedCommand
} }
} }
} }

View File

@@ -61,4 +61,3 @@ final readonly class SeedLoader
return null; return null;
} }
} }

View File

@@ -5,17 +5,68 @@ declare(strict_types=1);
namespace App\Framework\Database\Seed; namespace App\Framework\Database\Seed;
use App\Framework\Database\ConnectionInterface; use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\Platform\DatabasePlatform;
use App\Framework\Database\ValueObjects\SqlQuery; use App\Framework\Database\ValueObjects\SqlQuery;
use App\Framework\DateTime\Clock; use App\Framework\DateTime\Clock;
final readonly class SeedRepository final readonly class SeedRepository
{ {
private const string TABLE_NAME = 'seeds';
public function __construct( public function __construct(
private ConnectionInterface $connection, private ConnectionInterface $connection,
private DatabasePlatform $platform,
private Clock $clock private Clock $clock
) { ) {
} }
/**
* Ensure the seeds table exists, create it if not
*
* This method should be called before any seed operations to prevent
* "table does not exist" errors on fresh databases.
*/
public function ensureSeedsTable(): void
{
if ($this->tableExists()) {
return;
}
$this->createSeedsTable();
}
/**
* Check if the seeds table exists
*/
private function tableExists(): bool
{
try {
$sql = $this->platform->getTableExistsSQL(self::TABLE_NAME);
$result = $this->connection->queryScalar(SqlQuery::create($sql));
return (bool) $result;
} catch (\Throwable) {
return false;
}
}
/**
* Create the seeds tracking table
*/
private function createSeedsTable(): void
{
$sql = <<<'SQL'
CREATE TABLE seeds (
id VARCHAR(64) PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
description TEXT,
executed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)
SQL;
$this->connection->execute(SqlQuery::create($sql));
}
/** /**
* Check if a seeder has already been executed * Check if a seeder has already been executed
*/ */
@@ -60,4 +111,3 @@ final readonly class SeedRepository
return hash('sha256', $name); return hash('sha256', $name);
} }
} }

View File

@@ -27,6 +27,7 @@ final readonly class SeedRunner
// Check if already executed // Check if already executed
if ($this->seedRepository->hasRun($name)) { if ($this->seedRepository->hasRun($name)) {
$this->log("Skipping seeder '{$name}' - already executed", 'info'); $this->log("Skipping seeder '{$name}' - already executed", 'info');
return; return;
} }
@@ -38,6 +39,7 @@ final readonly class SeedRunner
$this->log("Seeder '{$name}' completed successfully", 'info'); $this->log("Seeder '{$name}' completed successfully", 'info');
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->log("Seeder '{$name}' failed: {$e->getMessage()}", 'error'); $this->log("Seeder '{$name}' failed: {$e->getMessage()}", 'error');
throw $e; throw $e;
} }
} }
@@ -71,4 +73,3 @@ final readonly class SeedRunner
} }
} }
} }

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Framework\Database\Seed; namespace App\Framework\Database\Seed;
use App\Framework\Database\ConnectionInterface; use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\Platform\DatabasePlatform;
use App\Framework\DateTime\Clock; use App\Framework\DateTime\Clock;
use App\Framework\DI\Container; use App\Framework\DI\Container;
use App\Framework\DI\Initializer; use App\Framework\DI\Initializer;
@@ -20,6 +21,7 @@ final readonly class SeedServicesInitializer
$container->singleton(SeedRepository::class, function (Container $c) { $container->singleton(SeedRepository::class, function (Container $c) {
return new SeedRepository( return new SeedRepository(
$c->get(ConnectionInterface::class), $c->get(ConnectionInterface::class),
$c->get(DatabasePlatform::class),
$c->get(Clock::class) $c->get(Clock::class)
); );
}); });
@@ -27,12 +29,14 @@ final readonly class SeedServicesInitializer
// SeedLoader // SeedLoader
$container->singleton(SeedLoader::class, function (Container $c) { $container->singleton(SeedLoader::class, function (Container $c) {
$discoveryRegistry = $c->get(DiscoveryRegistry::class); $discoveryRegistry = $c->get(DiscoveryRegistry::class);
return new SeedLoader($discoveryRegistry, $c); return new SeedLoader($discoveryRegistry, $c);
}); });
// SeedRunner // SeedRunner
$container->singleton(SeedRunner::class, function (Container $c) { $container->singleton(SeedRunner::class, function (Container $c) {
$logger = $c->has(Logger::class) ? $c->get(Logger::class) : null; $logger = $c->has(Logger::class) ? $c->get(Logger::class) : null;
return new SeedRunner( return new SeedRunner(
$c->get(SeedRepository::class), $c->get(SeedRepository::class),
$logger $logger
@@ -40,4 +44,3 @@ final readonly class SeedServicesInitializer
}); });
} }
} }

View File

@@ -36,4 +36,3 @@ interface Seeder
*/ */
public function getDescription(): string; public function getDescription(): string;
} }