AAP Template Spec v2 — Authoring Guide
Overview
Templates define how applications are deployed on AI AdminPanel. Each template is a YAML file that describes the Docker Compose configuration, user-configurable variables, resource requirements, and routing rules.
This guide covers the v2 template specification. All curated templates in the catalog follow this exact format.
Template Structure
A template YAML file has three sections:
- Top-level envelope —
apiVersionandkind(alwaysv2andTemplate) - Metadata — name, description, category, tags, icon
- Spec — compose definition, variables, resource requirements, domain routing
Here is a complete minimal example:
apiVersion: v2
kind: Template
metadata:
name: my-app
displayName: My App
description: "A simple web application with persistent storage."
category: webapps
icon: ""
tags: [web]
spec:
domains:
- port: 3000
minResources:
cpuCores: 1
memoryMB: 256
compose: |
services:
app:
image: myapp/myapp:latest
expose:
- "3000"
volumes:
- app_data:/app/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
restart: unless-stopped
volumes:
app_data:
Full YAML Reference
Metadata Fields
| Field | Type | Required | Description |
|---|---|---|---|
apiVersion | string | yes | Must be "v2" |
kind | string | yes | Must be "Template" |
metadata.name | string | yes | URL-safe slug (e.g., "my-app"). Used as the unique identifier. |
metadata.displayName | string | yes | Human-readable name shown in the catalog UI |
metadata.description | string | yes | 2-3 sentence marketing description |
metadata.category | string | yes | One of: ai, webapps, databases, devtools |
metadata.icon | string | no | Inline SVG string, or empty for default icon |
metadata.tags | string[] | no | Search and filter tags (e.g., [web, database, llm]) |
metadata.trending | bool | no | Show in the trending section of the catalog |
metadata.comingSoon | bool | no | Show as coming soon (not deployable) |
Spec Fields
| Field | Type | Required | Description |
|---|---|---|---|
spec.compose | string | yes* | Docker Compose YAML (as a multiline string) |
spec.image | string | yes* | Docker image for single-container shorthand |
spec.domains | DomainSpec[] | recommended | Port-to-domain routing declarations |
spec.minResources | object | yes | {cpuCores: int, memoryMB: int} |
spec.securityProfile | string | no | "secure", "advanced", or "raw" (default: no restriction) |
spec.recommendedVersion | string | no | Last tested image tag (informational, shown in UI) |
spec.variables | Variable[] | no | User-configurable parameters |
spec.ai_managed | bool | no | Marks the service as AI-managed (shows AI badge in catalog) |
spec.gpu_required | bool | no | Indicates GPU hardware is required |
spec.recommended_vram_mb | int | no | Recommended GPU VRAM in megabytes |
*Either compose or image must be provided. If only image is set, the platform generates a minimal compose wrapper automatically.
Variable Fields
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Environment variable name (UPPER_SNAKE_CASE) |
displayName | string | yes | Label shown in the deployment form |
type | string | yes | "string", "integer", "boolean", "password", or "select" |
default | string | yes | Default value (use "" for empty) |
required | bool | no | Must be filled before deploy (default: false) |
description | string | no | Help text shown below the input field |
locked | bool | no | Read-only when security profile is secure |
hidden | bool | no | Only shown in Advanced mode (hidden from basic deploy form) |
options | SelectOption[] | no | For select type only: [{value: "x", label: "X Label"}] |
DomainSpec Fields
| Field | Type | Required | Description |
|---|---|---|---|
port | int | yes | Container port to expose (1-65535) |
protocol | string | no | "https" (default) or "http" |
System Variables
System variables are auto-resolved by the platform at deploy time. Use them in your compose YAML with ${AAP_*} syntax. You never define these in the variables section — they are built-in.
Auto-Generated Credentials
| Pattern | Generates | Length | Use Case |
|---|---|---|---|
${AAP_PASSWORD_<ID>} | Random alphanumeric password | 24 chars | Database passwords, admin passwords |
${AAP_SECRET_<ID>} | Random hex string | 64 chars | JWT secrets, API keys, encryption keys |
${AAP_BASE64_<ID>} | Base64-encoded random bytes | 44 chars | Credential encryption keys |
Consistency rule: The same <ID> resolves to the same value everywhere in one template. For example, ${AAP_PASSWORD_DB} used in both the app service and the database service will produce the same password.
IDs must be: UPPER_SNAKE_CASE — e.g., DB, ADMIN, JWT, SESSION, ROOT, CREDS
Domain Variables
These are resolved at deploy time based on the service name and panel configuration:
| Variable | Resolves To | Example |
|---|---|---|
${AAP_FQDN} | Fully qualified domain name | myapp.panel.example.com |
${AAP_URL} | Full URL with protocol | https://myapp.panel.example.com |
${AAP_SERVICE_NAME} | Service slug name | myapp |
Quick Reference: Which Variable to Use
| Need | Variable |
|---|---|
| Database password | ${AAP_PASSWORD_DB} |
| Admin password | ${AAP_PASSWORD_ADMIN} |
| JWT / session secret | ${AAP_SECRET_JWT} |
| Encryption key | ${AAP_SECRET_CREDS} or ${AAP_BASE64_CREDS} |
| Application URL (with https://) | ${AAP_URL} |
| Domain name only (no protocol) | ${AAP_FQDN} |
Domain Routing
Declare which port your service listens on:
spec:
domains:
- port: 3000
The platform automatically configures Traefik to route https://{service}.{panel-domain} to container:3000. Users never need to enter URLs or configure reverse proxy rules.
For multi-port services (rare), declare multiple domains:
spec:
domains:
- port: 3000 # Main web UI
- port: 8080 # API endpoint
Tip: You do not need to add Traefik labels or configure SSL certificates. The platform handles all routing, TLS termination, and certificate provisioning automatically.
Compose Requirements
When writing the spec.compose section, follow these rules:
Must Do
- Use
expose:instead ofports:— Traefik handles external access. Never bind to host ports. - Include a
healthcheck:— The platform uses this to detect when the service is ready. - Use named volumes for persistence — not bind mounts.
- Use
restart: unless-stoppedon all services. - Use system variables for URLs — never hardcode domains (
${AAP_URL},${AAP_FQDN}). - Use
${AAP_PASSWORD_*}/${AAP_SECRET_*}for credentials — never hardcode secrets.
Must Not
- Do not use
ports:— this will conflict with Traefik routing. - Do not hardcode URLs or domains — they change per deployment.
- Do not use bind mounts (
./data:/app/data) — use named volumes. - Do not include Traefik labels — the platform adds them automatically.
- Do not set
network_mode: host— this breaks container isolation.
Health Check Examples
HTTP health check (most common):
healthcheck: test: ["CMD-SHELL", "curl -sf http://localhost:3000/ || exit 1"] interval: 30s timeout: 10s retries: 3 start_period: 30s
PostgreSQL readiness check:
healthcheck: test: ["CMD-SHELL", "pg_isready -U myuser"] interval: 10s timeout: 5s retries: 5
TCP port check (when no HTTP endpoint exists):
healthcheck: test: ["CMD-SHELL", "nc -z localhost 6379 || exit 1"] interval: 10s timeout: 5s retries: 3
Security Profiles
The securityProfile field controls how the platform handles template variable overrides:
| Profile | Behavior |
|---|---|
secure | Default for curated templates. Variables marked locked: true cannot be changed by the user — they always use the default value. |
advanced | All variables are editable. May request specific Linux capabilities or device access. |
raw | No restrictions. Full Docker access. Use with caution. |
When authoring templates, prefer secure and lock any variables that could compromise security if changed (e.g., bind addresses, exec policies).
Variable Types in Detail
String
Free-text input. Validated against control character injection.
- name: APP_TITLE displayName: Application Title type: string default: "My Application" required: false
Integer
Numeric-only input. Only digits are accepted.
- name: WORKER_COUNT displayName: Worker Threads type: integer default: "4" required: false
Boolean
Toggle switch. Value must be exactly "true" or "false".
- name: ENABLE_REGISTRATION displayName: Allow User Registration type: boolean default: "true" required: false
Password
Masked input field. Validated against YAML injection. Good for API keys and tokens that users provide.
- name: OPENAI_API_KEY displayName: OpenAI API Key type: password default: "" required: false description: "Your OpenAI API key. Get one at https://platform.openai.com/api-keys"
Note: For auto-generated passwords (database credentials, admin passwords), use system variables (
${AAP_PASSWORD_*}) instead of a password variable. System variables are generated automatically — the user never sees or enters them.
Select
Dropdown with predefined options. The submitted value is validated against the options list.
- name: DEPLOYMENT_PROFILE
displayName: Deployment Profile
type: select
default: "solo"
required: true
description: "Resource allocation profile"
options:
- value: solo
label: "Solo Dev — 1 CPU, 512MB RAM"
- value: team
label: "Team — 2 CPU, 2GB RAM"
- value: production
label: "Production — 4 CPU, 4GB RAM"
Examples
Minimal: Single-Container Service
The simplest possible template — one container, one volume, one port.
apiVersion: v2
kind: Template
metadata:
name: uptime-kuma
displayName: Uptime Kuma
description: "Self-hosted uptime monitoring tool with a beautiful status page."
category: devtools
icon: ""
tags: [monitoring, uptime, status-page]
spec:
domains:
- port: 3001
minResources:
cpuCores: 1
memoryMB: 128
recommendedVersion: "1.23.16"
variables:
- name: VERSION
displayName: Uptime Kuma Version
type: string
default: "latest"
required: false
hidden: true
compose: |
services:
uptime-kuma:
image: louislam/uptime-kuma:${VERSION}
expose:
- "3001"
volumes:
- uptime_kuma_data:/app/data
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:3001/ || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
volumes:
uptime_kuma_data:
Multi-Service: App + Database
A web application with a PostgreSQL database. Uses system variables for credential sharing between services.
apiVersion: v2
kind: Template
metadata:
name: my-webapp
displayName: My Web App
description: "Web app with PostgreSQL database. Zero-config deployment with auto-generated credentials."
category: webapps
icon: ""
tags: [web, database]
spec:
domains:
- port: 3000
minResources:
cpuCores: 1
memoryMB: 512
variables:
- name: VERSION
displayName: App Version
type: string
default: "latest"
required: false
hidden: true
compose: |
services:
app:
image: myapp/webapp:${VERSION}
environment:
DATABASE_URL: postgresql://app:${AAP_PASSWORD_DB}@db:5432/myapp
APP_URL: ${AAP_URL}
SESSION_SECRET: ${AAP_SECRET_SESSION}
expose:
- "3000"
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:3000/health || exit 1"]
interval: 30s
timeout: 10s
retries: 3
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: ${AAP_PASSWORD_DB}
POSTGRES_DB: myapp
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
db_data:
Note how ${AAP_PASSWORD_DB} appears in both the app and db services. The platform generates the password once and substitutes the same value in both places.
AI Service with User-Provided API Keys
An AI application where the user provides their own API key during deployment.
apiVersion: v2
kind: Template
metadata:
name: ai-chat
displayName: AI Chat
description: "AI chat application with configurable LLM provider. Bring your own API key."
category: ai
icon: ""
tags: [ai, chat, llm]
spec:
ai_managed: true
domains:
- port: 3000
minResources:
cpuCores: 1
memoryMB: 512
variables:
- name: OPENAI_API_KEY
displayName: OpenAI API Key
type: password
default: ""
required: false
description: "Your OpenAI API key. Get one at https://platform.openai.com/api-keys"
- name: MODEL
displayName: Default Model
type: select
default: "gpt-4o"
required: true
options:
- value: gpt-4o
label: "GPT-4o (recommended)"
- value: gpt-4o-mini
label: "GPT-4o Mini (faster, cheaper)"
compose: |
services:
chat:
image: ai-chat/app:latest
environment:
OPENAI_API_KEY: ${OPENAI_API_KEY}
DEFAULT_MODEL: ${MODEL}
APP_URL: ${AAP_URL}
JWT_SECRET: ${AAP_SECRET_JWT}
expose:
- "3000"
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:3000/health || exit 1"]
interval: 30s
timeout: 10s
retries: 3
restart: unless-stopped
Using the Image Shorthand
For the simplest single-container services, you can use spec.image instead of spec.compose. The platform generates a minimal compose wrapper automatically.
apiVersion: v2
kind: Template
metadata:
name: simple-app
displayName: Simple App
description: "A stateless web application."
category: webapps
icon: ""
tags: [web]
spec:
image: myapp/simple:latest
domains:
- port: 8080
minResources:
cpuCores: 1
memoryMB: 128
This is equivalent to writing a full compose spec with a single service. Use the compose field instead when you need volumes, health checks, environment variables, or multiple services.
Input Validation and Security
The platform performs strict validation on all user-provided variable values before substituting them into compose YAML:
- String values are rejected if they contain control characters or newlines
- Integer values must match
^\d+$ - Boolean values must be exactly
"true"or"false" - Password values are rejected if they contain control characters or YAML structure characters (
:,#,-) - Select values are validated against the declared options list
This prevents YAML injection attacks where a malicious variable value could alter the compose structure.
Submitting to the Catalog
To contribute a template to the curated catalog:
- Fork the repository
- Create your template YAML in
internal/templates/catalog/{category}/ - Run
go test ./internal/templates/...to validate the template loads and passes all checks - Submit a pull request
All templates in the catalog are embedded into the panel binary at build time and seeded into the database on startup.