A modern Docker setup for hosting Python websites with Caddy web server and SSH access for Fabric deployments.
- Python 3.14.7: Supported Python version with common extensions.
- Caddy: Modern web server with automatic HTTPS.
- SSH Access: For automated deployments with Fabric.
- Automatic Site Discovery: Just add your site folder and it works.
- Development Domains: Test with .local domains that map to production folders.
- Automatic WWW Redirection: For second-level domains (e.g., example.com → www.example.com).
- Auto-HTTPS: Certificates are automatically generated on-demand.
- Environment Separation: Development and production environments managed through Docker Compose override.
- Optional PHP embed + phpy: Build PHP with
--enable-embedand install phpy so Python code running in this container can host and call PHP libraries directly (e.g.derafu/backbone-bridge-python). Disabled by default; see PHP embed + phpy (optional) below.
- Docker and Docker Compose installed on your system.
- SSH key for deployment access.
-
Clone this repository:
git clone https://github.com/derafu/docker-python3.14-caddy-server.git cd docker-python3.14-caddy-server -
Copy the environment and override templates (both are gitignored, so a fresh clone starts without them):
cp .env-dist .env cp docker-compose.override-example.yml docker-compose.override.yml
docker-compose.override.ymlis where the HTTP/HTTPS/SSH ports are actually mapped to the host — without it,docker-compose up -dstarts the container with no ports exposed at all. -
Add your SSH public key to
config/ssh/authorized_keysfor admin, and default deployment, access:cat ~/.ssh/id_rsa.pub > config/ssh/authorized_keys
-
Build and start the container:
docker-compose up -d
The
-dparameter runs it in detached mode (background).
Check that the container is running:
docker-compose psView container logs:
docker-compose logs -fThe -f parameter allows you to follow logs in real-time.
-
Create the site directory structure:
mkdir -p sites/www.example.com/ cd sites/www.example.com/ -
Create project Django
python3 -m venv venv source venv/bin/activate pip install django django-admin startproject example .
-
Run a single site manually
docker-compose exec webserver /scripts/start_procfile_supervisord.sh www.example.comNote: If no site is specified, the script will start all available sites under /var/www/sites.
-
Access the site at:
- Production mode: https://www.example.com (requires DNS configuration).
- Development mode: https://www.example.com.local:8443 (requires local hosts entry).
For local development, add to your /etc/hosts file:
127.0.0.1 www.example.com.local
This image can optionally build PHP from source with --enable-embed and install
phpy, so Python code can load and call a PHP
library directly in the same process (the Python-hosts-PHP direction; the
opposite direction, PHP-hosts-Python, is what docker-php8.5-caddy-server provides).
This is disabled by default: no regular PHP package (apt, homebrew, official
Docker images) ships with --enable-embed, so getting it requires compiling PHP
from source, which adds several minutes to the image build. Enable it explicitly
when you actually need it:
# In your .env file:
PHPY_ENABLED=true
PHPY_PHP_VERSION=8.5.3 # optional, defaults to 8.5.3
docker compose build
docker compose up -dWhen enabled, the embed-enabled PHP build lives at /opt/php (php, php-config,
phpize, composer on PATH), and phpy is installed into the container's Python.
Verify it works with:
docker compose exec webserver python3 -c "import phpy; print(phpy)"Building with the default PHPY_ENABLED=false skips all of this and behaves
exactly like a normal Python + Caddy image.
docker-python3.14-caddy-server/
├── Dockerfile # Python + Caddy server base image definition (+ optional PHP/phpy)
├── docker-compose.yml # Production Docker services and volumes
├── docker-compose.override-example.yml # Template: copy to docker-compose.override.yml
├── .env-dist # Template: copy to .env
├── .env # Environment variables for docker-compose (gitignored)
├── LICENSE # Project license
├── README.md # Main project documentation
Configuration
├── config/
│ ├── bash/
│ │ └── bashrc # Shell prompt / history tweaks for container user
│ ├── caddy/
│ │ ├── Caddyfile # Caddy reverse proxy rules (HTTPS, domains, routing)
│ │ └── routes.d/ # Per-site path routes, generated from CADDY_ROUTE_PATH
│ ├── cron/
│ │ └── logrotate # Cron job for rotating logs periodically
│ ├── logrotate/
│ │ ├── caddy # Logrotate rules for Caddy
│ │ └── gunicorn # Logrotate rules for Gunicorn
│ ├── ssh/
│ │ ├── authorized_keys # Public keys for SSH login (e.g., deploy access)
│ │ └── sshd_config # SSH server settings (OpenSSH)
│ └── supervisor/
│ └── supervisord.conf # Supervisor config to manage processes (Caddy, Gunicorn, etc.)
Development
├── sites/ # Django projects, one per domain
│ └── www.example.com/ # Project folder for www.example.com
Helper Scripts
├── scripts/
│ └── start_procfile_supervisord.sh # Auto-detect and launch Gunicorn/Celery for each site under /sites
Documentation
├── docs/
│ └── docker.md # Notes and recommendations for Docker usage
This project uses Docker Compose's override functionality to separate development and production configurations:
The base docker-compose.yml contains the minimal configuration needed for production deployment. It:
- Sets up required environment variables.
- Defines essential ports (HTTP, HTTPS, SSH).
- Doesn't mount external volumes.
The docker-compose.override.yml file adds development-specific settings:
- Adds additional development ports (e.g., management interface).
- Mounts local volumes for easy site development.
-
Development: Docker Compose automatically merges both files:
docker-compose up -d
-
Production: Use only the base configuration:
docker-compose -f docker-compose.yml up -d
Connect to the container via SSH:
ssh admin@localhost -p 2222Access the container shell:
docker-compose exec webserver bashRestart Caddy web server:
docker-compose exec webserver supervisorctl restart caddydocker-compose downRebuild for development:
docker-compose build --no-cache
docker-compose up -dRebuild for production:
docker-compose -f docker-compose.yml build --no-cache
docker-compose -f docker-compose.yml up -d-
Create the site directory structure:
mkdir -p sites/www.newsite.com/ cd sites/www.newsite.com/ -
Create project Django
python3 -m venv venv source venv/bin/activate pip install django django-admin startproject newsite .
NOTE: It's important that the requirements.txt file includes the gunicorn dependency (Example gunicorn==23.0.0)
-
Start the site's processes (Caddy itself needs no restart — it detects new sites automatically on the next request — but its gunicorn process does need to be registered with supervisor at least once):
docker-compose exec webserver /scripts/start_procfile_supervisord.sh www.newsite.com -
For local development, add to your hosts file:
127.0.0.1 www.newsite.com.local
Customize behavior through environment variables:
| Variable | Description | Default |
|---|---|---|
SERVER_NAME |
Name for the docker container | derafu-sites-server-python-caddy |
CADDY_DEBUG |
Enable debug mode with debug |
(empty) |
CADDY_EMAIL |
Email for Let's Encrypt | admin@example.com |
CADDY_HTTPS_ISSUER |
TLS issuer (internal, acme) | internal |
CADDY_HTTPS_ALLOW_ANY_HOST |
Allow any host for TLS | false |
CADDY_LOG_SIZE |
Log file max size | 100mb |
CADDY_LOG_KEEP |
Number of log files to keep | 5 |
WWW_ROOT_PATH |
Web root path | /var/www/sites |
WWW_USER |
WWW and SSH user in the container | admin |
WWW_GROUP |
WWW group in the container | www-data |
HTTP_PORT |
HTTP port in host | 8080 |
HTTPS_PORT |
HTTPS port in host | 8443 |
SSH_PORT |
SSH port in host | 2222 |
MANAGER_PORT |
Port for the on-demand TLS endpoint (/api/ask) |
9090 |
GITHUB_WEBHOOK_SECRET |
No effect yet. Reserved for a /api/webhook receiver; no such service exists in this repo (external tooling) |
(empty) |
DEPLOYER_HOST |
No effect yet. Not passed into the container by docker-compose.yml; expects derafu/python-fabric-deployer (external tooling) |
(empty) |
PHPY_ENABLED |
Build PHP (--enable-embed) + phpy + Composer |
false |
PHPY_PHP_VERSION |
PHP version to build when PHPY_ENABLED=true |
8.5.3 |
The server handles domains in the following way:
-
Development domains: Any domain ending with
.local(e.g.,www.example.com.local)- Maps to the same directory as its production counterpart.
- Uses internal self-signed certificates.
-
Production domains:
- Redirects from non-www to www for second-level domains.
- Automatically obtains and manages Let's Encrypt certificates (issuer
acme).
If you're having issues with SSL certificates in development:
- Ensure your browser trusts self-signed certificates.
- Try using HTTP instead of HTTPS for local development.
If you encounter permission issues:
docker-compose exec webserver chown -R admin:www-data /var/www/sitesLogs are available in the container and can be accessed with:
docker-compose exec webserver cat /var/log/caddy/access.logFor advanced configurations, modify the Caddyfile at config/caddy/Caddyfile.
A site's Procfile normally has one web: process handling every
request. Sometimes a specific URL path needs its own, differently
configured process instead — for example, an endpoint that must run
single-threaded (--worker-class sync) while the rest of the site keeps
gthread, to isolate it without affecting anything else the site serves.
Add a CADDY_ROUTE_PATH=<path> environment variable inline on that
process's own Procfile line (this is plain shell syntax — any process
manager that reads a Procfile understands it, nothing custom):
web: gunicorn --bind :8000 --workers 4 --threads 4 --worker-class gthread yourproject.wsgi:application
heavy: CADDY_ROUTE_PATH=/api/v2/heavy/* gunicorn --bind :8000 --workers 2 --worker-class sync --timeout 300 yourproject.wsgi:application
- The process name (
heavyabove) can be anything exceptweb. - Write
--bind :8000like any other process — it gets rewritten to that process's own private socket automatically, the same wayweb:already works. Never hardcode a socket path yourself. <path>is a Caddy path pattern (glob), e.g./api/v2/heavy/*.
Apply it by (re-)running the site's setup script:
docker-compose exec webserver /scripts/start_procfile_supervisord.sh yoursite.comThis generates the matching Caddy rule under config/caddy/routes.d/,
validates the entire Caddy configuration, and reloads Caddy only if it's
valid — a broken or ambiguous route (e.g. two processes on the same site
declaring the same path) is rejected, logged, and the previous, working
routing is kept untouched, for that site and every other one sharing the
container. Everything else on the site keeps going to its normal web:
process, unaffected.
Contributions are welcome! Please feel free to submit a Pull Request. For major changes, please open an issue first to discuss what you would like to change.
This package is open-sourced software licensed under the MIT license.