Tools: Dockerizing a Video Platform: From Dev to Production
The Problem with PHP+SQLite on Bare Metal
Multi-Stage Dockerfile
docker-compose.yml for Local Dev
Nginx Dev Config (LiteSpeed Proxy)
LiteSpeed Gap: What Docker Cannot Replicate
SQLite WAL Mode Check
Developer Workflow
Deployment: From Docker Image to LiteSpeed
Results TrendVidStream aggregates trending video content from 8 regions spanning the Nordics, Middle East, and Central Europe — CH, DK, AE, BE, CZ, FI plus US and GB. Each region has its own cron schedule and fetch cadence. Keeping all of that consistent between a developer's laptop and a LiteSpeed production server is where Docker earns its keep. SQLite has no server to install, which seems like an advantage. The catch is that PHP extensions, system SQLite versions, and locale settings can differ silently between machines. A developer on Ubuntu 24.04 with SQLite 3.45 behaves differently from the LiteSpeed server running SQLite 3.39 from the distro package manager. Docker freezes these variables into a reproducible image. LiteSpeed has two production-only behaviours: 1. The lscache/ directory — LiteSpeed writes HTML page cache here. On Docker/Nginx this directory simply does not appear. The PHP fallback page cache in data/pagecache/ is used instead: 2. <IfModule LiteSpeed> blocks — Apache and Nginx silently skip these, so .htaccess cache headers do not interfere with local dev. The production LiteSpeed servers do not run Docker — they are shared hosting. The Docker image serves three purposes: The actual deploy to TrendVidStream production uses lftp to mirror files to the LiteSpeed server, as covered in other articles in this series. The Dockerfile enforced PHP 8.3 with identical extensions across the team and CI. Three previously silent bugs (a strftime() locale difference, a missing intl extension on one machine, and a SQLite version discrepancy) were caught before they ever reached production. This is part of the "Building TrendVidStream" series, documenting the architecture behind a global video directory covering Nordic, Middle Eastern, and Central European regions. Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse
# Stage 1: Build dependencies
FROM php:8.3-cli-alpine AS deps RUN -weight: 500;">apk add --no-cache -weight: 500;">git -weight: 500;">curl unzip
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer WORKDIR /app
COPY composer.json composer.lock ./
RUN composer -weight: 500;">install \ --no-dev \ --optimize-autoloader \ --no-interaction \ --no-progress # Stage 2: Production runtime
FROM php:8.3-fpm-alpine AS production RUN -weight: 500;">apk add --no-cache \ sqlite-dev \ libpng-dev libjpeg-turbo-dev freetype-dev \ -weight: 500;">curl-dev icu-dev && \ -weight: 500;">docker-php-ext-configure gd \ --with-freetype --with-jpeg && \ -weight: 500;">docker-php-ext--weight: 500;">install \ pdo_sqlite gd -weight: 500;">curl intl opcache # OPcache tuned for a read-heavy video platform
COPY -weight: 500;">docker/opcache.ini /usr/local/etc/php/conf.d/opcache.ini WORKDIR /var/www/html COPY --from=deps /app/vendor ./vendor
COPY app/ ./app/
COPY public/ ./public/
COPY templates/ ./templates/
COPY cron/ ./cron/
COPY api_keys.conf ./ # data/ is ALWAYS a volume — never in the image
RUN mkdir -p data/pagecache && \ chown -R www-data:www-data data USER www-data
EXPOSE 9000
CMD ["php-fpm"]
# Stage 1: Build dependencies
FROM php:8.3-cli-alpine AS deps RUN -weight: 500;">apk add --no-cache -weight: 500;">git -weight: 500;">curl unzip
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer WORKDIR /app
COPY composer.json composer.lock ./
RUN composer -weight: 500;">install \ --no-dev \ --optimize-autoloader \ --no-interaction \ --no-progress # Stage 2: Production runtime
FROM php:8.3-fpm-alpine AS production RUN -weight: 500;">apk add --no-cache \ sqlite-dev \ libpng-dev libjpeg-turbo-dev freetype-dev \ -weight: 500;">curl-dev icu-dev && \ -weight: 500;">docker-php-ext-configure gd \ --with-freetype --with-jpeg && \ -weight: 500;">docker-php-ext--weight: 500;">install \ pdo_sqlite gd -weight: 500;">curl intl opcache # OPcache tuned for a read-heavy video platform
COPY -weight: 500;">docker/opcache.ini /usr/local/etc/php/conf.d/opcache.ini WORKDIR /var/www/html COPY --from=deps /app/vendor ./vendor
COPY app/ ./app/
COPY public/ ./public/
COPY templates/ ./templates/
COPY cron/ ./cron/
COPY api_keys.conf ./ # data/ is ALWAYS a volume — never in the image
RUN mkdir -p data/pagecache && \ chown -R www-data:www-data data USER www-data
EXPOSE 9000
CMD ["php-fpm"]
# Stage 1: Build dependencies
FROM php:8.3-cli-alpine AS deps RUN -weight: 500;">apk add --no-cache -weight: 500;">git -weight: 500;">curl unzip
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer WORKDIR /app
COPY composer.json composer.lock ./
RUN composer -weight: 500;">install \ --no-dev \ --optimize-autoloader \ --no-interaction \ --no-progress # Stage 2: Production runtime
FROM php:8.3-fpm-alpine AS production RUN -weight: 500;">apk add --no-cache \ sqlite-dev \ libpng-dev libjpeg-turbo-dev freetype-dev \ -weight: 500;">curl-dev icu-dev && \ -weight: 500;">docker-php-ext-configure gd \ --with-freetype --with-jpeg && \ -weight: 500;">docker-php-ext--weight: 500;">install \ pdo_sqlite gd -weight: 500;">curl intl opcache # OPcache tuned for a read-heavy video platform
COPY -weight: 500;">docker/opcache.ini /usr/local/etc/php/conf.d/opcache.ini WORKDIR /var/www/html COPY --from=deps /app/vendor ./vendor
COPY app/ ./app/
COPY public/ ./public/
COPY templates/ ./templates/
COPY cron/ ./cron/
COPY api_keys.conf ./ # data/ is ALWAYS a volume — never in the image
RUN mkdir -p data/pagecache && \ chown -R www-data:www-data data USER www-data
EXPOSE 9000
CMD ["php-fpm"]
; -weight: 500;">docker/opcache.ini
opcache.-weight: 500;">enable=1
opcache.memory_consumption=128
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=8192
opcache.validate_timestamps=0
opcache.revalidate_freq=0
opcache.fast_shutdown=1
; -weight: 500;">docker/opcache.ini
opcache.-weight: 500;">enable=1
opcache.memory_consumption=128
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=8192
opcache.validate_timestamps=0
opcache.revalidate_freq=0
opcache.fast_shutdown=1
; -weight: 500;">docker/opcache.ini
opcache.-weight: 500;">enable=1
opcache.memory_consumption=128
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=8192
opcache.validate_timestamps=0
opcache.revalidate_freq=0
opcache.fast_shutdown=1
version: '3.9' services: app: build: context: . target: production volumes: # Live code reload — mount source over image copies - ./app:/var/www/html/app:ro - ./templates:/var/www/html/templates:ro - ./public:/var/www/html/public:ro # Persistent SQLite database - tvs_data:/var/www/html/data environment: SITE_NAME: TrendVidStream SITE_URL: https://trendvidstream.com FETCH_REGIONS: "US,GB,CH,DK,AE,BE,CZ,FI" DB_PATH: /var/www/html/data/videos.db CACHE_PATH: /var/www/html/data/pagecache networks: - tvs nginx: image: nginx:1.27-alpine ports: - "8080:80" volumes: - ./public:/var/www/html/public:ro - ./-weight: 500;">docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro depends_on: - app networks: - tvs cron: build: context: . target: production volumes: - tvs_data:/var/www/html/data environment: FETCH_REGIONS: "US,GB,CH,DK,AE,BE,CZ,FI" DB_PATH: /var/www/html/data/videos.db command: > sh -c 'while true; do php /var/www/html/cron/fetch_videos.php; sleep 25200; done' networks: - tvs volumes: tvs_data: networks: tvs:
version: '3.9' services: app: build: context: . target: production volumes: # Live code reload — mount source over image copies - ./app:/var/www/html/app:ro - ./templates:/var/www/html/templates:ro - ./public:/var/www/html/public:ro # Persistent SQLite database - tvs_data:/var/www/html/data environment: SITE_NAME: TrendVidStream SITE_URL: https://trendvidstream.com FETCH_REGIONS: "US,GB,CH,DK,AE,BE,CZ,FI" DB_PATH: /var/www/html/data/videos.db CACHE_PATH: /var/www/html/data/pagecache networks: - tvs nginx: image: nginx:1.27-alpine ports: - "8080:80" volumes: - ./public:/var/www/html/public:ro - ./-weight: 500;">docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro depends_on: - app networks: - tvs cron: build: context: . target: production volumes: - tvs_data:/var/www/html/data environment: FETCH_REGIONS: "US,GB,CH,DK,AE,BE,CZ,FI" DB_PATH: /var/www/html/data/videos.db command: > sh -c 'while true; do php /var/www/html/cron/fetch_videos.php; sleep 25200; done' networks: - tvs volumes: tvs_data: networks: tvs:
version: '3.9' services: app: build: context: . target: production volumes: # Live code reload — mount source over image copies - ./app:/var/www/html/app:ro - ./templates:/var/www/html/templates:ro - ./public:/var/www/html/public:ro # Persistent SQLite database - tvs_data:/var/www/html/data environment: SITE_NAME: TrendVidStream SITE_URL: https://trendvidstream.com FETCH_REGIONS: "US,GB,CH,DK,AE,BE,CZ,FI" DB_PATH: /var/www/html/data/videos.db CACHE_PATH: /var/www/html/data/pagecache networks: - tvs nginx: image: nginx:1.27-alpine ports: - "8080:80" volumes: - ./public:/var/www/html/public:ro - ./-weight: 500;">docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro depends_on: - app networks: - tvs cron: build: context: . target: production volumes: - tvs_data:/var/www/html/data environment: FETCH_REGIONS: "US,GB,CH,DK,AE,BE,CZ,FI" DB_PATH: /var/www/html/data/videos.db command: > sh -c 'while true; do php /var/www/html/cron/fetch_videos.php; sleep 25200; done' networks: - tvs volumes: tvs_data: networks: tvs:
server { listen 80; root /var/www/html/public; index index.php; charset utf-8; # Same rewrite logic as production .htaccess location / { try_files $uri $uri/ /index.php?$query_string; } location ~ \.php$ { fastcgi_pass app:9000; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; # Simulate Cloudflare Flexible SSL header fastcgi_param HTTP_X_FORWARDED_PROTO https; } location ~* \.(css|js|woff2|svg|webp|png|jpg)$ { expires 30d; add_header Cache-Control "public, immutable"; }
}
server { listen 80; root /var/www/html/public; index index.php; charset utf-8; # Same rewrite logic as production .htaccess location / { try_files $uri $uri/ /index.php?$query_string; } location ~ \.php$ { fastcgi_pass app:9000; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; # Simulate Cloudflare Flexible SSL header fastcgi_param HTTP_X_FORWARDED_PROTO https; } location ~* \.(css|js|woff2|svg|webp|png|jpg)$ { expires 30d; add_header Cache-Control "public, immutable"; }
}
server { listen 80; root /var/www/html/public; index index.php; charset utf-8; # Same rewrite logic as production .htaccess location / { try_files $uri $uri/ /index.php?$query_string; } location ~ \.php$ { fastcgi_pass app:9000; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; # Simulate Cloudflare Flexible SSL header fastcgi_param HTTP_X_FORWARDED_PROTO https; } location ~* \.(css|js|woff2|svg|webp|png|jpg)$ { expires 30d; add_header Cache-Control "public, immutable"; }
}
<?php const IS_LITESPEED = (PHP_SAPI === 'litespeed'); function serveFromCache(string $cacheKey): bool
{ if (IS_LITESPEED) { // LiteSpeed handles this at the web server layer return false; } $file = CACHE_PATH . '/' . md5($cacheKey) . '.html'; if (file_exists($file) && (time() - filemtime($file)) < 10800) { readfile($file); return true; } return false;
}
<?php const IS_LITESPEED = (PHP_SAPI === 'litespeed'); function serveFromCache(string $cacheKey): bool
{ if (IS_LITESPEED) { // LiteSpeed handles this at the web server layer return false; } $file = CACHE_PATH . '/' . md5($cacheKey) . '.html'; if (file_exists($file) && (time() - filemtime($file)) < 10800) { readfile($file); return true; } return false;
}
<?php const IS_LITESPEED = (PHP_SAPI === 'litespeed'); function serveFromCache(string $cacheKey): bool
{ if (IS_LITESPEED) { // LiteSpeed handles this at the web server layer return false; } $file = CACHE_PATH . '/' . md5($cacheKey) . '.html'; if (file_exists($file) && (time() - filemtime($file)) < 10800) { readfile($file); return true; } return false;
}
# Verify WAL mode is active after first boot
-weight: 500;">docker compose exec app \ sqlite3 data/videos.db 'PRAGMA journal_mode;'
# Expected output: wal
# Verify WAL mode is active after first boot
-weight: 500;">docker compose exec app \ sqlite3 data/videos.db 'PRAGMA journal_mode;'
# Expected output: wal
# Verify WAL mode is active after first boot
-weight: 500;">docker compose exec app \ sqlite3 data/videos.db 'PRAGMA journal_mode;'
# Expected output: wal
<?php
// Set on first connection — idempotent
$pdo->exec('PRAGMA journal_mode=WAL');
$pdo->exec('PRAGMA synchronous=NORMAL');
$pdo->exec('PRAGMA cache_size=-32768'); // 32MB page cache
<?php
// Set on first connection — idempotent
$pdo->exec('PRAGMA journal_mode=WAL');
$pdo->exec('PRAGMA synchronous=NORMAL');
$pdo->exec('PRAGMA cache_size=-32768'); // 32MB page cache
<?php
// Set on first connection — idempotent
$pdo->exec('PRAGMA journal_mode=WAL');
$pdo->exec('PRAGMA synchronous=NORMAL');
$pdo->exec('PRAGMA cache_size=-32768'); // 32MB page cache
# Bootstrap: one command from clone to running platform
-weight: 500;">docker compose up -d --build # Seed the database with real trending data
-weight: 500;">docker compose exec cron php /var/www/html/cron/fetch_videos.php # Tail the fetcher logs
-weight: 500;">docker compose logs -f cron # Open a SQLite shell
-weight: 500;">docker compose exec app sqlite3 data/videos.db # Build the production image without dev mounts
-weight: 500;">docker build --target production -t tvs:latest . # Check image size
-weight: 500;">docker images tvs:latest
# Should be ~95MB
# Bootstrap: one command from clone to running platform
-weight: 500;">docker compose up -d --build # Seed the database with real trending data
-weight: 500;">docker compose exec cron php /var/www/html/cron/fetch_videos.php # Tail the fetcher logs
-weight: 500;">docker compose logs -f cron # Open a SQLite shell
-weight: 500;">docker compose exec app sqlite3 data/videos.db # Build the production image without dev mounts
-weight: 500;">docker build --target production -t tvs:latest . # Check image size
-weight: 500;">docker images tvs:latest
# Should be ~95MB
# Bootstrap: one command from clone to running platform
-weight: 500;">docker compose up -d --build # Seed the database with real trending data
-weight: 500;">docker compose exec cron php /var/www/html/cron/fetch_videos.php # Tail the fetcher logs
-weight: 500;">docker compose logs -f cron # Open a SQLite shell
-weight: 500;">docker compose exec app sqlite3 data/videos.db # Build the production image without dev mounts
-weight: 500;">docker build --target production -t tvs:latest . # Check image size
-weight: 500;">docker images tvs:latest
# Should be ~95MB - CI testing — GitHub Actions builds the image and runs PHPUnit inside it
- Local dev — Developers run the full stack locally
- Staging preview — -weight: 500;">docker compose up spins up a functional preview before FTP deploy