Extending Docker Lab¶
Learn how to add your own services, build custom profiles, and create reusable modules that integrate seamlessly with the Docker Lab foundation.
Overview¶
Docker Lab is designed to be extended. The foundation stack gives you a reverse proxy, secret management, network isolation, and health-checked startup ordering -- but the real value comes when you deploy your own applications on top of it. Think of Docker Lab as a well-organized workshop: the workbench, power tools, and safety equipment are already set up. Your job is to bring your project to the bench and plug it in.
This chapter walks you through extending Docker Lab at every level. You will add a custom service from scratch, create a reusable profile, and learn the module system that ties everything together. By the end, you will understand the patterns that keep Docker Lab extensible without sacrificing the security and reliability of the foundation.
Why does this matter? Every self-hosted application you deploy follows the same integration pattern. Learn it once, and you can deploy anything -- a blog, a chat server, an API backend, a machine learning pipeline -- with consistent security, routing, and resource management.
The Four-Tier Extension Model¶
Before adding your first service, you need to understand where it fits. Docker Lab organizes everything into four tiers, each building on the one below it.
The following diagram shows how the four tiers relate to each other:
flowchart TB
Tier4["Tier 4: Services"]
Tier3["Tier 3: Examples"]
Tier2["Tier 2: Profiles"]
Tier1["Tier 1: Foundation"]
Tier4 --> Tier3
Tier3 --> Tier2
Tier2 --> Tier1
Each tier has a specific purpose:
- Tier 1 (Foundation) provides Traefik, the socket proxy, the four-network topology, and secret management. You never modify this tier -- you build on top of it.
- Tier 2 (Profiles) adds supporting infrastructure like PostgreSQL, MySQL, Redis, and MinIO. These are shared services that multiple applications can use.
- Tier 3 (Examples) contains application deployments. Each example lives in its own directory with its own compose file and documentation. This is where most custom services land.
- Tier 4 (Services) is for deeply integrated components that register with the dashboard, declare module manifests, and participate in the event system.
Most of the time, you will work at Tier 3. If your application needs a database that Docker Lab does not include yet, you will create a Tier 2 profile. If you are building a full module with dashboard integration, you are working at Tier 4.
Tutorial: Add Your First Custom Service¶
We will walk through adding a hypothetical internal wiki called "TeamNotes" -- a Node.js application that needs PostgreSQL, serves content over HTTPS, and should be protected by authentication on its admin pages. This is a realistic scenario that exercises every integration point.
Step 1: Create the Example Directory¶
Every custom application lives in the examples/ directory. Start by creating a directory for your service and copying the template.
$ mkdir -p examples/teamnotes
$ cp examples/_template/docker-compose.template.yml examples/teamnotes/docker-compose.teamnotes.yml
$ cp examples/_template/README.md examples/teamnotes/README.md
$ cp examples/_template/.env.example examples/teamnotes/.env.example
Your directory now looks like this:
Step 2: Configure the Compose File¶
Open docker-compose.teamnotes.yml and replace the template placeholders. Here is the complete, working configuration:
File: examples/teamnotes/docker-compose.teamnotes.yml
# TeamNotes Internal Wiki
#
# Prerequisites:
# - Foundation running (docker compose up -d)
# - PostgreSQL profile enabled
#
# Deploy:
# docker compose -f docker-compose.yml \
# -f profiles/postgresql/docker-compose.postgresql.yml \
# -f examples/teamnotes/docker-compose.teamnotes.yml \
# up -d
x-teamnotes-healthcheck: &teamnotes-healthcheck
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
x-teamnotes-logging: &teamnotes-logging
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
services:
teamnotes:
image: example/teamnotes:2.4.1
container_name: pmdl_teamnotes
depends_on:
postgres:
condition: service_healthy
environment:
NODE_ENV: production
DATABASE_URL: postgresql://teamnotes:${TEAMNOTES_DB_PASSWORD}@postgres:5432/teamnotes
APP_URL: https://wiki.${DOMAIN}
APP_SECRET_FILE: /run/secrets/teamnotes_secret
secrets:
- teamnotes_db_password
- teamnotes_secret
volumes:
- pmdl_teamnotes_data:/app/data
- pmdl_teamnotes_uploads:/app/uploads
networks:
- proxy-external
- db-internal
deploy:
resources:
limits:
memory: 512M
reservations:
memory: 256M
healthcheck:
<<: *teamnotes-healthcheck
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:3000/health || exit 1"]
restart: unless-stopped
logging:
<<: *teamnotes-logging
labels:
- "traefik.enable=true"
- "traefik.http.routers.teamnotes.rule=Host(`wiki.${DOMAIN}`)"
- "traefik.http.routers.teamnotes.entrypoints=websecure"
- "traefik.http.routers.teamnotes.tls=true"
- "traefik.http.routers.teamnotes.tls.certresolver=letsencrypt"
- "traefik.http.routers.teamnotes.service=teamnotes"
- "traefik.http.routers.teamnotes.priority=10"
- "traefik.http.routers.teamnotes-admin.rule=Host(`wiki.${DOMAIN}`) && PathPrefix(`/admin`)"
- "traefik.http.routers.teamnotes-admin.entrypoints=websecure"
- "traefik.http.routers.teamnotes-admin.tls=true"
- "traefik.http.routers.teamnotes-admin.tls.certresolver=letsencrypt"
- "traefik.http.routers.teamnotes-admin.middlewares=authelia@file"
- "traefik.http.routers.teamnotes-admin.service=teamnotes"
- "traefik.http.routers.teamnotes-admin.priority=100"
- "traefik.http.services.teamnotes.loadbalancer.server.port=3000"
secrets:
teamnotes_db_password:
file: ./secrets/teamnotes_db_password
teamnotes_secret:
file: ./secrets/teamnotes_secret
volumes:
pmdl_teamnotes_data:
driver: local
pmdl_teamnotes_uploads:
driver: local
networks:
proxy-external:
external: true
name: pmdl_proxy-external
db-internal:
external: true
name: pmdl_db-internal
There is a lot happening in this file, so let us break down each section.
Step 3: Understand the Integration Points¶
The following diagram shows how your custom service connects to the foundation stack:
flowchart TB
Internet["Internet"]
Traefik["Traefik"]
TeamNotes["TeamNotes App"]
Postgres["PostgreSQL"]
SocketProxy["Socket Proxy"]
Docker["Docker Engine"]
Internet -->|"HTTPS"| Traefik
Traefik --> TeamNotes
TeamNotes --> Postgres
Traefik --> SocketProxy
SocketProxy --> Docker
Your service connects to the foundation through three mechanisms:
Networks determine which services can talk to each other. TeamNotes joins two networks: proxy-external so Traefik can route traffic to it, and db-internal so it can reach PostgreSQL. It does not join the management network because it has no reason to talk to the Docker socket proxy.
Traefik labels tell the reverse proxy how to route traffic. The labels declare two routers: one for public access to the wiki and one for the admin section that requires authentication through Authelia. The priority values ensure the more specific admin route matches first.
Secrets are file-based credentials mounted into the container at /run/secrets/. This approach keeps passwords out of environment variables (which can leak through process listings and debug endpoints) and out of compose files (which might be committed to version control).
Step 4: Generate Secrets¶
Every application needs its own secret files. Generate them before the first deployment.
$ openssl rand -base64 32 > secrets/teamnotes_db_password
$ openssl rand -base64 32 > secrets/teamnotes_secret
$ chmod 600 secrets/teamnotes_db_password secrets/teamnotes_secret
The chmod 600 command restricts the files so only the file owner can read them. Docker Lab enforces this permission model across all secret files.
Step 5: Initialize the Database¶
PostgreSQL needs to know about your application's database and user before the application starts. Add an initialization block to the PostgreSQL profile's init script.
File: profiles/postgresql/init-scripts/01-init-databases.sh (append to existing file)
TEAMNOTES_PASSWORD=$(cat /run/secrets/teamnotes_db_password 2>/dev/null || echo "")
if [ -n "$TEAMNOTES_PASSWORD" ]; then
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
CREATE DATABASE teamnotes;
CREATE USER teamnotes WITH PASSWORD '$TEAMNOTES_PASSWORD';
GRANT ALL PRIVILEGES ON DATABASE teamnotes TO teamnotes;
EOSQL
fi
This script runs only once -- when the PostgreSQL container initializes for the first time. The if guard ensures it does not fail if the secret file is not present (for example, if PostgreSQL is being used by other services that do not need TeamNotes).
Step 6: Deploy and Validate¶
Start the full stack with your new service included:
$ docker compose \
-f docker-compose.yml \
-f profiles/postgresql/docker-compose.postgresql.yml \
-f examples/teamnotes/docker-compose.teamnotes.yml \
up -d
Validate the deployment:
$ docker compose ps
NAME STATUS PORTS
traefik running (healthy) 80/tcp, 443/tcp
socket-proxy running (healthy) 2375/tcp
postgres running (healthy) 5432/tcp
teamnotes running (healthy) 3000/tcp
$ curl -I https://wiki.yourdomain.com
HTTP/2 200
$ curl -I https://wiki.yourdomain.com/admin
HTTP/2 302
Location: https://auth.yourdomain.com/...
The admin route returns a 302 redirect to Authelia, confirming that authentication protection is active.
Creating a Custom Profile¶
Profiles live at Tier 2 and provide shared infrastructure that multiple applications can use. While Docker Lab ships with profiles for PostgreSQL, MySQL, MongoDB, Redis, and MinIO, you may need something else -- a message queue like RabbitMQ, a search engine like Meilisearch, or a time-series database like InfluxDB.
Profile Directory Structure¶
Every profile follows a standard layout:
profiles/my-service/
PROFILE-SPEC.md
docker-compose.my-service.yml
init-scripts/
backup-scripts/
healthcheck-scripts/
Start by copying the profile template:
Writing the Compose File¶
A profile compose file defines the service, its health check, resource limits, and network connections. Here is a complete RabbitMQ profile:
File: profiles/rabbitmq/docker-compose.rabbitmq.yml
services:
rabbitmq:
image: rabbitmq:3.13-management-alpine
container_name: pmdl_rabbitmq
environment:
RABBITMQ_DEFAULT_USER_FILE: /run/secrets/rabbitmq_user
RABBITMQ_DEFAULT_PASS_FILE: /run/secrets/rabbitmq_password
secrets:
- rabbitmq_user
- rabbitmq_password
volumes:
- pmdl_rabbitmq_data:/var/lib/rabbitmq
networks:
- db-internal
deploy:
resources:
limits:
memory: ${RABBITMQ_MEMORY_LIMIT:-512M}
reservations:
memory: ${RABBITMQ_MEMORY_RESERVATION:-256M}
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "check_port_connectivity"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
restart: unless-stopped
secrets:
rabbitmq_user:
file: ./secrets/rabbitmq_user
rabbitmq_password:
file: ./secrets/rabbitmq_password
volumes:
pmdl_rabbitmq_data:
driver: local
networks:
db-internal:
external: true
name: pmdl_db-internal
Notice the patterns that make this a well-behaved profile:
- Secrets via files -- credentials use
_FILEsuffixes, never plain text in environment variables - Resource limits with defaults -- the
${RABBITMQ_MEMORY_LIMIT:-512M}syntax lets operators override limits without editing the compose file - Health check -- RabbitMQ provides a built-in diagnostic command; use it instead of a generic TCP check
- Network isolation -- RabbitMQ joins only
db-internalbecause no external traffic should reach it directly - Named volumes with prefix -- the
pmdl_prefix prevents naming collisions with other Docker projects on the same host
Testing Your Profile¶
Before deploying, validate the compose configuration:
$ docker compose \
-f docker-compose.yml \
-f profiles/rabbitmq/docker-compose.rabbitmq.yml \
config
This command merges the compose files and prints the resolved configuration. If there are syntax errors, missing network references, or other problems, they appear here -- not during deployment. Always run config before up.
The Example Application Pattern¶
Docker Lab uses a deliberate pattern for organizing example applications. Understanding this pattern helps you create services that are consistent with the rest of the project and easy for others to use.
Why Separate Compose Files?¶
You might wonder why each application gets its own compose file instead of being added to the main docker-compose.yml. The reason is lifecycle separation. Foundation infrastructure (Traefik, socket proxy) has a different change frequency than your applications. You update your blog software weekly; you update Traefik once a quarter. Keeping them in separate files means you can:
- Start and stop applications independently
- Update one application without touching others
- Share application configurations with other Docker Lab users
- Test applications in isolation
The following diagram shows how compose files extend and override each other:
flowchart TB
Base["docker-compose.yml"]
Profile["postgresql profile"]
App["teamnotes app"]
Base --> Profile
Base --> App
Profile --> App
When you run docker compose -f with multiple files, Docker merges them left to right. The foundation file defines the networks, the profile file adds a database on those networks, and the application file adds your service on the same networks with a dependency on the database.
Example Directory Checklist¶
Every example directory should contain these files:
| File | Purpose |
|---|---|
docker-compose.<name>.yml |
Complete compose configuration for the service |
README.md |
Setup instructions, prerequisites, configuration reference |
.env.example |
Template environment file with all required variables |
secrets-required.txt |
List of secret files that must be created before deployment |
Using the COMPOSE_FILE Variable¶
Typing long -f chains gets tedious. Docker Compose supports the COMPOSE_FILE environment variable as a shortcut:
$ export COMPOSE_FILE=docker-compose.yml:profiles/postgresql/docker-compose.postgresql.yml:examples/teamnotes/docker-compose.teamnotes.yml
$ docker compose up -d
$ docker compose ps
$ docker compose logs teamnotes
All subsequent docker compose commands in that shell session use the same set of files. This is especially useful in .env files for persistent configuration:
File: .env
COMPOSE_FILE=docker-compose.yml:profiles/postgresql/docker-compose.postgresql.yml:examples/teamnotes/docker-compose.teamnotes.yml
DOMAIN=example.com
Integration Testing Your Custom Service¶
Deploying is only half the work. You need to verify that your service integrates correctly with the foundation stack.
Compose Configuration Validation¶
Always start by validating the merged compose configuration:
$ docker compose -f docker-compose.yml \
-f profiles/postgresql/docker-compose.postgresql.yml \
-f examples/teamnotes/docker-compose.teamnotes.yml \
config -q
The -q flag suppresses output on success. If this command exits with code 0, your compose files merge correctly. If it exits with an error, the error message tells you exactly what is wrong.
Health Check Verification¶
After deployment, confirm that all services report healthy status:
$ docker compose ps --format "table {{.Name}}\t{{.Status}}"
NAME STATUS
pmdl_traefik running (healthy)
pmdl_socket-proxy running (healthy)
pmdl_postgres running (healthy)
pmdl_teamnotes running (healthy)
If a service shows "starting" for more than its start_period, check its logs for errors.
Network Connectivity Tests¶
Verify that your service can reach its dependencies and that isolation is enforced:
$ docker compose exec teamnotes nc -zv postgres 5432
Connection to postgres 5432 port [tcp/postgresql] succeeded!
$ docker compose exec teamnotes wget -q --spider http://127.0.0.1:3000/health
TLS Certificate Check¶
Confirm that Traefik has provisioned a valid certificate for your subdomain:
$ echo | openssl s_client -connect wiki.yourdomain.com:443 2>/dev/null \
| openssl x509 -noout -dates
notBefore=Feb 25 00:00:00 2026 GMT
notAfter=May 26 00:00:00 2026 GMT
Resource Limit Verification¶
Check that container memory limits are enforced:
$ docker stats --no-stream pmdl_teamnotes
CONTAINER CPU % MEM USAGE / LIMIT MEM %
pmdl_teamnotes 0.15% 87MiB / 512MiB 17.00%
The "LIMIT" column should match the deploy.resources.limits.memory value from your compose file.
Best Practices for Service Isolation and Security¶
Docker Lab's four-network topology exists to enforce the principle of least privilege. Every service should connect only to the networks it needs and nothing more.
Network Selection Guide¶
| Your service needs to... | Connect to this network |
|---|---|
| Receive traffic from the internet via Traefik | proxy-external |
| Communicate with other internal services | proxy-internal |
| Access a database (PostgreSQL, MySQL, MongoDB) | db-internal |
| Talk to the Docker socket proxy (rare) | management |
Most applications need only proxy-external and db-internal. If your service does not serve web traffic (for example, a background worker), it may only need db-internal.
Secret Hygiene¶
Follow these rules for every service you add:
- Never hardcode credentials in compose files, Dockerfiles, or environment variable defaults
- Use the
_FILEsuffix pattern when the application supports it -- this reads the secret from a mounted file instead of an environment variable - Generate unique secrets per service -- do not reuse the PostgreSQL root password for your application
- Set file permissions to 600 on every secret file so only the owner can read it
Resource Limits Are Not Optional¶
Every service must declare memory limits. A service without limits can consume all available memory on the host, crashing every other service. Use these guidelines for sizing:
| Application Type | Memory Limit | Memory Reservation |
|---|---|---|
| Static site generator | 128M | 64M |
| Node.js application | 256-512M | 128-256M |
| Python/Django application | 256-512M | 128-256M |
| Java/Kotlin application | 512M-2G | 256M-1G |
| PHP application | 256-512M | 128-256M |
Health Checks Are Not Optional¶
Every service must declare a health check. Without one, Docker cannot determine startup ordering, and dependent services may start before their dependencies are ready. Follow these rules:
- Use IPv4 explicitly --
http://127.0.0.1:3000/healthinstead ofhttp://localhost:3000/healthbecause localhost may resolve to IPv6 on some systems - Match the health check tool to the base image -- Alpine images include
wgetbut notcurl; Debian images includecurl - Set a generous
start_periodfor applications that take time to initialize (database migrations, asset compilation)
The Module System: Deep Integration¶
If your service needs to register with the Docker Lab dashboard, declare dependencies on other modules, or participate in the event system, you are building a Tier 4 module. Modules go beyond simple compose file integration -- they include a JSON manifest that describes the module's capabilities, requirements, and lifecycle.
Module Directory Structure¶
modules/my-module/
module.json
docker-compose.yml
README.md
hooks/
install.sh
start.sh
stop.sh
health.sh
dashboard/
StatusWidget.html
Creating a Module from the Template¶
Docker Lab provides a starter template:
The Module Manifest¶
The module.json file is the contract between your module and the foundation. Here is a minimal manifest:
{
"id": "my-module",
"version": "1.0.0",
"name": "My Custom Module",
"description": "A module that does useful things",
"foundation": {
"minVersion": "1.0.0"
},
"requires": {
"connections": [
{
"type": "database",
"providers": ["postgres"],
"required": true,
"alias": "my-module-db"
}
]
},
"provides": {
"events": [
"my-module.data.imported",
"my-module.data.exported"
]
},
"lifecycle": {
"install": "./hooks/install.sh",
"start": "./hooks/start.sh",
"stop": "./hooks/stop.sh",
"health": "./hooks/health.sh"
},
"dashboard": {
"displayName": "My Module",
"icon": "puzzle",
"routes": [
{
"path": "/my-module",
"component": "./dashboard/StatusWidget.html",
"nav": {
"label": "My Module",
"icon": "puzzle",
"order": 50
}
}
]
}
}
The manifest declares three things:
- Requirements -- the module needs a PostgreSQL database connection
- Capabilities -- the module emits two events that other modules can listen for
- Dashboard registration -- the module appears in the dashboard navigation with a status widget
Module Compose File Pattern¶
Module compose files use extends to inherit resource limits from foundation base services:
services:
my-module-app:
extends:
file: ../../docker-compose.base.yml
service: _service-standard
image: my-org/my-module:1.0.0
container_name: pmdl_my-module
environment:
- MY_MODULE_DB_URL=postgresql://mymod:${MY_MODULE_DB_PASSWORD}@postgres:5432/mymod
volumes:
- my-module-data:/data
healthcheck:
test: ["CMD", "sh", "-c", "wget -q --spider http://127.0.0.1:8080/health || exit 0"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- proxy-external
- db-internal
The extends directive pulls in resource limits, logging configuration, and other base settings from the foundation. This keeps your compose file focused on what makes your module unique.
Activating a Module¶
Include the module compose file alongside the root stack:
$ docker compose -f docker-compose.yml \
-f profiles/postgresql/docker-compose.postgresql.yml \
-f modules/my-module/docker-compose.yml \
up -d
Common Gotchas¶
Network name mismatch. When referencing foundation networks from a separate compose file, you must use the full prefixed name. The compose file declares proxy-external as a service network alias, but the actual Docker network name is pmdl_proxy-external. Always include the name field in your network declaration:
Healthcheck uses curl on Alpine image. Alpine-based images do not include curl by default. Use wget instead:
# Wrong -- fails on Alpine
test: ["CMD-SHELL", "curl -f http://127.0.0.1:3000/health || exit 1"]
# Correct -- works on Alpine
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:3000/health || exit 1"]
Forgot to create secret files. If a compose file references a secret file that does not exist, docker compose up fails with a cryptic error about missing paths. Always generate secrets before the first deployment and verify they exist:
$ ls -la secrets/teamnotes_*
-rw------- 1 deploy deploy 44 Feb 25 10:00 secrets/teamnotes_db_password
-rw------- 1 deploy deploy 44 Feb 25 10:00 secrets/teamnotes_secret
Database init script runs only once. PostgreSQL's init scripts execute only when the data directory is empty (first container start). If you add a new application after PostgreSQL is already running, you must create the database manually:
$ docker compose exec postgres psql -U postgres -c "CREATE DATABASE teamnotes;"
$ docker compose exec postgres psql -U postgres -c "CREATE USER teamnotes WITH PASSWORD 'your-password';"
$ docker compose exec postgres psql -U postgres -c "GRANT ALL PRIVILEGES ON DATABASE teamnotes TO teamnotes;"
Resource limit not enforced. If docker stats shows no memory limit, your compose file's deploy.resources section may not be indented correctly. The deploy key must be at the same level as image and environment, not nested inside another key.
Key Takeaways¶
- Every custom service follows the same pattern: create a directory in
examples/, write a compose file that references foundation networks, add Traefik labels for routing, and generate secret files for credentials. - Profiles provide reusable infrastructure (databases, caches, queues) at Tier 2. Create one when multiple applications need the same backing service.
- Modules at Tier 4 add dashboard registration, lifecycle hooks, and event declarations for deeply integrated services.
- Always validate your compose configuration with
docker compose configbefore deploying. - Enforce network isolation, resource limits, health checks, and file-based secrets on every service you add. These are not optional -- they are the patterns that keep Docker Lab reliable.
Next Steps¶
Now that you know how to extend Docker Lab with your own services, the Use Cases chapter shows real-world deployment scenarios. You will see how teams combine foundation services, profiles, and custom applications to build complete self-hosted platforms -- from a personal blog with analytics to a multi-tenant SaaS stack with federated identity.