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.

Flowchart:
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:

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.