Running a video discovery platform across multiple environments gets messy fast. Different PHP versions, missing extensions, inconsistent configs — the usual headaches. Here's how I containerized DailyWatch using Docker, going from a bare-metal setup to a reproducible, deployable container image.
Why Docker for a PHP + SQLite Stack?
DailyWatch aggregates trending video content from 8+ regions. The stack is intentionally simple: PHP 8.3, SQLite, and LiteSpeed. No PostgreSQL cluster, no Redis dependency in production. That simplicity is a strength when containerizing — fewer moving parts means fewer things to break.
The main goals were:
- Identical environments across dev and production
- Easy onboarding (one command to spin up)
- Predictable deployments with no "works on my machine" surprises
Multi-Stage Dockerfile
A single-stage build bloats the image with build tools you never need at runtime. Multi-stage keeps it lean:
# Stage 1: Build dependencies
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
COPY composer.json composer.lock /app/
WORKDIR /app
RUN curl -sS https://getcomposer.org/installer | php && \
php composer.phar install --no-dev --optimize-autoloader
# Stage 2: Production image
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/
# SQLite database directory
RUN mkdir -p /var/www/html/data && \
chown -R nobody:nogroup /var/www/html/data
EXPOSE 80 443
CMD ["/usr/local/lsws/bin/lswsctrl", "start"]
The builder stage compiles extensions and installs Composer packages. The production stage starts from the official OpenLiteSpeed image and copies only what's needed.
Volume Mounts for SQLite Persistence
SQLite files live on disk, so you need volumes to survive container restarts. This is the part most tutorials skip:
# docker-compose.yml
version: '3.8'
services:
web:
build: .
ports:
- "8080:80"
volumes:
- sqlite_data:/var/www/html/data
- page_cache:/var/www/html/data/pagecache
environment:
- APP_ENV=production
- SQLITE_JOURNAL_MODE=WAL
restart: unless-stopped
volumes:
sqlite_data:
driver: local
page_cache:
driver: local
Critical detail: SQLite in WAL mode needs the -wal and -shm files to live alongside the main .db file. Mount the entire data/ directory, not individual files.
LiteSpeed Configuration Inside Docker
OpenLiteSpeed uses XML-based virtual host configs. Override the default one:
<!-- conf/vhosts/videoplatform/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>
<cache>
<enableCache>1</enableCache>
<cacheStorePath>/tmp/lscache/</cacheStorePath>
</cache>
</virtualHost>
The cache store path points to /tmp inside the container so it doesn't pollute the persistent volume.
Development vs Production Compose Files
For local development, override the production compose with hot-reload mounts:
# docker-compose.dev.yml
services:
web:
build:
context: .
target: builder # Use the builder stage for dev tools
volumes:
- .:/var/www/html # Live code reload
- sqlite_data:/var/www/html/data
environment:
- APP_ENV=development
- DISPLAY_ERRORS=1
Run with docker compose -f docker-compose.yml -f docker-compose.dev.yml up. Changes to PHP files reflect immediately without rebuilding.
Health Checks
Don't skip this. A container that starts isn't necessarily a container that works:
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD curl -f http://localhost/health || exit 1
On DailyWatch, the /health endpoint checks PHP, SQLite connectivity, and disk space on the data volume.
Lessons Learned
- SQLite locking: Use WAL mode and keep write operations short. Long-running imports should use a separate connection with busy timeout set.
-
OPcache in containers: Set
opcache.validate_timestamps=0in production — files don't change inside a running container, so skip the stat calls. - Image size: The multi-stage approach brought the image from 1.2GB down to 380MB.
Containerizing a simple stack like PHP + SQLite is one of those things that sounds trivial until you hit the edge cases. Volume ownership, SQLite journal files, and LiteSpeed's config format all needed attention. But once it works, every deploy is just docker compose up -d.
This article is part of the Building DailyWatch series.
Top comments (0)