This guide will walk you through everything you need to know about contributing a new application to the catalog, from understanding the project architecture to submitting your first pull request.
- Welcome & Project Overview
- Getting Started
- Project Architecture
- Contributing a New Application
- Configuration System Deep Dive
- Templates and Rendering
- Local Testing
- Updates and Migrations
- Best Practices
- Submission Guidelines
- Getting Help
TrueNAS Apps is a Docker Compose-based application catalog that makes it easy to deploy and manage containerized applications on TrueNAS systems. Think of it as an app store for your TrueNAS server.
Users can browse, install, configure, and manage applications through a friendly web interface without needing to write Docker Compose files manually.
The system uses a templating approach:
- App developers (that's you!) define applications using a combination of metadata files (
app.yaml), configuration schemas (questions.yaml), and Jinja2 templates (docker-compose.yaml) - TrueNAS users select an app from the catalog and fill out a form based on the questions you define
- The rendering system processes your templates with the user's values and generates a standard Docker Compose file
- Docker Compose deploys and manages the containers
We aim to:
- Make self-hosting applications accessible to everyone, regardless of technical expertise
- Provide a curated, well-tested catalog of applications
- Enable easy application management with sensible defaults
- Support the open-source community by promoting and simplifying deployment of great applications
By contributing an app to this catalog, you:
- Help TrueNAS users easily deploy applications they love
- Give back to the open-source community
- Gain experience with Docker, templating systems, and Python
- Connect with other app developers and the TrueNAS community
Before you begin, make sure you have the following installed on your local machine:
- Git - For cloning the repository
- Docker - To run containers locally
- Docker Compose - For orchestrating multi-container apps
- Python 3.x - The templating and testing system is written in Python
- jq - JSON processor used by the CI scripts
To avoid installing python (and its dependencies) on your system, you can install devbox
and use the provided devbox.json file to set up your environment:
devbox shellYou can skip this if using devbox as described above.
Install these Python packages:
pip install pyyaml psutil pytest pytest-cov bcrypt pydanticAlternative using nix-shell (if you have Nix installed):
nix-shell -p 'python3.withPackages (ps: with ps; [ pyyaml psutil pytest pytest-cov bcrypt pydantic ])'git clone https://github.com/truenas/apps.git
cd appsBefore starting work on an app:
- Check existing issues: Someone might already be working on it
- Check existing PRs: The app might already be in review
- Open an issue or comment: Let others know you're working on it before starting work
- Open a draft PR: This allows maintainers to catch any wrong directions early
Once cloned, you'll see this structure:
.
├── ix-dev/ # App definitions
│ ├── community/ # Community-contributed apps (this is where you work!)
│ ├── stable/ # TrueNAS curated apps
│ ├── enterprise/ # Apps for enterprise users
│ ├── dev/ # Development/testing
│ └── test/ # Test apps
├── library/ # Rendering library (Python modules)
│ └── 2.x.x/ # Library versions
├── trains/ # Auto-generated catalog files (DO NOT EDIT)
├── docs/ # Documentation
├── .github/ # CI/CD scripts and workflows
│ └── scripts/
│ └── ci.py # Local/CI testing script
└── README.mdImportant: /ix-dev/ or /library/ directories. All other files are auto-generated.
Apps are organized into "trains" (categories):
- community: Community-contributed apps - all new contributions go here
- stable: TrueNAS applications that are curated and maintained by TrueNAS team
- enterprise: Enterprise-grade applications maintained by iXsystems
All new contributions should target the community train. Other trains are managed by iXsystems maintainers.
The TrueNAS Apps system follows this flow:
┌─────────────────┐
│ App Developer │ ← You define the app structure
└────────┬────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ App Definition Files │
│ • app.yaml (metadata) │
│ • ix_values.yaml (static defaults) │
│ • questions.yaml (user configuration schema) │
│ • templates/docker-compose.yaml (Jinja2) │
└────────┬────────────────────────────────────────┘
│
▼
┌─────────────────┐
│ TrueNAS User │ ← User fills out configuration form
└────────┬────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ Rendering System │
│ • Merges ix_values.yaml + user values │
│ • Processes Jinja2 template │
│ • Uses library functions (ports, storage, etc) │
│ • Validates configuration │
└────────┬────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ Docker Compose File │
│ • Standard docker-compose.yaml │
│ • Contains all services, volumes, networks │
└────────┬────────────────────────────────────────┘
│
▼
┌─────────────────┐
│ Docker Engine │ ← Deploys and manages containers
└─────────────────┘Each app lives in /ix-dev/{train}/{app}/ and has this structure:
/ix-dev/{train}/{app}/
├── app.yaml # App metadata (required)
├── item.yaml # Auto-generated catalog entry
├── ix_values.yaml # Static default values (required)
├── questions.yaml # User configuration schema (required)
├── README.md # Short app description (required)
├── app_migrations.yaml # Migration definitions (optional)
├── migrations/ # Migration scripts (optional)
│ └── migration_script # Python migration script
└── templates/
├── docker-compose.yaml # Jinja2 template (required)
├── library/ # Auto-copied library files (auto-generated)
│ └── base_v2_x_xx/ # Library version (auto-generated)
├── rendered/ # Temporary (gitignored)
│ └── docker-compose.yaml # Rendered compose file (auto-generated)
└── test_values/ # CI test configurations (required)
└── basic-values.yaml # Basic test scenario (required)The library (/library/{version}/) is a collection of Python modules that handle common tasks:
- render.py: Main orchestration—coordinates the entire rendering process
- container.py: Container configuration (image, command, environment, etc.)
- storage.py: Volume and mount management
- ports.py: Port mapping configuration
- environment.py: Environment variable handling
- healthcheck.py: Health check configuration
- deps_*.py: Pre-built common dependencies:
deps_postgres.py: PostgreSQL databasedeps_redis.py: Redis cachedeps_mariadb.py: MariaDB databasedeps_perms.py: Permission initialization container- And more...
The library provides a Python API that you use in your Jinja2 templates to generate Docker Compose configuration.
When a user deploys an app:
- Value Collection: User input from the UI form (based on
questions.yaml) is collected - Validation: Middleware validates user input against the schema
- Value Merging: User values are merged with
ix_values.yamldefaults - Template Processing:
- Jinja2 processes
templates/docker-compose.yaml - Your template calls library functions to build configuration
- Library returns structured data representing Docker Compose services
- Jinja2 processes
- Rendering: The final Docker Compose YAML is generated
- Validation: The compose file is validated against docker engine for spec correctness
- Deployment: Docker Compose creates and starts the containers
- Monitoring: Health checks ensure containers are running properly
- Portal Generation: Web UI links are made available to the user
The GitHub Actions CI/CD system:
- Validates all app definitions
- Runs tests against test values
- Publishes to the catalog (after PR is merged)
The easiest way to create a new app is to copy an existing one that's similar. Browse /ix-dev/community/ to find an app with similar requirements:
- Similar number of containers (single app vs. app + database)
- Similar storage needs
- Similar networking requirements
# Example: Creating a new app based on qbittorrent
cd ix-dev/community
cp -r qbittorrent myapp
cd myappUpdate the app metadata in app.yaml:
app_version: 2.1.0 # Upstream application version (ie the container image tag)
capabilities: [] # List any Linux capabilities needed (autogenerated using the generate metadata script)
categories: # Single category that best fits the app
- productivity
date_added: '2025-01-01' # Date the app was added to TrueNAS
description: A brief description of what the app does
home: https://myapp.com # Upstream homepage
host_mounts: [] # List any host mounts needed (usually for devices like /var/run/docker.sock)
icon: https://media.sys.truenas.net/apps/myapp/icons/icon.png
keywords:
- productivity
- tools
lib_version: 2.1.60 # Use the latest library version (check /library/)
lib_version_hash: "" # Leave empty, will be auto-generated
maintainers: # TrueNAS is the only maintainer for now
- email: dev@ixsystems.com
name: truenas
url: https://www.ixsystems.com
name: myapp # Must match directory name
run_as_context: # Metadata for user/group the app runs as (use 568 if it can run as any non-root user)
- description: MyApp runs as any non-root user
gid: 568
group_name: myapp
uid: 568
user_name: myapp
screenshots: [] # URLs to screenshots (hosted on TrueNAS CDN)
sources: [] # Upstream source code repositories
title: My Awesome Application # Human-friendly app title
train: community # Must match parent directory
version: 1.0.0 # App version (start at 1.0.0)Key fields:
name: Must exactly match your directory nametrain: Must match the parent directory (community,stable, etc.)version: Increment this whenever you make changes to the appapp_version: The version of the upstream applicationlib_version: Use the latest non-v1 library version (check/library/directory)run_as_context: Metadata for user/group the app runs as (security best practice)
About icons and screenshots:
For icons and screenshots that will be hosted on the TrueNAS CDN, include the URLs or attach the images in your PR description. The PR reviewer will upload them to the CDN and provide you with the correct URLs.
This file contains values that are always used, not exposed to users:
Keys for the images must always end with image.
If the versioning of an image is not SemVer, a custom versioning regex must be added in the renovate-config.js file to ensure proper updates.
Prefer ghcr over docker.io for images.
images:
image:
repository: myorg/myapp
tag: 2.1.0
worker_image:
repository: myorg/myapp-worker
tag: 2.1.0
consts:
app_container_name: myapp
perms_container_name: myapp-permsCommon patterns:
images: Define all container images and their tagsconsts: Constants used throughout your templates
This defines the form users see when configuring your app.
Most groups should exist across all apps. For example:
- My App Configuration (App-specific settings)
- User and Group Configuration (Can be skipped if the app can ONLY run as a specific uid/gid and users can't choose)
- Network Configuration
- Storage Configuration
- Labels Configuration
- Resources Configuration
Also keep in mind that most groups and their fields structure is "fixed" and cannot be changed. Rendering library does lots of automated things based on this structure.
It uses a schema-based approach:
This is a simplified example - with only few of the above mentioned groups.
groups:
- name: My App Configuration
description: Configure MyApp Settings
- name: Network
description: Network Configuration
- name: Storage
description: Storage Configuration
questions:
- variable: myapp
label: ""
group: My App Configuration
schema:
type: dict
attrs:
- variable: admin_email
label: Administrator Email
description: Email address for the administrator
schema:
type: string
required: true
default: "admin@example.com"
- variable: enable_feature
label: Enable Advanced Feature
schema:
type: boolean
default: false
- variable: additional_envs
label: Additional Environment Variables
schema:
type: list
default: []
items:
- variable: env
label: Environment Variable
schema:
type: dict
attrs:
- variable: name
label: Name
schema:
type: string
required: true
- variable: value
label: Value
schema:
type: string
# Network Configuration
- variable: network
label: ""
group: Network
schema:
type: dict
attrs:
# Also simplified, a port must have whole lot more attributes.
# See other apps for full example.
- variable: web_port
label: Web Port
description: Port for the web interface
schema:
type: dict
attrs:
- variable: port
label: Port Number
schema:
type: int
default: 8080
min: 1024
max: 65535
required: true
# Storage Configuration
- variable: storage
label: ""
group: Storage
schema:
type: dict
attrs:
# Also simplified, a storage must have whole lot more attributes.
# See other apps for full example.
- variable: config
label: App Configuration Storage
description: Stores application configuration
schema:
type: dict
attrs:
- variable: type
label: Type
schema:
type: string
default: "ix_volume"
enum:
- value: "host_path"
description: "Host Path"
- value: "ix_volume"
description: "TrueNAS Dataset"
- variable: ix_volume_config
label: Dataset Configuration
schema:
type: dict
show_if: [["type", "=", "ix_volume"]]
attrs:
- variable: acl_enable
label: Enable ACL
schema:
type: boolean
default: false
- variable: host_path_config
label: Host Path Configuration
schema:
type: dict
show_if: [["type", "=", "host_path"]]
attrs:
- variable: path
label: Host Path
schema:
type: hostpath
required: trueSchema types available:
string: Text inputint: Number inputboolean: Checkboxdict: Nested configuration (object)list: Array of itemsuri: URL inputpath: File/directory path on the systemhostpath: Path that must exist on the host
And more...
Important attributes:
required: Whether the field is mandatorydefault: Default valuemin/max: For numeric fieldsmin_length/max_length: For string fieldsenum: List of allowed valuesshow_if: Conditional display based on other field valuesprivate: Hides the value (for passwords)hidden: Completely hides the field but includes it in config
And more...
The templates/docker-compose.yaml file is a Jinja2 template that uses the library to generate configuration:
{# First, initialize the rendering system #}
{% set tpl = ix_lib.base.render.Render(values) %}
{# Define the main application container #}
{% set app = tpl.add_container(values.consts.app_container_name, "image") %}
{% do app.set_user(values.run_as.user, values.run_as.group) %}
{% do app.healthcheck.set_test("curl", {"port": 8080, "path": "/health"}) %}
{# Configure environment variables #}
{% do app.environment.add_env("APP_PORT", values.network.web_port.port) %}
{% do app.environment.add_env("ADMIN_EMAIL", values.myapp.admin_email) %}
{# Add port mappings #}
{% do app.add_port(values.network.web_port) %}
{# Configure storage #}
{% do app.add_storage("/config", values.storage.config) %}
{# Setup permissions container for storage initialization #}
{% set perms = tpl.deps.perms(values.consts.perms_container_name) %}
{% do perms.add_or_skip_action("config", values.storage.config, {"uid": 568, "gid": 568, "mode": "check"}) %}
{% if perms.has_actions() %}
{% do perms.activate() %}
{% do app.depends.add_dependency(values.consts.perms_container_name, "service_completed_successfully") %}
{% endif %}
{# Add portal for UI access #}
{% do tpl.portals.add(values.network.web_port, scheme="http", path="/") %}
{# Render the final configuration #}
{{ tpl.render() | tojson }}Explore other apps for more examples.
Key library components:
tpl.add_container(name, image_key): Creates a new containerapp.set_user(uid, gid): Sets the user the container runs asapp.healthcheck.set_test(): Configures health checksapp.environment.add_env(): Adds environment variablesapp.add_port(): Maps ports to the hostapp.add_storage(): Mounts volumestpl.deps.perms(): Creates a permissions init containertpl.portals.add(): Adds a web UI portal link
Adding dependencies (database example):
{# Add PostgreSQL database #}
{% set pg_config = {
"user": values.consts.db_user,
"password": values.myapp.db_password,
"database": values.consts.db_name,
"volume": values.storage.postgres_data,
} %}
{% set postgres = tpl.deps.postgres("postgres", "postgres_image", pg_config, perms) %}
{% do app.depends.add_dependency("postgres", "service_healthy") %}
{# Use postgres.get_url() to generate the connection string #}
{% do app.environment.add_env("DATABASE_URL", postgres.get_url("postgresql")) %}Create test value files in templates/test_values/ to test different configurations:
basic-values.yaml:
resources:
limits:
cpus: 2.0
memory: 4096
# Should contain all options that are exposed in the questions.yaml
myapp:
admin_email: test@example.com
enable_feature: false
additional_envs: []
network:
web_port:
bind_mode: published
port_number: 30080 # Use a non-default port for testing, catches issues early
ix_volumes:
test_myapp_config: /opt/tests/mnt/myapp/config
storage:
config:
type: ix_volume
ix_volume_config:
dataset_name: test_myapp_config
create_host_path: trueNote on test storage paths:
Most apps use directories like /opt/tests/** for storage in test files. This is because:
- macOS whitelists
/opt/by default for Docker - Linux doesn't have this restriction
- It prevents accidentally mounting sensitive directories
Make sure your test files won't mount any directories you don't want them to!
Keep the README brief - just a title and short description. Link to upstream documentation for details:
# My Awesome Application
[My Awesome Application](https://myapp.com) is a tool for doing awesome things.When your template is rendered, values come from multiple sources merged in this order:
ix_values.yaml- Your static defaults- User input from
questions.yaml(or test values during testing)
Access values in templates using: values.path.to.variable
Every template follows this basic structure:
{# 1. Initialize the rendering system #}
{% set tpl = ix_lib.base.render.Render(values) %}
{# 2. Add containers #}
{# 3. Configure containers #}
{# 4. Add dependencies #}
{# 5. Setup permissions #}
{# 6. Add portals #}
{# Final: Render the configuration #}
{{ tpl.render() | tojson }}{% set app = tpl.add_container("container_name", "image_key") %}- First argument: Container name (use value from
consts) - Second argument: Key in
ix_values.yamlimages section
{# Set user and group - usually picked automatically from run_as_context in app.yaml #}
{# Only set manually if you need to override the default #}
{% do app.set_user(568, 568) %}
{# Set entrypoint and command #}
{% do app.set_entrypoint(["myapp"]) %}
{% do app.set_command(["--config", "/config/app.conf"]) %}
{# Add environment variables #}
{% do app.environment.add_env("KEY", "value") %}
{% do app.environment.add_env("PORT", 8080) %}
{# Add port mappings #}
{% do app.add_port(values.network.web_port) %}
{# Add storage mounts #}
{% do app.add_storage("/config", values.storage.config) %}
{% do app.add_storage("/data", values.storage.data) %}
{# Set capabilities #}
{% do app.add_caps(["NET_ADMIN", "SYS_ADMIN"]) %}{# HTTP health check #}
{% do app.healthcheck.set_test("curl", {"port": 8080, "path": "/"}) %}
{# TCP health check #}
{% do app.healthcheck.set_test("netcat", {"port": 5432}) %}
{# Disable health check #}
{% do app.healthcheck.disable() %}{# Wait for service to be healthy #}
{% do app.depends.add_dependency("database", "service_healthy") %}
{# Wait for init container to complete #}
{% do app.depends.add_dependency("init", "service_completed_successfully") %}{# Create permissions container #}
{% set perms = tpl.deps.perms("perms_container_name") %}
{% set perms_config = {"uid": 568, "gid": 568, "mode": "check"} %}
{# Add permission actions #}
{% do perms.add_or_skip_action("config", values.storage.config, perms_config) %}
{# Activate and add dependency #}
{% if perms.has_actions() %}
{% do perms.activate() %}
{% do app.depends.add_dependency("perms_container_name", "service_completed_successfully") %}
{% endif %}Permission modes:
Both modes will skip if the directory is not empty.
check: Only fix if permissions are wrongalways: Always set permissions
PostgreSQL:
{% set pg_config = {
"user": "myapp",
"password": values.myapp.db_password,
"database": "myapp_db",
"volume": values.storage.postgres_data,
} %}
{% set postgres = tpl.deps.postgres("postgres", "postgres_image", pg_config, perms) %}
{% do app.depends.add_dependency("postgres", "service_healthy") %}Redis:
{% set redis_config = {
"password": values.myapp.redis_password,
"volume": values.storage.redis_data,
} %}
{% set redis = tpl.deps.redis("redis", "redis_image", redis_config, perms) %}
{% do app.depends.add_dependency("redis", "service_healthy") %}MariaDB:
{% set mariadb_config = {
"user": "myapp",
"password": values.myapp.db_password,
"database": "myapp_db",
"root_password": values.myapp.root_password,
"volume": values.storage.mariadb_data,
} %}
{% set mariadb = tpl.deps.mariadb("mariadb", "mariadb_image", mariadb_config, perms) %}
{% do app.depends.add_dependency("mariadb", "service_healthy") %}Portals create clickable links in the TrueNAS UI:
{# Simple portal (HTTP, path="/") #}
{% do tpl.portals.add(values.network.web_port) %}
{# Custom portal #}
{% do tpl.portals.add(values.network.web_port, {"scheme": "https", "path": "/admin"}) %}
{# Multiple portals #}
{% do tpl.portals.add(values.network.web_port, {"scheme": "http"}) %}
{% do tpl.portals.add(values.network.api_port, {"name": "Custom Label", "path": "/api"}) %}Add informational notes displayed to users:
{% do tpl.notes.add_info("First time setup requires visiting /setup") %}
{% do tpl.notes.add_warning("This app requires GPU passthrough") %}Your template generates a standard Docker Compose file. Here's an example:
Input (your template):
{% set tpl = ix_lib.base.render.Render(values) %}
{% set app = tpl.add_container(values.consts.app_container_name, "image") %}
{% do app.set_user(568, 568) %}
{% do app.add_port(values.network.web_port) %}
{% do app.healthcheck.set_test("curl", {"port": 8080, "path": "/ping"}) %}
{% do app.add_storage("/config", values.storage.config) %}
{{ tpl.render() | tojson }}Output (generated compose file):
services:
myapp:
cap_drop:
- ALL
deploy:
resources:
limits:
cpus: 2
memory: "4294967296"
environment:
GID: "568"
GROUP_ID: "568"
NVIDIA_VISIBLE_DEVICES: void
PGID: "568"
PUID: "568"
TZ: Etc/UTC
UID: "568"
UMASK: "002"
UMASK_SET: "002"
USER_ID: "568"
group_add:
- "568"
healthcheck:
test:
- CMD
- curl
- --request
- GET
- --silent
- --output
- /dev/null
- --show-error
- --fail
- http://127.0.0.1:8080/ping
timeout: 5s
interval: 30s
retries: 5
start_period: 15s
start_interval: 2s
image: some-repo/myapp:2.1.0
platform: linux/amd64
ports:
- mode: ingress
target: 8080
published: "8080"
protocol: tcp
restart: unless-stopped
security_opt:
- no-new-privileges=true
user: 568:568
volumes:
- type: bind
source: /opt/tests/mnt/config
target: /config
bind:
propagation: rprivate
create_host_path: true
x-notes: |
# <app_name>
## Security
**Read the following security precautions to ensure that you wish to continue using this application.**
---
### Container: [myapp]
#### Running user/group(s)
- User: 568
- Group: 568
- Supplementary Groups: apps
## Bug Reports and Feature Requests
If you find a bug in this app or have an idea for a new feature, please file an issue at
https://github.com/truenas/apps
x-portals:
- host: 0.0.0.0
name: Web UI
path: /
port: 8080
scheme: httpBefore submitting your PR, thoroughly test your app locally using the CI script.
The .github/scripts/ci.py script is your primary testing tool:
# Basic test - deploys the app and waits for it to be healthy
./.github/scripts/ci.py --app myapp --train community --test-file basic-values.yaml
# Keep the app running for manual testing
./.github/scripts/ci.py --app myapp --train community --test-file basic-values.yaml --wait=true
# or devbox run app-test community myapp <optionally basic-values.yaml>
# Just render the compose file without deploying
./.github/scripts/ci.py --app myapp --train community --test-file basic-values.yaml --render-only=true
# or devbox run app-render community myapp <optionally basic-values.yaml>--app: Your app directory name--train: The train (community, stable, etc.)--test-file: Which test file fromtemplates/test_values/to use--wait=true: Keeps the app running until you stop it (Ctrl+C). Shows the web UI URL if available--render-only=true: Only renders the compose file without deploying
When you run the CI script, it automatically:
- Generates
item.yaml: Creates the catalog entry - Updates library files: Copies the correct library version to
templates/library/based onlib_versioninapp.yaml - Updates
lib_version_hash: Calculates and sets the hash inapp.yaml - Renders the template: Processes your Jinja2 template with the test values
- Deploys with Docker Compose: Starts the containers (unless
--render-only) - Monitors health: Waits for containers to become healthy (times out after 10 minutes)
# Generates some metadata (like capabilities) for your app
./.github/scripts/generate_metadata.py --app myapp --train community
# Validates ports in questions.yaml are unique
./.github/scripts/port_validation.py-
Start with basic test:
./.github/scripts/ci.py --app myapp --train community --test-file basic-values.yaml --wait=true
-
Check the output:
- The script will show you the rendered compose file location
- It will display the web UI URL if configured
- Watch for any errors in container startup
-
Verify functionality:
- Open the web UI in your browser
- Test basic functionality
- Check logs:
docker logs myapp
-
Test different configurations:
- Create additional test files if needed (e.g.,
with-database-values.yaml,hostpath-values.yaml) - Test each configuration thoroughly
- Create additional test files if needed (e.g.,
-
Clean up:
- Press Ctrl+C to stop (with
--wait=true) - Or the script will auto-cleanup if it runs without
--wait - Manual cleanup if needed:
docker compose -f /path/to/rendered/docker-compose.yaml down -v
- Press Ctrl+C to stop (with
Containers won't start:
- Check
docker logs <container_name>for errors - Verify image names and tags in
ix_values.yaml - Check port conflicts:
docker psto see if ports are already in use
Permission errors:
- Verify
set_userin your template - Check permissions container configuration
- Ensure storage paths are accessible
Template errors:
- Use
--render-only=trueto see the rendered compose file - Check for Jinja2 syntax errors
- Verify all values paths exist in your test files
Health checks failing:
- Verify the health check command is correct
- Verify the application ships the binary used in the health check (ie
curl, etc.)
Currently, there's no easy way to test directly on a TrueNAS system before your PR is merged. However:
- If it works on your local machine with Docker Compose, it should work on TrueNAS
- Exceptions include hardware-specific features (GPU, devices, etc.)
- Let the reviewer know about any special requirements in your PR
The questions.yaml schema is validated during CI, but also needs manual review:
- To see how different values affect the rendered compose file, modify your test files
- Test all conditional fields (
show_if) - Verify all enum options work correctly
- Test required vs optional fields
When you update an existing app or change its configuration structure, you may need migrations to preserve user data and settings.
Use migrations when:
- Changing the structure of configuration values
- Renaming configuration fields
Define when your migration should run:
migrations:
- file: migrate_to_v2
from:
max_version: 1.0.10 # Applies to apps upgrading from version <= 1.0.10
target:
min_version: 2.0.0 # When upgrading to version >= 2.0.0Create migrations/migrate_to_v2 (Python script):
#!/usr/bin/python3
import yaml
import sys
import os
def migrate(values):
"""
Transform old configuration to new configuration.
Old structure:
network:
web_port: 8080
New structure:
network:
web_port:
port: 8080
bind_mode: "published"
"""
# Check if old structure exists
if isinstance(values.get("network", {}).get("web_port"), int):
old_port = values["network"]["web_port"]
# Transform to new structure
values["network"]["web_port"] = {
"port": old_port,
"bind_mode": "published",
"host_ips": [],
}
return values
if __name__ == "__main__":
if len(sys.argv) != 2:
exit(1)
if os.path.exists(sys.argv[1]):
with open(sys.argv[1], "r") as f:
print(yaml.dump(migrate(yaml.safe_load(f.read()))))chmod +x migrations/migrate_to_v2Increment the version field to trigger the migration:
version: 2.0.0 # Incremented from 1.0.10- Handle Edge Cases: Check for missing or unexpected values
- Test Thoroughly: Test migrations with various old configurations
- Document Changes: Explain what changed in your PR description
- Preserve Data: Never delete user data without explicit consent
- Validate Output: Ensure migrated values match the new schema
-
Run as Non-Root: Always define
run_as_contextwith a non-root userNote: this is only METADATA
run_as_context: - uid: 568 gid: 568 user_name: myapp group_name: myapp
-
Use Secrets: Mark sensitive fields as private in
questions.yamlNote: this is just UI masking.
- variable: password schema: type: string private: true
-
Minimal Capabilities: Only add capabilities if absolutely necessary
{% do app.set_capabilities(["NET_ADMIN"]) %} # Only if needed -
Validate Input: Use schema validation (min, max, enum) in
questions.yaml
-
Use Health Checks: Always define appropriate health checks
{% do app.healthcheck.set_test("curl", {"port": 8080, "path": "/health"}) %} -
Optimize Dependencies: Only add dependencies when needed
{% do app.depends.add_dependency("postgres", "service_healthy") %} -
Use Latest Library: Always use the newest non-v1 library version
- Sensible Defaults: Provide good default values in
questions.yaml - Clear Descriptions: Add helpful descriptions to fields (where applicable)
- Group Related Settings: Use the existing groups to organize configuration
- Conditional Fields: Use
show_ifto hide irrelevant options - Add Portals: Always add web UI portals when applicable
- Start from Similar App: Copy a similar app rather than starting from scratch
- Follow Naming Conventions: Use consistent naming (snake_case for variables)
- Comment Complex Logic: Add comments in templates for clarity
- Test Multiple Scenarios: Create multiple test files
- Clean Up: Remove unused code from copied apps
-
Use ix_volume by Default: TrueNAS-managed datasets are preferred as defaults, as it makes it easy to spin up the app and test.
default: "ix_volume"
-
Document Storage Needs: Explain what each storage mount is for
-
Test Paths: Use
/opt/tests/prefix in test files for compatibility
-
Semantic Versioning: Use semantic versioning for
versionfield- Major: Breaking changes
- Minor: New features
- Patch: Bug fixes
-
Track Upstream: Keep
app_versionin sync with main container tag -
Increment on Every Change: Always bump
versionwhen modifying the app
- Document Special Requirements: Note GPU, devices, or network requirements
- Add Configuration Notes: Include setup instructions if needed
- Link to Upstream Docs: Provide links to official documentation
Checklist before opening a PR:
- App works locally with all test files
-
app.yamlmetadata is complete and accurate -
questions.yamlhas clear labels and descriptions - All test files pass successfully
- README.md is written
- Only files under
/ix-dev/or/library/are modified - No auto-generated files are included in the PR
- Icons/screenshots are ready (links provided in PR description)
When you create a new pull request, GitHub will automatically populate it with our PR template. This template includes sections for:
- Description: Brief overview of the app and what it does
- App Information: Links to upstream repository, documentation, license, and version
- Testing: Checklist of test scenarios you've verified
- Icons and Screenshots: Visual assets for the app
- Special Notes: Any important setup or usage information
- Checklist: Final verification before submission
The template is located at .github/PULL_REQUEST_TEMPLATE/app_addition.md
and will be automatically loaded when you create a PR.
Simply fill in the placeholders with your app's specific information.
What to expect:
-
Automated Checks: CI will run automatically
- Validates your app structure
- Runs test files
- Checks for errors
-
Human Review: A maintainer will review your PR
- Check code quality
- Verify app works as expected
- Suggest improvements
-
Iteration: You may need to make changes
- Address review comments
- Fix any issues found
- Update based on feedback
-
CDN Upload: Reviewer will upload icons/screenshots
- You'll receive CDN URLs
- Update your PR with the URLs
-
Merge: Once approved, your PR will be merged!
Congratulations! Your app is now in the catalog. 🎉
It's a great idea to let the upstream app developers know that their app is now available on TrueNAS:
- Contact them politely: Via GitHub issue, email, or Discord
- Provide a quick how-to: Explain how to deploy their app on TrueNAS
- Suggest adding TrueNAS: To their supported platforms list
- Share the catalog link: Link to the TrueNAS Apps catalog
-
GitHub Discussions: https://github.com/truenas/apps/discussions
- Ask questions
- Share tips and tricks
- Collaborate with other developers
-
Library Tests:
/library/{version}/tests/- Real examples of library usage
- See how different features work
-
Existing Apps:
/ix-dev/community/- Browse real-world examples
- See how similar apps are structured
- Copy patterns that work
-
TrueNAS Forums: https://forums.truenas.com
- Community support
- User feedback
- General TrueNAS questions
Q: Which library version should I use?
A: Always use the latest non-v1 version from /library/. Check the directory for available versions and use the highest numbered 2.x.x version.
Q: My app needs a GPU. How do I configure that?
A: Check apps like plex or jellyfin that use GPU passthrough. You'll need to add device mappings and possibly capabilities.
Q: Can I test on TrueNAS before submitting? A: Currently there's no easy way. Test locally with Docker Compose—if it works there, it should work on TrueNAS.
Q: How do I handle questions.yaml migrations?
A: Create an app_migrations.yaml file and a Python migration script. See the "Updates and Migrations" section above.
Q: Can I add an app to the stable or enterprise train? A: You can't. This train is managed by TrueNAS maintainers only. Submit to the community train only.
Q: How do I update an existing app?
A: Fork the repo, make your changes, increment the version in app.yaml, and open a PR. Include what changed in the description.
Q: An app is outdated. Can I update it? A: If it's been more than 2 days that the upstream container tag has been released, and our bot hasn't picked it up yet, please open an issue. A fix has to be applied so the bot can pick it up.
Want to do more than just contribute apps?
- Review PRs: Help review other contributors' apps
- Improve Documentation: Submit improvements to this guide
- Report Issues: Found a bug? Open an issue
- Help Users: Answer questions in Discussions / Issues
- GitHub Issues: For bugs and feature requests
- GitHub Discussions: For questions and general discussion
- Pull Requests: For contributions
Thank you for contributing to the TrueNAS Apps catalog! Your work helps make self-hosting accessible to everyone. We appreciate your time and effort in making this ecosystem better.
Happy coding! 🚀