When your video platform runs across multiple European regions, environment consistency stops being a nice-to-have and becomes essential. Here's how I Dockerized ViralVidVault, a viral video vault that curates trending content across European countries, from a manual setup to a fully containerized workflow.
The Stack
ViralVidVault runs on PHP 8.3, SQLite, and LiteSpeed. No heavy database server, no message queue — the simplicity is deliberate. Docker's job is to wrap this into something portable and predictable.
The containerization goals:
- One command to spin up the full environment locally
- Identical behavior between dev laptop and production hosting
- SQLite data survives container rebuilds
Multi-Stage Dockerfile
Keep build tools out of the production image:
# Stage 1: Build
FROM php:8.3-cli AS builder
RUN apt-get update && apt-get install -y \
libsqlite3-dev libcurl4-openssl-dev libzip-dev \
&& docker-php-ext-install pdo_sqlite curl zip opcache
WORKDIR /app
COPY composer.json composer.lock ./
RUN curl -sS https://getcomposer.org/installer | php \
&& php composer.phar install --no-dev --optimize-autoloader
# Stage 2: Runtime
FROM litespeedtech/openlitespeed:1.7.19-lsphp83
COPY --from=builder /usr/local/lib/php/extensions/ \
/usr/local/lsws/lsphp83/lib/php/extensions/
COPY --from=builder /app/vendor /var/www/html/vendor
COPY . /var/www/html/
RUN mkdir -p /var/www/html/data \
&& chown -R nobody:nogroup /var/www/html/data
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD curl -f http://localhost/health || exit 1
EXPOSE 80
CMD ["/usr/local/lsws/bin/lswsctrl", "start"]
The builder stage handles Composer dependencies and PHP extension compilation. The runtime stage copies only the artifacts it needs. This cuts the image from over 1GB to roughly 400MB.
Docker Compose with SQLite Volumes
SQLite needs careful volume management. Mount the entire data directory, not individual .db files — SQLite creates -wal and -shm companion files that must live in the same directory:
# docker-compose.yml
version: '3.8'
services:
viralvidvault:
build: .
ports:
- "8080:80"
volumes:
- app_data:/var/www/html/data
environment:
- APP_ENV=production
- SQLITE_JOURNAL_MODE=WAL
- FETCH_REGIONS=US,GB,PL,NL,SE,NO,AT
restart: unless-stopped
deploy:
resources:
limits:
memory: 512M
volumes:
app_data:
driver: local
The FETCH_REGIONS environment variable drives which European regions the cron fetcher targets. Different from other sites in the family — ViralVidVault focuses on European content.
LiteSpeed Virtual Host Config
OpenLiteSpeed uses XML config files. Override the default virtual host:
<!-- conf/vhosts/app/vhconf.xml -->
<virtualHost>
<docRoot>/var/www/html/public</docRoot>
<index useServer="0">
<indexFiles>index.php</indexFiles>
</index>
<rewrite>
<enable>1</enable>
<rules>
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ /index.php?url=$1 [QSA,L]
</rules>
</rewrite>
<phpIniOverride>
php_value opcache.validate_timestamps 0
php_value opcache.memory_consumption 128
</phpIniOverride>
</virtualHost>
Setting opcache.validate_timestamps to 0 in production is safe inside containers — files don't change at runtime, so skipping stat calls gives a measurable performance boost.
Development Override
For local development, add live code mounting and debugging tools:
# docker-compose.dev.yml
services:
viralvidvault:
volumes:
- .:/var/www/html
- app_data:/var/www/html/data
environment:
- APP_ENV=development
- DISPLAY_ERRORS=1
ports:
- "8080:80"
- "9003:9003" # Xdebug
Run both with docker compose -f docker-compose.yml -f docker-compose.dev.yml up. The bind mount overlays the built image with your local source, so edits appear instantly without rebuilding.
Handling SQLite Locking in Containers
SQLite's biggest gotcha in Docker: file locking. WAL mode is mandatory for any concurrent read/write scenario:
// Ensure WAL mode on every connection
$db = new PDO('sqlite:/var/www/html/data/videos.db');
$db->exec('PRAGMA journal_mode=WAL');
$db->exec('PRAGMA busy_timeout=5000'); // Wait up to 5s for locks
$db->exec('PRAGMA synchronous=NORMAL'); // Good balance of safety and speed
Without busy_timeout, concurrent requests during a cron fetch will get SQLITE_BUSY errors. Five seconds is generous but prevents any realistic timeout scenario.
What I Learned
- Named volumes, not bind mounts for production data. Bind mounts to host directories have permission headaches across different Docker hosts.
- OPcache preloading inside containers is free performance. Precompile your autoloader and router.
- Don't run cron inside the app container. Use a separate container or host-level cron that hits the app's task endpoint.
The container setup took a day to get right. It saves that time back on every deploy, every new developer onboarding, and every "it works on my machine" conversation that no longer happens.
This article is part of the Building ViralVidVault series.
Top comments (0)