A Proper Docker Image for WebCalendar: SQLite-Backed, Multi-Arch, and Self-Contained
Published 12th of July 2026
I recently needed a simple, self-hosted calendar solution and chose WebCalendar. It’s a mature PHP application that remains reliable and functional. However, the upstream Docker implementation had a design choice that conflicted with my containerization principles: it relies on bind-mounting the application source code from the host into the container.
I prefer containers to be immutable and self-contained. The host should only manage state—data that must persist across rebuilds—not the application binaries or source code. To address this, I created webcalendar-docker, a reimagined packaging of WebCalendar that keeps everything inside the image except for what strictly needs to survive.
Key Design Decisions
Building from Git, not Release Zips.
This decision was driven by a packaging inconsistency in the
upstream
v1.9.19 release zip. The archive references
includes/mcp-loader.php, but the file is missing.
The git repository has contained this file since
v1.9.14. By building from a pinned git reference
(WEBCALENDAR_REF, defaulting to
v1.9.19), we avoid patching workarounds and ensure
a complete source tree.
SQLite over MySQL/MariaDB. Upstream Compose files typically wire up MariaDB or PostgreSQL. For single-user or small-team deployments, this introduces unnecessary complexity: an extra container, connection management, and startup ordering dependencies. SQLite handles this workload efficiently within a single file, reducing the infrastructure footprint to one container.
Strict Separation of Code and State.
Only two directories are bind-mounted to the host:
data/ (for the SQLite database) and
includes/ (for generated configuration files like
settings.php). Everything else—the PHP runtime, web
server configuration, and WebCalendar source—is baked into the
image. You can rebuild the image or destroy the container
without risking your data or configuration.
Multi-Architecture Support.
GitHub Actions automatically builds and publishes images for
both
amd64 and arm64. This ensures seamless
deployment on Raspberry Pi devices or other ARM-based servers
without requiring manual local builds.
Documented Upstream Quirks.
Enabling "single-user mode" in the install wizard currently
triggers a warning regarding single_user_login due
to an upstream configuration handling issue. Until this is
resolved upstream, the recommended approach is to use multi-user
mode, which functions correctly.
Under the Hood: The Dockerfile Strategy
The following sections detail how the Dockerfile achieves a robust, self-contained setup.
1. Defensive PHP Extension Installation
WebCalendar requires several PHP extensions: gd,
zip, intl, mbstring,
opcache, and SQLite support. Each extension depends
on specific system libraries (e.g., libpng-dev for
gd).
Rather than assuming which extensions are pre-installed in the
base
php:8-apache image, the Dockerfile checks the
runtime environment before installing:
(php -m | grep -qi "Zend OPcache" || docker-php-ext-install -j"$(nproc)" opcache) && (php -m | grep -qix pdo_sqlite || docker-php-ext-install pdo_sqlite) && (php -m | grep -qix sqlite3 || docker-php-ext-install sqlite3)
This approach ensures compatibility across different PHP 8.x point releases, where bundling behaviors for extensions like OPcache and SQLite may vary. It prevents build failures when the base image updates its PHP version.
2. Shallow Git Clone
To resolve the upstream packaging issue, we clone directly from git:
RUN cd / \
&& rm -rf /var/www/html \
&& git clone --depth 1 --branch "${WEBCALENDAR_REF}" \
https://github.com/craigk5n/webcalendar.git /var/www/html \
&& rm -rf /var/www/html/.git
Using --depth 1 performs a shallow clone, fetching
only the specific commit for the target tag. This keeps the
build fast and the final image size minimal. The
.git directory is removed afterward, as version
history is not needed in a production container.
3. Preparing the Installer
The WebCalendar install wizard writes configuration to
includes/settings.php. Since this file does not
exist in the fresh git clone, we create it and set appropriate
permissions:
RUN mkdir -p data \
&& touch includes/settings.php \
&& chmod 777 includes/settings.php \
&& chown -R www-data:www-data /var/www/html
4. The Bind-Mount Seeding Mechanism
This is the core innovation that allows us to keep the source code inside the image while still using bind mounts for state.
The Dockerfile declares two volumes:
VOLUME ["/var/www/html/data", "/var/www/html/includes"]
When a host directory is bind-mounted to a container path, it
completely replaces the contents of that path in the image. If
we simply mounted an empty host folder to
/var/www/html/includes, we would lose all the
essential PHP include files cloned from git, breaking the
application.
The solution involves backing up the includes directory during the build and restoring it at runtime if the bind mount is empty:
# 1. Backup the cloned includes directory to a safe location within the image RUN cp -a /var/www/html/includes /opt/includes-backup # 2. Create an entrypoint script to handle seeding RUN printf '#!/bin/bash\nset -e\n\n# If init.php is missing, the host bind-mount is empty.\nif [ ! -f /var/www/html/includes/init.php ]; then\n echo "Seeding includes directory from image backup..."\n cp -a /opt/includes-backup/. /var/www/html/includes/\n chown -R www-data:www-data /var/www/html/includes\nfi\n\n# Execute the main command (apache2-foreground)\nexec "$@"\n' > /usr/local/bin/docker-entrypoint.sh RUN chmod +x /usr/local/bin/docker-entrypoint.sh ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] CMD ["apache2-foreground"]
The backup is stored at /opt/includes-backup, a
location unaffected by volume mounts. On container startup, the
entrypoint script checks for includes/init.php. If
it’s missing (indicating a fresh, empty host mount), the script
restores the files from the backup and corrects ownership.
Subsequent restarts skip this step, as the file already exists.
Note the use of printf with explicit
\n
sequences to generate the entrypoint script. This guarantees
clean Unix line endings, avoiding potential issues with carriage
returns that can occur when using heredocs across different
editing environments.
Host Filesystem Container Filesystem
---------------- ------------------
./webcalendar-data --> /var/www/html/data (SQLite DB)
./webcalendar-includes --> /var/www/html/includes (Config)
/opt/includes-backup (Hidden inside Image)
|
| [Entrypoint Script]
v
IF /var/www/html/includes/init.php is MISSING:
COPY /opt/includes-backup/* -> /var/www/html/includes/
ELSE:
Do nothing (State already exists)
The CI/CD Pipeline
The project uses GitHub Actions to handle the heavy lifting of building and publishing. The workflow is designed to be "set-and-forget," ensuring the image stays secure and up-to-date across multiple architectures.
Trigger Event Action Taken
---------------- --------------------------------------------------
Push to 'main' Builds 'latest' tag (amd64 + arm64)
New Git Tag (v1.x) Builds specific version tag (e.g., v1.9.19)
Weekly Cron (Monday) Rebuilds from scratch to catch base-image
security patches (php:8-apache updates)
Manual Dispatch Allows building a specific branch (e.g., 'master')
for testing upstream changes
The most complex part of the pipeline is the multi-architecture build. Instead of maintaining two separate pipelines, we use Docker Buildx and QEMU to cross-compile both versions in a single job:
Think of the build process as a lean assembly line: it installs only the necessary system dependencies, prepares the application, and strips away the build tools to create a lightweight package. By using cross-compilation, we produce two optimized versions—one for standard servers (amd64) and one for devices like Raspberry Pis (arm64)—from a single workflow.
[ GitHub Actions Runner (ubuntu-latest) ]
|
v
[ Setup Buildx & QEMU ]
|
v
/---------------------------\
| |
[ Build linux/amd64 ] [ Build linux/arm64 ]
| |
\---------------------------/
|
v
[ Push to GHCR ]
|
v
[ Update Manifest List ]
(So 'docker pull' works on any arch)
This ensures that whether you are running a powerful x86 server or a low-power Raspberry Pi, you get a natively compiled, optimized image without any extra steps on your end.
Usage
git clone https://github.com/MysterHawk/webcalendar-docker.git cd webcalendar-docker docker compose up -d
Navigate to http://localhost:8080 to access the
install wizard. Select SQLite3 as the database type, use
localhost
as the path, and complete the setup.
Backup is straightforward:
tar czf webcalendar-backup.tar.gz ./webcalendar-data ./webcalendar-includes
To build a different version, modify
WEBCALENDAR_REF in the Compose file (e.g., to
master) and rebuild:
docker compose build --no-cache && docker compose
up -d. Your data remains intact as it is stored outside the image.
Why a Separate Project?
Contributing these changes or reporting bugs upstream would have been ideal, but the original repository currently has issue tracking disabled. This makes it difficult to coordinate fixes or discuss architectural changes with the maintainer. Consequently, this project exists as an unofficial, independent packaging of Craig Knudsen's WebCalendar. All calendar functionality remains attributable to the original project.
Current Limitations
Two features are not yet implemented:
-
Email Reminders: The cron job for
send_reminders.phpis not configured. - Composer Dependencies: Vendor libraries required for the optional MCP/AI-assistant endpoint are excluded, as they are not necessary for core calendar operations.
If these features are critical for your use case, please open an issue on the repository.
This project represents my first attempt at creating a publicly usable package for a third-party application. If you value a clean, self-contained Docker setup for WebCalendar, I hope you find it useful. Feedback and contributions are welcome.
Repository & Images: github.com/MysterHawk/webcalendar-docker
This page was last updated on the 12th of July 2026.