This repository is a comprehensive example of how to package an app for the Appbox platform. It uses Uptime Kuma (a self-hosted monitoring tool) as the base, demonstrating every configuration option, the entrypoint lifecycle, and best practices.
Use this as a template and reference when creating your own Appbox app.
- Packaging Requirements
- Repository Structure
- appbox.yml Schema Reference
- app — Store Metadata
- image — Docker Image
- container — Runtime Configuration
- networking — Domain and SSL
- behavior — Platform Behavior
- install — Install Descriptions
- ports — Port Configuration
- volumes — Persistent Bind Mounts
- shared_data — Shared File System Access
- env — Environment Variables
- custom_fields — User Input
- advanced — Advanced Settings
- Custom Field Types
- Custom Field Validation Rules
- Template Variables
- Available Categories
- Port Types Explained
- Volume Bind Types
- Shared File System
- Multi-Service Apps (s6-overlay)
- Entrypoint Lifecycle
- API Callback
- Password Change Script (moduser.sh)
- Embedding Databases
- Security Checklist
- Step-by-Step: Creating a New App
- Submitting Your App
-
Single container — Apps must be fully self-contained in one Docker container with no external dependencies. No separate database containers, no docker-compose, no sidecar services. If the app needs a database, it must be embedded (e.g. SQLite) or bundled inside the same container.
-
Init system — If your app needs multiple services running in a single container (e.g. app server + database + background worker), you MUST use s6-overlay as the init/process supervisor. It handles process lifecycle, restarts, and signal forwarding correctly for multi-service containers. For single-process apps, a plain bash entrypoint with
execis sufficient. When building on an existing upstream image (as this example does with Uptime Kuma), any init approach is acceptable — the priority is reusing well-maintained official images over custom builds. -
Entrypoint — Regardless of init system, the app must follow the Appbox entrypoint lifecycle: first-run setup, upgrade detection, platform callback, then exec the main process. See Entrypoint Lifecycle.
-
Secure by default — Apps should be configured securely out of the box. Use strong password validation, disable public registration, and bind to appropriate interfaces.
-
User namespaces (userns) — All Appbox containers run with user namespaces enabled for security. UID 0 (root) inside the container is mapped to an unprivileged UID on the host. Your app's main process must run as UID 1000 inside the container. All files and directories the app touches must be owned by 1000:1000 inside the container. The entrypoint runs as root (UID 0 inside the container) only for
/etc/resolv.confand/etc/hostssetup, then drops to UID 1000 viagosu. -
UID 1000 everywhere — This is critical. Whether you're creating directories in the Dockerfile, chowning data paths in the entrypoint, or running the main process, always use UID/GID 1000. If the upstream image uses a different UID, add
chown -R 1000:1000 /path/to/datain your Dockerfile or entrypoint. -
Password change script — All apps must include a
moduser.shscript at the container root (/moduser.sh). This allows users to reset the default user's password if they get locked out. The script accepts one argument: the new password. It is run viadocker exec <container> /moduser.sh <new_password>.
example-app/
├── AGENTS.md # Instructions for AI coding agents
├── appbox.yml # App configuration (metadata, ports, volumes, env, fields, etc.)
├── Dockerfile # Container image definition
├── entrypoint.sh # Lifecycle script (setup, upgrade, callback)
├── icon.png # App icon (512x512 PNG) for the store listing
├── moduser.sh # Password change script (required for all apps)
├── README.md # This documentation
└── TESTING.md # Testing framework for pre-submission validation
| File | Purpose |
|---|---|
AGENTS.md |
Instructions for AI coding agents working on Appbox apps. Covers constraints, patterns, and the human review policy. |
appbox.yml |
Single source of truth for all app configuration. The platform reads this to create database records for the app. |
Dockerfile |
Wraps the upstream image with the Appbox entrypoint, installing required tools (bash, curl, gosu). |
entrypoint.sh |
Handles first-run setup (creating admin user), upgrade detection, platform callback, and privilege dropping. |
icon.png |
512x512 PNG icon displayed in the app store. Uploaded to the platform's image server during registration. |
moduser.sh |
Password change script. Allows users to change the default user's password if locked out. Required for all apps. |
TESTING.md |
Testing framework with checklists for all test scenarios. Complete before submitting. |
The appbox.yml file is organized into sections. Only app and image are required; all other sections have sensible defaults and can be omitted.
Controls how your app appears in the Appbox app store. Maps to the apps database table.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
display_name |
string | Yes | — | App name shown in the store. Must be unique. |
publisher |
string | Yes | — | Developer or organization name. |
description |
string | Yes | — | Full description (Markdown supported). Shown on the app detail page. |
short_description |
string | No | Truncated description |
Brief summary for store cards. |
icon |
string | No | default-app.png |
Path to icon file relative to repo root. Must be 512x512 PNG. |
categories |
list | No | [] |
Category names for store filtering. See Available Categories. |
devsite |
string | No | null |
Developer website URL. Shown as "Visit developer" link. |
source_repo |
string | No | null |
Source code repository URL. |
Defines the Docker image to pull. Maps to apps and app_versions tables.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
name |
string | Yes | — | Docker image name (e.g. louislam/uptime-kuma) |
version |
string | Yes | — | Version string shown to users |
tag |
string | No | Same as version |
Docker tag to pull |
Docker container settings. All optional. Maps to apps table.
| Field | Type | Default | Description |
|---|---|---|---|
cmd |
string | null |
Override container CMD |
user |
string | null |
Container user (e.g. "1000", "user:group") |
group_add |
string | null |
Additional groups, comma-separated |
memory |
integer | 0 |
Memory limit in GB (0 = unlimited) |
memory_swap |
integer | 0 |
Memory + swap limit in GB |
memory_reservation |
integer | 0 |
Soft memory limit in GB |
cpus |
float | 0 |
CPU limit (0 = unlimited, 0.5 = half core) |
init |
boolean | false |
Use tini init process |
cap_add |
string | null |
Linux capabilities to add (e.g. "NET_ADMIN,SYS_PTRACE") |
cap_drop |
string | null |
Capabilities to drop (e.g. "ALL") |
shm_size |
integer | null |
Shared memory size in bytes |
pids_limit |
integer | null |
Max process count |
Controls domain assignment and reverse proxy behavior. Maps to apps table.
| Field | Type | Default | Description |
|---|---|---|---|
subdomain |
string | — | Default subdomain prefix (required for web apps) |
is_web_app |
boolean | false |
Accessible via HTTP reverse proxy |
requires_domain |
boolean | false |
Requires domain assignment during install |
ssl |
boolean | false |
Provision SSL certificate |
multiple_domains |
boolean | false |
Allow multiple domains |
tcp_passthrough |
boolean | false |
Forward TCP directly (no HTTP proxy) |
Controls the app's lifecycle on the platform. Maps to apps table.
| Field | Type | Default | Description |
|---|---|---|---|
app_slots |
integer | 1 |
Resource slots consumed (required) |
expect_callback |
boolean | false |
Wait for container callback before marking installed |
callback_requires_auth |
boolean | false |
Callback must include CALLBACK_TOKEN |
restart_text |
string | null |
Warning shown before restart |
can_update |
boolean | true |
Allow user-initiated updates |
update_text |
string | null |
Warning shown before update |
allow_downgrade |
boolean | false |
Allow version downgrades |
ssl_restart |
boolean | false |
Auto-restart every 15 days for SSL renewal |
Text shown during installation. Maps to apps table.
| Field | Type | Default | Description |
|---|---|---|---|
pre_description |
string | null |
Shown above the custom fields form |
post_description |
string | null |
Shown after installation completes |
custom_description |
string | null |
Replaces default install description |
Defines ports to be publicly exposed on the host. See Port Types Explained for details.
Web apps: If your app is a web app (
is_web_app: true) and only exposes a web UI, you do not need any ports here. The platform reverse-proxies HTTP traffic via nginx automatically. Instead, setVIRTUAL_PORTin theenvsection to the HTTP port your app listens on (default: 80). Only define ports here for non-HTTP traffic (game servers, custom protocols, etc.). Defining your HTTP port here would expose it publicly, bypassing the reverse proxy and SSL.
ports:
tcp:
range: null # Fixed internal port(s), random external
dynamic: 0 # Count of random ports (internal = external)
udp:
range: null
dynamic: 0
combined: # Both TCP and UDP on same port
range: null
dynamic: 0Maps to apps table: TCPPortRange, UDPPortRange, CombinedPortRange, TCPDynamicPorts, UDPDynamicPorts, CombinedDynamicPorts.
Directories persisted across restarts and upgrades. Maps to appbinds table.
| Field | Type | Required | Description |
|---|---|---|---|
source |
string | Yes | Host directory name (relative to /apps/<domain>/) |
destination |
string | Yes | Mount point inside the container |
permissions |
string | Yes | rw (read-write) or ro (read-only) |
uid |
integer | Yes | Owner UID inside the container. Always 1000 (see note below) |
UID explained: The
uidfield should always be1000, matching the UID your app runs as inside the container. The platform handles host-side UID remapping via user namespaces automatically — you do not need to worry about host UIDs.
Mounts the user's shared home directory into the container so the app can access data from other installed apps. Maps to appbinds table. See Shared File System for full details.
| Field | Type | Required | Description |
|---|---|---|---|
source |
string | Yes | Absolute host path using template variables (e.g. /cylostore/%CYLO.DISK_NAME%/%CYLO.ID%/home/apps/) |
destination |
string | Yes | Mount point inside the container (e.g. /APPBOX_DATA) |
permissions |
string | Yes | rw (read-write) or ro (read-only) |
uid |
integer | Yes | Owner UID inside the container. Always 1000 |
shared_data:
- source: "/cylostore/%CYLO.DISK_NAME%/%CYLO.ID%/home/apps/"
destination: "/APPBOX_DATA"
permissions: "rw"
uid: 1000Variables injected into the container. Maps to appenvironmentvars table.
| Field | Type | Required | Description |
|---|---|---|---|
key |
string | Yes | Environment variable name |
value |
string | Yes | Value (supports template variables) |
template_type |
string | Yes | How to resolve the value: none, password, complexPassword, hidden, instance |
The platform also auto-injects: INSTANCE_ID, VIRTUAL_HOST, CALLBACK_TOKEN.
VIRTUAL_PORT: Required for web apps (is_web_app: true) not listening on port 80. Set this to the HTTP port your app listens on inside the container. The platform's nginx reverse proxy uses it to route traffic. Must be plain HTTP — the platform handles SSL termination. If your app listens on port 80, this is not needed.
Form fields shown during installation. Maps to customfields table. See Custom Field Types and Custom Field Validation Rules.
| Field | Type | Required | Description |
|---|---|---|---|
label |
string | Yes | Display label |
type |
string | Yes | Input type (see below) |
width |
integer | No | Grid width 1–12 (default: 12) |
default_value |
string | No | Pre-filled value |
template_type |
string | No | Template resolution: none, password, complexPassword, hidden |
validate |
list | No | Validation rules (see below) |
params |
object | No | Type-specific parameters |
Rarely needed container configuration.
| Section | Table | Fields per entry |
|---|---|---|
devices |
appdevices |
host_path, container_path, cgroup_permissions |
ulimits |
appulimits |
name, soft, hard |
sysctls |
appsysctl |
name, value |
chains |
appchains |
chained_to (app name or ID) |
| Type | Rendering | Value | Use case |
|---|---|---|---|
dynamicText |
Standard text input | User-entered string | Usernames, site names, general text |
alphaNumeric |
Text input (alphanumeric only) | Letters and numbers | Identifiers, slugs |
password |
Masked password input | User-entered or auto-generated (%RAND.N%) |
Passwords |
complexPassword |
Masked input with complexity enforcement | Must include upper, lower, number, special char | Admin passwords |
number |
Numeric input | Integer or float | Port numbers, limits, sizes |
email |
Email input with validation | Valid email address | Admin email, notification address |
date |
Date input | Date string | Expiry dates, schedules |
switch |
Toggle switch | "1" (on) or "0" (off) |
Feature toggles, boolean settings |
selector |
Dropdown menu | Selected option value | Theme selection, mode selection |
staticText |
Read-only display | Shows default_value |
Information, URLs, generated values |
externalURL |
Read-only clickable link | Set by container via API callback | URLs only known after the app starts (e.g. admin panel, API endpoint) |
hidden |
Not rendered | Auto-generated (e.g. %RAND.32%) |
Internal tokens, secrets |
spacer |
Visual spacing | No value | Layout control |
params:
menuItems:
value1: "Display Label 1"
value2: "Display Label 2"params:
menuItems:
"1": "Enabled"
"0": "Disabled"| Rule | Description |
|---|---|
required |
Field must not be empty |
alphanumeric |
Only letters and numbers allowed |
notOnlyAlpha |
Must contain at least one non-letter character |
complexPassword |
Must include uppercase, lowercase, number, and special character |
email |
Must be a valid email address |
date |
Must be a valid date |
| Rule | Example | Description |
|---|---|---|
minLength |
{ minLength: 3 } |
Minimum character count |
maxLength |
{ maxLength: 50 } |
Maximum character count |
matches |
{ matches: "^[a-z]+$" } |
Must match regex (set params.regex and params.errorText too) |
validate:
- required
- alphanumeric
- { minLength: 3 }
- { maxLength: 32 }Template variables are resolved at install time by the platform's TemplateService. They can be used in env values, custom_fields default values, and volumes source paths.
| Pattern | Description | Example |
|---|---|---|
%TABLE.FIELD% |
Single value lookup | %INSTANCE.ID% |
%TABLE|N.FIELD% |
Array index (0-based) | %PORTS|0.EXTERNAL% |
%RAND.N% |
Random hex string of N chars | %RAND.32% |
%PASSWORD% |
First password field's value | %PASSWORD% |
%MATH.V1.OP.V2% |
Math: +, -, *, / |
%MATH.100.+.50% → 150 |
Available when template_type is instance. Fields from the appinstances table plus derived values.
| Field | Description |
|---|---|
ID |
Instance ID |
APP_ID |
App ID |
VERSION |
Installed version |
CALLBACK_TOKEN |
Auth token for callbacks |
The user's Cylo (account) record.
| Field | Description |
|---|---|
ID |
Cylo ID |
CYLONAME |
Cylo name |
SERVER_IP |
Server IP address |
The physical server hosting this instance.
| Field | Description |
|---|---|
ID |
Server ID |
DISPLAY_NAME |
Server display name |
IP |
Public IP address |
The domain assigned to this app instance.
| Field | Description |
|---|---|
ID |
Domain ID |
DOMAIN |
Domain name |
INSTANCE_ID |
Linked instance ID |
The user who owns this instance.
| Field | Description |
|---|---|
ID |
User ID |
FIRSTNAME |
First name |
LASTNAME |
Last name |
EMAIL |
Email address |
The app definition.
| Field | Description |
|---|---|
ID |
App ID |
DISPLAY_NAME |
App name |
PUBLISHER |
Publisher name |
DESCRIPTION |
Full description |
SHORT_DESCRIPTION |
Short description |
Port allocations (0-indexed). Available after ports are assigned.
| Field | Description |
|---|---|
INTERNAL |
Container port |
EXTERNAL |
Host port (the one users connect to) |
TYPE |
Protocol: tcp, udp, or combined |
QTY |
Port count (for dynamic allocations) |
Existing categories you can use in the app.categories list:
- Blogs
- CMS
- Communication
- Databases
- Documentation
- File Manager
- Games
- Marketing
- Media
- Notes
- Operating System
- Privacy
- Programming
- Projects
- SEO Utilities
- Stacks
- Streaming
- Sync
- Torrent Clients
- VPS
- Webserver
- Windows
If none of these fit your app, you can suggest a new category in your submission. All apps are automatically added to "All Apps" — do not include it.
Reminder: Web-only apps do not need ports defined here — use
VIRTUAL_PORTin theenvsection instead. Theportssection is for publicly exposed non-HTTP traffic.
The platform supports four port allocation patterns:
Your app listens on a known port. The platform assigns a random available host port.
Container port 25575 ←→ Host port 14523 (randomly assigned)
ports:
tcp:
range: "25575"The user accesses the app via the external port. Use %PORTS|0.EXTERNAL% in env vars to pass the external port to the app if needed.
Multiple known ports, each mapped to a random host port.
Container port 8000 ←→ Host port 14523
Container port 8001 ←→ Host port 14524
...
Container port 8010 ←→ Host port 14534
ports:
tcp:
range: "8000-8010"The platform allocates N consecutive ports where internal and external are the same.
Port 14523 ←→ Port 14523 (same inside and outside)
Port 14524 ←→ Port 14524
Port 14525 ←→ Port 14525
ports:
tcp:
dynamic: 3Use when your app can be configured to listen on any port.
Different protocols can be configured independently:
ports:
tcp:
range: "25575" # RCON on TCP
dynamic: 0
udp:
range: "27015" # Game traffic on UDP
dynamic: 0
combined:
range: "5060" # SIP on both TCP and UDP
dynamic: 0The range field accepts:
| Format | Example | Ports |
|---|---|---|
| Single port | "25575" |
25575 |
| Range | "8080-8090" |
8080, 8081, ..., 8090 |
| Multiple | "80,443" |
80, 443 |
| Mixed | "80,443,8080-8090" |
80, 443, 8080–8090 |
All volume bind mounts store data under the user's app directory at /apps/<app-domain>/<source>. The platform handles the host-side directory creation and ownership automatically.
File ownership inside the container: All data directories must be owned by UID 1000:GID 1000 inside the container. If the upstream image creates data directories owned by a different user, add a chown -R 1000:1000 /path step in your Dockerfile or entrypoint.
Always set uid: 1000 in your volume definitions. The platform handles host-side UID remapping via user namespaces automatically.
Volumes defined in appbox.yml are stored in the shared home area:
/cylostore/<disk>/<cylo_id>/home/apps/<app-domain>/<source>/
This means other apps on the same account can access your app's data if they have the shared file system mounted (see Shared File System below). This is by design — it enables cross-app workflows like torrent clients sharing downloads with media players.
Apps on the Appbox platform can access each other's data through a shared file system. This enables powerful cross-app workflows — for example, a torrent client downloads files that a media player (Plex, Jellyfin) can immediately access, or an FTP server exposes all app data for remote access.
Every app's volumes (the home storage) are stored on the host at:
/cylostore/<disk>/<cylo_id>/home/apps/
├── plex.user-domain.com/
│ └── config/
├── rtorrent.user-domain.com/
│ └── downloads/
├── jellyfin.user-domain.com/
│ ├── config/
│ └── cache/
├── openclaw.user-domain.com/
│ └── data/
└── ...
When an app needs access to other apps' data, it mounts this home/apps/ tree (or the parent home/ directory) into the container. Inside the container, the app sees:
/APPBOX_DATA/
├── plex.user-domain.com/
│ └── config/
├── rtorrent.user-domain.com/
│ └── downloads/
├── jellyfin.user-domain.com/
│ ├── config/
│ └── cache/
└── ...
/APPBOX_DATAis shared-access space, not your app's primary storage location.- Do not store your app's own database, config, or internal state directly under
/APPBOX_DATA. - Keep app state in your normal persistent app volumes from the
volumessection. /APPBOX_DATA/appsis for accessing data from other apps, not for your app's own state./APPBOX_DATA/storageis user general storage and can be used for user-managed files when needed.- Assume
/APPBOX_DATAmay be owned by nobody inside the container; design your app around its own writable volume paths.
| App type | Why it needs shared access | Example |
|---|---|---|
| Media players | Read downloads/media from torrent clients | Plex, Jellyfin, Emby reading from /APPBOX_DATA/<torrent-app>/downloads/ |
| File managers | Browse and manage all app data | File Browser, SFTPGo exposing /APPBOX_DATA/ |
| FTP servers | Remote access to app files | Pure-FTPd serving /APPBOX_DATA/ |
| AI assistants | Read/write user files across apps | OpenClaw accessing documents, media, configs |
| OS/VPS apps | Full access to user's app ecosystem | Ubuntu Desktop, Debian browsing all data |
| Sync tools | Sync data between apps or to external services | Nextcloud, OwnCloud syncing from /APPBOX_DATA/ |
Use the shared_data section in appbox.yml to mount the shared file system. The source uses template variables that the platform resolves at install time:
shared_data:
- source: "/cylostore/%CYLO.DISK_NAME%/%CYLO.ID%/home/apps/"
destination: "/APPBOX_DATA"
permissions: "rw"
uid: 1000The source path typically points to:
| Path | Contents |
|---|---|
/cylostore/%CYLO.DISK_NAME%/%CYLO.ID%/home/apps/ |
All apps' shared volumes (most common) |
/cylostore/%CYLO.DISK_NAME%/%CYLO.ID%/home/ |
The full home directory |
The destination is where the shared data appears inside the container. By convention, /APPBOX_DATA is used, but you can choose any path that suits your app (e.g. /media, /storage, /home/user/data).
Set permissions to ro if your app only needs to read other apps' data. Use rw if it also needs to write (e.g. a file manager).
This example (Uptime Kuma) is a single-process app. For apps requiring multiple services in one container (e.g. web app + PostgreSQL + Redis), use s6-overlay as the process supervisor. If your base image is from LinuxServer.io (LSIO) or ImageGenius, s6-overlay is already included.
entrypoint.sh (one-time setup)
└── exec /init (s6-overlay)
├── init-adduser (oneshot)
├── init-config-* (oneshot)
├── svc-postgres (longrun)
├── svc-redis (longrun)
├── svc-server (longrun)
└── svc-microservices (longrun)
| Aspect | Single-process | Multi-service (s6-overlay) |
|---|---|---|
| CMD | ["node", "server.js"] |
["/init"] |
| Process supervision | None (app is PID 1) | s6 supervises all services |
| Crash recovery | Container restarts | s6 restarts the crashed service |
| Graceful shutdown | Docker SIGTERM to PID 1 | s6 propagates SIGTERM to all services |
| User switching | exec gosu 1000:1000 "$@" |
Each service run script uses gosu or s6-setuidgid |
| Callback timing | Before exec (synchronous) |
Background subshell after exec /init (async) |
Critical: s6-rc starts services in parallel with init scripts unless you declare explicit dependencies. Without them, services start before init scripts create users, fix permissions, or set up directories.
Add dependency files to each service's dependencies.d/ directory in the Dockerfile:
RUN mkdir -p /etc/s6-overlay/s6-rc.d/svc-myapp/dependencies.d && \
touch /etc/s6-overlay/s6-rc.d/svc-myapp/dependencies.d/legacy-cont-init && \
touch /etc/s6-overlay/s6-rc.d/svc-myapp/dependencies.d/init-config-myappTo inspect the live dependency graph:
# List all services in the compiled database
s6-rc-db -c /run/s6/db list all
# Check what a service depends on
s6-rc-db -c /run/s6/db dependencies svc-myappLSIO init scripts may run expensive operations on every boot (e.g. recursively chowning thousands of immutable image-layer files). Override them by copying a replacement script:
COPY init-config-myapp /etc/s6-overlay/s6-rc.d/init-config-myapp/run
RUN chmod +x /etc/s6-overlay/s6-rc.d/init-config-myapp/runThe entrypoint performs one-time setup (database initialization, directory creation) then exec "$@" to hand off to /init. Since /init takes over as PID 1, the Appbox lifecycle (admin creation, callback) runs in a background subshell:
#!/bin/bash
set -e
# ... one-time setup (initdb, migrations, etc.) ...
if [[ ! -f /etc/app_configured ]]; then
touch /etc/app_configured
(
set +e # prevent set -e from killing subshell on timeout
wait_for_http "http://localhost:8080/api/health" 300 2
# ... admin creation, callback ...
) &
fi
exec "$@" # becomes: exec /initWarning: set -e propagates into ( ... ) & subshells. If wait_for_http times out (returns 1), set -e kills the subshell before callback_installed runs, leaving the app stuck in "installing" state forever. Always use set +e at the top of the subshell.
The entrypoint script detects three container states based on two signals:
| Signal | File | Persisted? | Survives restart? | Survives upgrade? |
|---|---|---|---|---|
| App data | /app/data/kuma.db (in volume) |
Yes | Yes | Yes |
| Config flag | /etc/app_configured (container fs) |
No | Yes | No |
┌──────────────────────────┐
│ Container starts │
└────────────┬─────────────┘
│
┌────────────▼─────────────┐
│ /etc/app_configured │
│ exists? │
└────┬──────────────┬──────┘
│ No │ Yes
│ │
┌──────────▼──────────┐ │
│ Touch │ │
│ /etc/app_configured │ │
└──────────┬──────────┘ │
│ │
┌──────────▼──────────┐ │
│ Persisted data │ │
│ exists? │ │
└───┬────────────┬────┘ │
│ No │ Yes │
│ │ │
┌──────────▼──────┐ ┌──▼─────┐ │
│ FRESH INSTALL │ │UPGRADE │ │
│ Start app │ │Skip │ │
│ Create admin │ │user │ │
│ Stop app │ │setup │ │
└──────────┬──────┘ └──┬─────┘ │
│ │ │
┌───▼────────────▼───┐ │
│ API Callback │ │
│ (retry loop) │ │
└────────┬───────────┘ │
│ │
┌────────▼────────────────▼──┐
│ exec gosu 1000:1000 "$@" │
│ (run app as PID 1) │
└────────────────────────────┘
- Mark container as configured (
/etc/app_configured) - App started in background
- Wait for app to be ready (poll HTTP)
- Create admin user via app's setup API
- Stop the background app
- Call platform API callback
- Start app with
execas PID 1
- Mark container as configured
- Skip user creation (data exists)
- Optional: run migration steps
- Call platform API callback
- Start app with
execas PID 1
- Skip all setup (both signals exist)
- Start app with
execas PID 1
When behavior.expect_callback is true, the platform waits for the container to signal that setup is complete before showing the app as "installed" to the user.
Endpoint:
POST https://api.cylo.net/v1/apps/installed/${INSTANCE_ID}
Headers:
Accept: application/json
Content-Type: application/json
If behavior.callback_requires_auth is true, also include:
Authorization: Bearer ${CALLBACK_TOKEN}
Behavior:
INSTANCE_IDis injected as an environment variable- The script retries every 5 seconds until HTTP 200
- The callback is idempotent (safe to call multiple times)
- Without the callback, the user sees the app as "installing" indefinitely
Fields of type externalURL render as clickable links on the installed app page. There are two approaches:
If the URL follows a known pattern (e.g. https://<domain>/), use a template variable in default_value. The platform resolves it at install time — no callback payload needed.
custom_fields:
LOGIN_URL:
label: "Login URL"
type: externalURL
width: 12
default_value: "https://%DOMAIN.DOMAIN%/"
template_type: instance
validate: []
params: {}If the URL depends on values only known after the app starts (dynamic ports, generated paths), set it via the API callback.
Defining the field in appbox.yml:
custom_fields:
ADMIN_PANEL:
label: "Admin Panel"
type: externalURL
width: 12
default_value: ""
template_type: none
validate: []
params: {}Setting the value in entrypoint.sh:
ADMIN_URL="https://${DOMAIN}:${ADMIN_PORT}/admin"
curl -s -o /dev/null -w "%{http_code}" \
-X POST "https://api.cylo.net/v1/apps/installed/${INSTANCE_ID}" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d "{\"custom_fields\": [{\"key\": \"ADMIN_PANEL\", \"value\": \"${ADMIN_URL}\"}]}"Rules:
keymust match the custom field name defined inappbox.yml- Only fields with type
externalURLcan be set this way — all other types are ignored - If the field already has a value (e.g. from a previous install), it is updated
- If you need to update other custom field types via callback, please contact us via support ticket
All Appbox apps must include a /moduser.sh script inside the container. This provides a way for users to change the default user's password if they get locked out.
Interface:
/moduser.sh <new_password>Requirements:
- Located at
/moduser.shin the container root - Accepts exactly 1 argument: the new password
- Overwrites the existing password unconditionally (this is a recovery tool)
- Exits non-zero on failure
- Does not need to produce output on success (but may)
How it is run:
docker exec <container_name> /moduser.sh "new-password"Implementation approaches (choose what fits your app):
| Approach | When to use |
|---|---|
| App CLI tool | Preferred if the app provides a password-reset command |
| Direct database update | App uses SQLite/embedded DB with a known schema |
| Config file rewrite | App reads credentials from a config file |
See moduser.sh in this repository for a working example using Uptime Kuma's built-in npm run reset-password CLI.
Some apps require a real database (PostgreSQL, MySQL, etc.) rather than SQLite. Since Appbox requires single-container deployment, you must embed the database inside the same container.
When bundling PostgreSQL:
-
Install from the official repo — Use the PGDG apt repository for the exact major version the app requires. The app's extensions (
.sofiles) are compiled against a specific PG major version and won't work with a different one. -
Initialize the data directory before services start — Run
initdbin the entrypoint (beforeexec /init), not in an s6 service. This ensures the data directory exists and is ready when the PostgreSQL service starts:PG_DATA="/config/postgres" if [[ ! -f "${PG_DATA}/PG_VERSION" ]]; then gosu 1000:1000 /usr/lib/postgresql/14/bin/initdb -D "${PG_DATA}" # configure postgresql.conf, create database, etc. fi
-
UID must exist in
/etc/passwd— PostgreSQL'sinitdbcallsgetpwuid()and fails with "could not look up effective user ID" if the UID has no entry. When using LSIO base images, theinit-adduserscript creates the user, but the entrypoint runs before/init. Bootstrap the user:if ! getent passwd 1000 >/dev/null 2>&1; then echo "appuser:x:1000:1000::/config:/usr/sbin/nologin" >> /etc/passwd fi if ! getent group 1000 >/dev/null 2>&1; then echo "appuser:x:1000:" >> /etc/group fi
-
Data directory permissions — PostgreSQL requires mode 700 on its data directory. LSIO init scripts may reset
/configpermissions to 755. Fix it in the s6runscript, immediately before starting PG:chmod 700 /config/postgres exec gosu 1000:1000 /usr/lib/postgresql/14/bin/postgres -D /config/postgres -
Crash recovery (stale PID files) — After an unclean shutdown, a stale
postmaster.pidprevents PostgreSQL from starting. Clean it up:if [ -f /config/postgres/postmaster.pid ]; then OLD_PID=$(head -1 /config/postgres/postmaster.pid) if ! kill -0 "$OLD_PID" 2>/dev/null; then pkill -9 -u 1000 postgres 2>/dev/null || true sleep 1 rm -f /config/postgres/postmaster.pid fi fi
-
Socket directory — Create and chown
/var/run/postgresqlbefore starting PG (it's a tmpfs and may not exist):mkdir -p /var/run/postgresql && chown 1000:1000 /var/run/postgresql -
Extensions and
shared_preload_libraries— Some extensions (e.g. pgvecto.rs, pgvector) must be listed inshared_preload_librariesinpostgresql.confbefore PostgreSQL starts. Configure this duringinitdb, not after.
Redis is straightforward to embed:
- Install
redis-server - Run it as an s6 longrun service:
exec gosu 1000:1000 redis-server --dir /config/redis - Redis automatically creates its data files on first start
When the app has no CLI password-reset command, moduser.sh must update the database directly. Key considerations:
- Hash the password using the app's expected algorithm (bcrypt, argon2, etc.). Install the hashing library during the Docker build.
- Module resolution — Globally installed npm packages may not be found by Node.js at runtime. Set
NODE_PATH=/usr/lib/node_modulesexplicitly. - Schema knowledge — You must know the exact table and column names. They may differ from what you'd expect (e.g. Immich uses a quoted
"user"table, notusers). Check the app's migration files or running database to confirm. - Reserved SQL keywords — Table names like
userare reserved in PostgreSQL and must be double-quoted in SQL:UPDATE "user" SET password = '...'
When creating an Appbox app, ensure:
- UID 1000: App process runs as UID 1000 inside the container, all data dirs owned by 1000:1000
- User namespaces: Never assume root inside the container has host root privileges — userns is always enabled
- Strong passwords: Use
complexPasswordtype for admin password fields - No default credentials: Either require user input or auto-generate with
%RAND.N% - Bind to 0.0.0.0: The platform handles external access through port mapping
- Disable public registration: Configure the app so only the admin can create accounts
- HTTPS: Set
networking.ssl: truefor all web apps - Non-root process: Use
gosu 1000:1000to drop from root to UID 1000 before exec - Minimal capabilities: Request only the
cap_addcapabilities your app actually needs - Data persistence: Ensure all important data is in a
volumespath - Upgrade safety: Test that upgrades preserve user data
- Volume UIDs: All volume
uidfields set to1000 - moduser.sh: Password change script included and working at
/moduser.sh
-
Choose your base image — Find an official Docker image for the app you want to package. Check Docker Hub or the app's GitHub for maintained images. If the app needs multiple services, look for a monolithic image (e.g. LinuxServer.io, ImageGenius) that bundles everything with s6-overlay.
-
Create the repository — Set up a new Git repo with these files:
appbox.ymlDockerfileentrypoint.shmoduser.shicon.png(512x512)- For multi-service apps: s6 service
runscripts and any init script overrides
-
Write
appbox.yml— Start with the required sections (appandimage), then add what your app needs. Copy from this example and modify. Key decisions:- What ports does the app listen on? →
portssection - What data needs to persist? →
volumessection - What does the user need to configure? →
custom_fieldssection - Does the app need a domain? →
networkingsection - Does the app need a database? → embed it and add its data dir to
volumes
- What ports does the app listen on? →
-
Write the Dockerfile — Follow this pattern:
FROM <upstream-image>:<tag> RUN <install bash, curl, gosu> ADD entrypoint.sh /entrypoint.sh ADD moduser.sh /moduser.sh RUN chmod +x /moduser.sh ENTRYPOINT ["/entrypoint.sh"] CMD [<app's default command>] # or CMD ["/init"] for s6 apps EXPOSE <internal port>
For multi-service apps, also define s6 longrun services and explicit dependency ordering. See Multi-Service Apps.
-
Write
moduser.sh— Password change script that accepts one argument (the new password) and overwrites the default user's password. See Password Change Script. For apps with embedded databases and no CLI reset command, see Embedding Databases → moduser.sh. -
Write
entrypoint.sh— Follow the lifecycle pattern:- State detection (check for persisted data via
/etc/app_configured) - Fresh install: start app, configure, stop app
- API callback
exec gosu 1000:1000 "$@"(always UID 1000, never another UID)- For s6 apps: one-time setup before
/init, lifecycle in background subshell (withset +e), thenexec "$@"
- State detection (check for persisted data via
-
Test locally — Build and run the container:
docker build -t my-app . docker run -e USERNAME=admin -e PASSWORD='TestPass123!' \ -e INSTANCE_ID=test -e SKIP_APPBOX_CALLBACK=1 \ -p 3001:3001 my-app
-
Test the three states:
- Fresh install: run with empty volume
- Upgrade: stop, rebuild image, start with same volume
- Restart: stop and start without rebuilding
-
Run the test suite — Complete all applicable tests in
TESTING.mdand confirm they pass. -
Submit — Push your image to the Appbox private registry (
repo.cylo.io) and submit a support ticket for review. See Submitting Your App below.
Once your app is tested and ready, submit it for review by opening a ticket at:
https://billing.appbox.co/submitticket.php?step=2&deptid=1
Use the following template for your submission:
Subject: App Submission: <App Name>
App Name: <display name as it should appear in the store>
Publisher: <developer or organization name>
Repository: <URL to your Git repo containing appbox.yml, Dockerfile, entrypoint.sh, icon.png>
Docker Image: <image name on repo.cylo.io, e.g. repo.cylo.io/your-org/your-app>
Version: <version string, e.g. 1.0.0>
Docker Tag: <tag to pull, e.g. latest, 1, 1.0.0>
Short Description:
<One-line description for store cards>
Categories:
<Comma-separated list, e.g. Communication, Media>
Notes:
<Any additional context for the reviewer — special requirements,
capabilities needed (cap_add), resource recommendations, etc.>
The review team will verify your appbox.yml, test the container, and enable the app in the store.