Zeabur Template Knowledge Base
This skill provides comprehensive knowledge for creating, debugging, and publishing Zeabur templates. It combines reference documentation with battle-tested patterns from real template development.
External Documentation
For the latest schema and detailed docs, fetch from
https://raw.githubusercontent.com/zeabur/zeabur-template-doc/main/
:
| User Need | Document to Fetch | Path |
|---|
| Create a template from scratch | Step-by-step guide | |
| Convert docker-compose.yml | Migration guide | docs/DOCKER_COMPOSE_MIGRATION.md
|
| Look up YAML fields or built-in variables | Technical reference | |
| Naming, design patterns, best practices | Best practices | |
| Debug template errors | Troubleshooting | |
| Pre-deployment checklist | Checklist | |
| Quick all-in-one overview | Comprehensive prompt | |
The template YAML schema is also available at
https://schema.zeabur.app/template.json and the prebuilt service schema at
https://schema.zeabur.app/prebuilt.json.
Core Principles
0. All services MUST use PREBUILT_V2 with Docker images
Every service in a template MUST be with a Docker image as the source. Never use
,
, or
source — these are NOT supported in templates.
If the project does not have a published Docker image (on Docker Hub, GHCR, etc.), tell the user they need to build and publish a Docker image first before a template can be created. Do not attempt to work around this.
If the user asks you to build the image, follow this workflow:
- Clone the project repo and find its (usually at repo root)
- Study the Dockerfile to understand build stages — use the production stage (often named or )
- Study or
docker-compose.fullapp.yml
for the correct startup command, env vars, and volumes — this is the battle-tested production config
- Build for amd64 (Zeabur servers are amd64, local Macs are arm64):
bash
docker buildx build --platform linux/amd64 --target runner -t org/image:tag --push .
- After pushing, verify the Docker Hub repo is public:
bash
curl -s "https://hub.docker.com/v2/repositories/ORG/IMAGE/" | grep is_private
New repos under org accounts often default to private. If , the user must make it public on Docker Hub.
1. Never start from scratch
When asked to create a template, always look for existing configuration first:
- First check if a Docker image exists — search Docker Hub, GHCR (), or the project's CI/CD for published images. If none exists, stop and inform the user.
- Search for the project's , , or
- Look for Helm charts (, )
- Check the project's GitHub repo for any deployment YAML files
- Use these as the foundation to build the Zeabur template, not as a loose reference — they contain battle-tested environment variables, port mappings, volume mounts, and service dependencies
2. Iterate via runtime logs — never expect one-shot success
Even experienced humans cannot create a working template in one shot. The workflow is an iterative loop:
- Write/update the template YAML
- Deploy the template
- It will likely fail — check runtime logs to find the cause
- Fix the issue in the template
- Delete the project and redeploy from scratch
- Repeat until the template achieves one-click deployment success
This is the normal process, not a sign of failure. Do not try to get everything perfect before deploying — deploy early, read logs, and iterate.
3. Reuse from existing templates — never write common services from scratch
When your template needs a common service (PostgreSQL, Redis, MySQL, MongoDB, etc.), do not write the service definition yourself. Instead:
- Search for existing templates that already use that service:
bash
npx zeabur@latest template search postgres
- Find a template that includes the service you need
- Get the raw template YAML to see the exact service definition:
bash
npx zeabur@latest template get -c TEMPLATE_CODE --raw
- Copy the service definition directly from that template into yours
How to judge template trustworthiness:
- Many templates are created by regular users and may not work correctly
- Prefer templates with more deployments — higher deployment count = more battle-tested
- Prefer official templates over user-submitted ones — official templates are vetted by Zeabur team
CLI Commands for the Iteration Loop
Deploy a template:
bash
npx zeabur@latest template deploy -f YOUR_TEMPLATE.yaml
For non-interactive mode (automation):
bash
npx zeabur@latest template deploy -i=false \
-f YOUR_TEMPLATE.yaml \
--project-id PROJECT_ID \
--var PUBLIC_DOMAIN=myapp
List services (to get SERVICE_ID):
bash
npx zeabur@latest service list --project-id PROJECT_ID
Check runtime logs:
bash
npx zeabur@latest deployment log --service-id SERVICE_ID
Execute a command inside a running service (like
):
bash
npx zeabur@latest service exec --id SERVICE_ID -- SHELL_COMMAND
This is extremely useful for debugging — check file paths, env vars, test connectivity, inspect the filesystem, etc. Examples:
bash
npx zeabur@latest service exec --id SERVICE_ID -- ls /app
npx zeabur@latest service exec --id SERVICE_ID -- env | grep DATABASE
npx zeabur@latest service exec --id SERVICE_ID -- nc -z localhost 5432
Restart a service (useful to clear ImagePullBackOff or force re-pull):
bash
npx zeabur@latest service restart --id SERVICE_ID -i=false -y
Delete the project and start over:
bash
npx zeabur@latest project delete --id PROJECT_ID
DANGEROUS OPERATION — Before deleting a project, you MUST ask the user for explicit confirmation, clearly stating the Project ID, Name, and createdAt timestamp. Never delete without confirmation.
Publish a new template:
bash
npx zeabur@latest template create -f YOUR_TEMPLATE.yaml
This returns a template URL like
https://zeabur.com/templates/XXXXXX
with a template code.
Update an existing template:
bash
npx zeabur@latest template update -c TEMPLATE_CODE -f YOUR_TEMPLATE.yaml
Quick Reference: Template Skeleton
yaml
# yaml-language-server: $schema=https://schema.zeabur.app/template.json
apiVersion: zeabur.com/v1
kind: Template
metadata:
name: ServiceName
spec:
description: |
English description (1-3 sentences)
icon: https://raw.githubusercontent.com/zeabur/service-icons/main/marketplace/service.svg
coverImage: https://example.com/cover.webp
tags:
- Category
variables: []
readme: |
# Service Name
English documentation...
services:
- name: service-name
icon: https://raw.githubusercontent.com/zeabur/service-icons/main/marketplace/service.svg
template: PREBUILT_V2
spec:
source:
image: image:tag
command: # MUST be inside source, alongside image
- /bin/sh
- -c
- /opt/app/startup.sh
ports:
- id: web
port: 8080
type: HTTP
volumes:
- id: data
dir: /path/to/data
configs:
- path: /opt/app/startup.sh
permission: 493 # 0755
envsubst: false
template: |
#!/bin/sh
exec node server.js
env:
VAR_NAME:
default: value
expose: true
localization:
zh-TW:
description: ...
variables: []
readme: |
# ...
zh-CN:
description: ...
variables: []
readme: |
# ...
ja-JP:
description: ...
variables: []
readme: |
# ...
es-ES:
description: ...
variables: []
readme: |
# ...
id-ID:
description: ...
variables: []
readme: |
# ...
Quick Reference: Built-in Variables
| Variable | Purpose |
|---|
| Auto-generated secure password |
| Full public URL (e.g. ) |
| Domain only (e.g. ) |
| Internal hostname for inter-service communication |
| Port value by port ID (e.g. ) |
${PORT_FORWARDED_HOSTNAME}
| External hostname (for ) |
${[PORTID]_PORT_FORWARDED_PORT}
| External forwarded port (for ) |
The
pattern: for a port named
, it becomes
; for
, it becomes
.
Quick Reference: command Placement
IMPORTANT: MUST be inside , alongside . NOT at level.
yaml
# WRONG -- command at spec level (will be IGNORED, container uses default CMD)
spec:
source:
image: python:3.12-slim
command:
- /bin/sh
- -c
- /opt/app/start.sh
# CORRECT -- command inside source
spec:
source:
image: python:3.12-slim
command:
- /bin/sh
- -c
- /opt/app/start.sh
Note: The external docs may show
at
level. This is incorrect. Always place
inside
as confirmed by the JSON schema at
schema.zeabur.app/prebuilt.json
.
Quick Reference: YAML Gotchas
yaml
# RISKY -- @ at start of value is a YAML reserved indicator (may cause parse errors)
description: @BotFather token
# SAFE -- quote the value or avoid @ at start
description: "Token from @BotFather for Telegram bot"
description: Telegram bot token from BotFather
Quick Reference: Docker Image ENTRYPOINT
Some base images have ENTRYPOINT set, which conflicts with .
| Image | ENTRYPOINT | Problem |
|---|
ghcr.io/astral-sh/uv:python3.12-*
| | becomes args to , container shows and exits |
| none | Safe to use |
| none | Safe to use |
If using an image with ENTRYPOINT, switch to a plain base image (e.g.
python:3.12-slim-bookworm
) or one without ENTRYPOINT.
Quick Reference: Headless Services (no HTTP)
If a service does NOT listen on any HTTP port (502 Bad Gateway), see
skill for the fix.
Quick Reference: Critical Rules
yaml
# WRONG -- hardcoded password
POSTGRES_PASSWORD:
default: mypassword123
# CORRECT -- use ${PASSWORD}
POSTGRES_PASSWORD:
default: ${PASSWORD}
expose: true
# WRONG -- PUBLIC_DOMAIN gives incomplete URL
APP_URL:
default: https://${PUBLIC_DOMAIN}
# CORRECT -- ZEABUR_WEB_URL gives full URL
APP_URL:
default: ${ZEABUR_WEB_URL}
readonly: true
# WRONG -- other services can't reference without expose
POSTGRES_HOST:
default: ${CONTAINER_HOSTNAME}
# CORRECT -- expose + readonly for connection info
POSTGRES_HOST:
default: ${CONTAINER_HOSTNAME}
expose: true
readonly: true
# WRONG -- referencing variables without declaring dependency
- name: app
spec:
env:
DB: ${POSTGRES_HOST}
# CORRECT -- declare dependency first
- name: app
dependencies:
- postgresql
spec:
env:
DB: ${POSTGRES_HOST}
Domain Binding
Use
on the service that needs a public domain. It maps to a variable defined in
with
.
Single domain:
Multiple domains (different ports):
yaml
domainKey:
- port: web
variable: ENDPOINT_DOMAIN
- port: console
variable: ADMIN_ENDPOINT_DOMAIN
Common Database Configs
PostgreSQL
yaml
- name: postgresql
icon: https://raw.githubusercontent.com/zeabur/service-icons/main/marketplace/postgresql.svg
template: PREBUILT_V2
spec:
source:
image: postgres:16-alpine
ports:
- id: database
port: 5432
type: TCP
volumes:
- id: data
dir: /var/lib/postgresql/data
env:
POSTGRES_USER:
default: postgres
expose: true
POSTGRES_PASSWORD:
default: ${PASSWORD}
expose: true
POSTGRES_DB:
default: mydb
expose: true
POSTGRES_HOST:
default: ${CONTAINER_HOSTNAME}
expose: true
readonly: true
POSTGRES_PORT:
default: ${DATABASE_PORT}
expose: true
readonly: true
POSTGRES_CONNECTION_STRING:
default: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
expose: true
readonly: true
MySQL/MariaDB
yaml
- name: mariadb
icon: https://raw.githubusercontent.com/zeabur/service-icons/main/marketplace/mariadb.svg
template: PREBUILT_V2
spec:
source:
image: mariadb:10.6
ports:
- id: database
port: 3306
type: TCP
volumes:
- id: data
dir: /var/lib/mysql
env:
MYSQL_ROOT_PASSWORD:
default: ${PASSWORD}
expose: true
MYSQL_DATABASE:
default: mydb
expose: true
MYSQL_HOST:
default: ${CONTAINER_HOSTNAME}
expose: true
readonly: true
MYSQL_PORT:
default: ${DATABASE_PORT}
expose: true
readonly: true
Redis
yaml
- name: redis
icon: https://raw.githubusercontent.com/zeabur/service-icons/main/marketplace/redis.svg
template: PREBUILT_V2
spec:
source:
image: redis:7-alpine
ports:
- id: database
port: 6379
type: TCP
volumes:
- id: data
dir: /data
env:
REDIS_HOST:
default: ${CONTAINER_HOSTNAME}
expose: true
readonly: true
REDIS_PORT:
default: ${DATABASE_PORT}
expose: true
readonly: true
Standard Volume Paths
| Service | Path |
|---|
| PostgreSQL | |
| MySQL/MariaDB | |
| MongoDB | |
| Redis | |
| MinIO | |
Template Complexity Levels
Level 1 -- Single prebuilt service (e.g., Memos, Uptime-Kuma, LobeChat):
- Just one service with image, port, and optionally a volume
- Simplest pattern, no cross-service wiring needed
Level 2 -- App + database (e.g., Ghost+MySQL, Linkwarden+PostgreSQL):
- Database service exposes vars, app service references them
- App service uses to ensure DB starts first
Level 3 -- App + database + init/migrator (e.g., Teable, Open Mercato):
- First-run initialization or separate migrator service
- Requires wait-for-db pattern and init marker files
Level 4 -- Multi-service with multiple domains (e.g., Logto):
- Multiple ports on one service, each bound to a different domain variable
Level 5 -- Large-scale multi-service platform (e.g., Dify 12 services, Supabase 11 services):
- Reverse proxy as entry point: nginx (Dify) or Kong (Supabase) as the single domain-bound service
- Same image, different MODE: e.g., Dify runs , , from the same image
- Internal-only services: no public domain, communicate via
- Heavy use of : Nginx conf, SQL init scripts, Kong config — all injected via field
- PostgreSQL init SQL via configs: Mount to
/docker-entrypoint-initdb.d/
for auto execution
- Shared secrets: Use for all internal credentials
Writing name, description, readme, icon, and coverImage
Where to collect information (in priority order):
- The project's GitHub repo README
- The project's official website
- Other public sources (blog posts, documentation sites, etc.)
Key rules:
- Do NOT copy-paste the original README. Write the introduction for this project's Zeabur template, not for the project itself. The readme should:
- Briefly introduce what the project is
- Focus on how to use this template (deployment steps, configuration, domain binding)
- Include important caveats and troubleshooting tips specific to Zeabur deployment
- Always include licensing and attribution. If the original project has an MIT, Apache, or other license:
- Mention the license in the readme
- Link to the original repo
- Link to the official website if available
- This is legally required -- never skip it
- icon and coverImage: Find the project's logo from their GitHub repo or official website. Use a direct URL to an image (SVG, PNG, or WebP). Always verify the URL returns HTTP 200:
bash
curl -s -o /dev/null -w "%{http_code}" "URL"
Common pitfall: wrong branch name in raw.githubusercontent.com
URLs ( vs vs ).
Localization Requirements
6 languages required: en-US (in
), zh-TW, zh-CN, ja-JP, es-ES, id-ID.
Do NOT translate: ,
,
, URLs
Hard-Won Lessons (from real template challenges)
Wait for database readiness
Zeabur's
field only ensures services are
deployed, not that they're
ready to accept connections. A database container can take 5-15 seconds to initialize. Always add a wait loop before running migrations or init:
yaml
args:
- -c
- |
echo "Waiting for PostgreSQL..."
while ! nc -z ${POSTGRES_HOST} ${POSTGRES_PORT} 2>/dev/null; do sleep 2; done
echo "PostgreSQL is ready!"
echo "Waiting for Redis..."
while ! nc -z ${REDIS_HOST} ${REDIS_PORT} 2>/dev/null; do sleep 2; done
echo "Redis is ready!"
# ... then run migrations/init
Note:
(netcat) is available on most Alpine-based images. If not, use
or a node one-liner.
Study the project's own Dockerfile and docker-compose
Never guess startup commands. Instead:
- Read the project's to understand the build stages and the final /
- Read and especially
docker-compose.fullapp.yml
(or ) for the production startup command -- these often override the Dockerfile's CMD with init/migrate logic
- Check the app's scripts to understand what or actually runs
- The startup command in docker-compose is battle-tested -- copy it, don't reinvent it
Memory-heavy apps need lighter startup
Apps that spawn workers, schedulers, and the web server all in one process may OOM on Zeabur's default allocation. Signs of OOM:
- Container starts, runs for 1-2 minutes, then crashes with no error logs (just
BackOff: Back-off restarting failed container
)
- No application-level crash message -- the kernel kills the process silently
Solutions:
- Run the web server directly (e.g., or ) instead of a CLI wrapper that spawns workers + scheduler + server
- Disable non-essential background processes via env vars (e.g., )
- If workers are needed, consider splitting them into a separate service
First-run init with persistent marker
For apps that need first-time initialization (DB schema, seed data, admin users), use a marker file in a persistent volume:
yaml
args:
- -c
- |
if [ ! -f /app/storage/.initialized ]; then
echo "First run: initializing..."
run-init-command && touch /app/storage/.initialized
else
echo "Subsequent run: migrations only..."
run-migrate-command
fi
exec start-server-command
Key points:
- The marker file MUST be in a persistent volume, not (which is ephemeral)
- Use after init so the marker is only created if init succeeds
- Use for the final server process so it becomes PID 1 and receives signals properly
- Read init logs carefully for the actual default credentials -- don't assume from docs
Working directory matters
When overriding
/
, be aware of the Dockerfile's
. In monorepo apps, different commands need to run from different directories:
yaml
args:
- -c
- |
cd /app # root workspace for yarn workspace commands
yarn mercato init # CLI from root package.json
cd /app/apps/myapp # app directory for next start
exec next start -p 3000
ImagePullBackOff recovery
When a pod is stuck in
(e.g., after making a Docker Hub repo public), the Kubernetes backoff timer prevents immediate retries. Fix by restarting the service:
bash
npx zeabur@latest service restart --id SERVICE_ID -i=false -y
Disable unused monitoring agents
Many production images ship with New Relic, Datadog, or similar APM agents. These require license keys and consume memory. Add
or equivalent env vars to disable them cleanly. Check if the
script injects agents via
NODE_OPTIONS='-r newrelic'
-- these still run even if the agent errors out.
Verify all URLs before publishing
Before publishing a template, verify that ALL URLs return HTTP 200:
Common pitfall:
raw.githubusercontent.com
URLs with wrong branch name (
vs
vs
). Always check:
bash
curl -s -o /dev/null -w "%{http_code}" "https://raw.githubusercontent.com/..."