Skip to main content

Example: a Compose bundle

Ghost needs a MySQL database next to it. That makes it a bundle: two containers that are installed, updated and removed together. The template uses the compose runtime, generates the database passwords on the device and takes the public URL from the web address the user picks.

The whole manifest​

apiVersion: obacht.dev/v2
kind: Template

metadata:
name: ghost
displayName: Ghost (MySQL bundle)
description: |
Ghost publishing platform with a dedicated MySQL 8 database.
version: "1.1.0"
author: obacht
license: MIT
homepage: https://ghost.org/
tags: [cms, blog, bundle]
trustLevel: official

spec:
minSpecVersion: "v2.2"
compatibility:
architectures: [linux/arm64, linux/amd64]
resources:
minRamMb: 768
minDiskMb: 2048

runtime:
type: compose
compose:
primaryService: ghost
primaryPort: 2368
imageDigests: {}
body: |
services:
ghost:
image: ghost:5-alpine
depends_on:
db:
condition: service_healthy
environment:
database__client: mysql
database__connection__host: db
database__connection__user: ghost
database__connection__password: "${secret.db_user_password}"
database__connection__database: ghost
url: "https://${cfg.domain}"
NODE_ENV: production
volumes:
- content:/var/lib/ghost/content
restart: unless-stopped
security_opt:
- "no-new-privileges:true"
db:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: "${secret.db_root_password}"
MYSQL_DATABASE: ghost
MYSQL_USER: ghost
MYSQL_PASSWORD: "${secret.db_user_password}"
volumes:
- mysql:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-p$$MYSQL_ROOT_PASSWORD"]
interval: 10s
timeout: 5s
retries: 10
restart: unless-stopped
security_opt:
- "no-new-privileges:true"
volumes:
content:
mysql:

services:
- name: web
targetType: container_port
targetService: ghost
targetPort: 2368

configSchema:
- key: domain
label: Public domain
type: domain
required: true
default: "blog.example.com"
description: The address Ghost runs under. It builds every link from it.

gettingStarted: |
1. Open **/ghost** on your address to create the owner account. The first
visitor to /ghost becomes the admin, so do this right after install.
2. Mail is not set up, so staff invitations and member magic links do not
send yet. For a blog of your own you do not need it.

secretsSchema:
- key: db_root_password
length: 32
charset: alphanumeric
- key: db_user_password
length: 32
charset: alphanumeric

secrets:
- DATABASE__CONNECTION__PASSWORD
- MYSQL_ROOT_PASSWORD
- MYSQL_PASSWORD

This is the published template with the description shortened and imageDigests shown the way you submit it.

Line by line​

runtime.type: compose with a body that is a Docker Compose file as a string. It looks like Compose and runs with Docker Compose, but it is checked against an allow list first, see below.

No readiness block. The default probe asks / until Ghost answers, which covers the minute MySQL and Ghost need on first start. An app with a long first start, like a big database import, would set timeoutSeconds.

primaryService and primaryPort say which container and port the web address points at. Only that container joins the network Caddy uses. The database stays on a private network that exists only for this install. Two Ghost installs on one device never see each other.

imageDigests: {} is what you write. On acceptance the registry fills it with the exact digest of every image, and the device refuses an image that does not match. Bump metadata.version for every new upstream release; the new digests come with it.

depends_on with condition: service_healthy plus the healthcheck on db keep Ghost from starting before MySQL accepts connections. Without it Ghost crashes on the first start and restarts until the database is up. It works, but it looks broken during install.

${secret.db_root_password} and ${secret.db_user_password} come from secretsSchema. The device generates them on first install with a secure random generator and keeps them. Nobody types them, Obacht never sees them, and the user never needs them. db_user_password appears twice, once for MySQL and once for Ghost, and both get the same value.

$$MYSQL_ROOT_PASSWORD in the healthcheck is a literal $ for Compose: the variable is read inside the container at check time, not filled in by Obacht.

url: "https://${cfg.domain}" is the reason for the one setting. Ghost writes its public URL into every link and redirect. The field has type: domain, so the install sheet fills it with the address the user picked, and in easy mode nobody has to type anything.

volumes are named volumes. Host paths are not allowed in a bundle. The volumes keep Ghost's content and the database across restarts, updates and changed settings. When the app is removed, they are deleted with it. The spec still knows x-obacht-data: true on a volume, but today it changes nothing.

security_opt: no-new-privileges:true and restart: unless-stopped are on both services. Copy them into your bundle.

gettingStarted tells the user the one thing that is urgent: claim the admin account before anyone else does.

secrets lists the environment variable names that carry passwords, so they are kept out of logs.

The allow list​

The device checks the body before anything starts, and so does the registry on submission. A service may use these keys and no others: image, command, entrypoint, environment, volumes, depends_on, healthcheck, restart, labels, networks, tmpfs, read_only, user, working_dir, cap_drop, security_opt, stop_grace_period, stop_signal, sysctls, shm_size, mem_limit, cpus, init.

The ones people trip over:

Not allowedInstead
buildA published image.
ports, exposeprimaryService and primaryPort.
Host bind mountsNamed volumes.
privileged, cap_add, devicesNothing. Bundles run unprivileged.
env_file, secrets, configsenvironment with ${cfg.…} and ${secret.…}.
network_mode, pid, ipcNothing. Services reach each other by name.
container_name, hostname, logging, deployLeave them out.

At the top level only services, volumes, networks and version are allowed.

Make it yours​

Start from the project's own docker-compose.yml. Delete what the allow list forbids, turn every password into a secretsSchema entry, turn the public URL into a domain setting, add a healthcheck to the database and depends_on to the app. Then try the body on your own device, see Test and submit.

Next: Test and submit.