Packaging Format
Town OS packages are YAML definitions that describe how to run a containerized service, including its image, networking, storage, and user-facing configuration prompts. Packages can also run virtual machines or Windows applications via Proton.
Repository Structure
A package repository is any git repository containing a packages/ directory.
Anyone can create one — host your own packages for family, friends, or custom deployments.
The structure is simple:
packages/
<package-name>/
<version>.yaml
featured.json # optional
# Example:
packages/
nginx/
1.0.yaml
2.0.yaml
postgres/
1.0.yaml
featured.json
Each package directory under packages/ contains one or more versioned YAML files.
Versions are compared using dot-separated segments — numeric segments are compared numerically,
otherwise lexicographically. The storage system supports upgrading between versions and
temporary uninstallation with later restoration. Town OS ships with a
default repository
of packages, but you can add as many additional repositories as you need.
Package Definition
A complete container package definition with all available fields:
image:
url: nginx:1.26-alpine
description: Lightweight high-performance web server and reverse proxy
supplies: ["http"]
command: ["optional", "command", "override"]
environment:
NGINX_HOST: "@hostname@"
network:
external:
"@port@": "80"
internal:
"5432": "5432"
domains:
- "@hostname@"
volumes:
html:
mountpoint: /usr/share/nginx/html
quota: 2gb
uid: 1000
gid: 1000
data:
mountpoint: /data
archive: seed-data.tar.gz
questions:
hostname:
query: "What hostname should nginx serve?"
type: hostname
port:
query: "What external port should nginx listen on?"
type: port
default: "8080"
archives:
- image: nginx:latest
directory: /usr/share/nginx/html
volume: html
git_sources:
- url: "https://github.com/example/config.git"
branch: main
volume: data
templates:
config:
volume: data
path: config.yaml
content: |
host: {{ .Responses.hostname }}
port: {{ .Responses.port }}
version: {{ .Package.Version }}
notes:
URL:
value: "http://@hostname@:@port@"
type: url
Support:
value: "+1 (555) 123-4567"
type: phone
The image field also accepts a shorthand string form: image: nginx:1.26-alpine.
Top-Level Fields
| Field | Description |
|---|---|
image | Required (container runtime). Container image reference. Can be a string or an object with url and optional type. Mutually exclusive with vm. |
description | Short human-readable summary of the package. |
supplies | List of semantic capability tags this package provides (e.g. ["database"], ["http"]). |
command | Optional command override for the container. Mutually exclusive with proton. |
environment | Environment variables passed to the container. Keys must match ^[a-zA-Z_][a-zA-Z0-9_]*$. Values may contain @variable@ template markers. |
network | Port mapping and domain configuration. |
volumes | Named volumes with mount configuration. |
questions | Interactive prompts shown during installation. Names must be alphanumeric (^[a-zA-Z0-9]+$). |
archives | Archive extraction specs to pre-populate volumes from container images. |
git_sources | Git repositories to clone into volumes. |
templates | File templates rendered with Go text/template and written to volumes on install. |
notes | Key-value metadata displayed after installation. Supports template substitution. |
vm | Virtual machine configuration. Mutually exclusive with image and proton. |
proton | Windows application configuration via Proton. Mutually exclusive with vm and command. |
There are two runtime types: container (the default) and vm.
A package specifies image or proton for the container runtime,
or vm for the VM runtime. Proton is a specialization of the container runtime
— it uses Podman under the hood but auto-generates the command and extracts Windows application
files from a separate container image. These fields are mutually exclusive: a package must
include exactly one of image/proton or vm.
Image Field
The image field identifies the container image to run. It accepts two forms:
# Object form
image:
url: nginx:1.26-alpine
type: oci # optional, defaults to "oci"
# Shorthand string form
image: nginx:1.26-alpine Short image names are automatically normalized during compilation:
| Input | Normalized To |
|---|---|
nginx | docker.io/library/nginx:latest |
myuser/myapp | docker.io/myuser/myapp:latest |
ghcr.io/org/app | ghcr.io/org/app:latest |
nginx:1.26-alpine | docker.io/library/nginx:1.26-alpine |
The only valid type value is oci (the default). Image URLs must contain only
alphanumeric characters, @, ., _, :,
/, and - — shell metacharacters are rejected.
Supplies Tags
The supplies field declares what capabilities a package provides,
enabling categorization and filtering. Tags are free-form strings — the following
are conventional:
| Tag | Used For | Examples |
|---|---|---|
http | Web servers, CMS platforms, web applications | nginx, wordpress, gitea |
database | Relational and NoSQL databases | postgres, mysql, mongo |
cache | Caching and key-value stores | redis, memcached, valkey |
search | Search and analytics engines | elasticsearch, opensearch, solr |
messaging | Message brokers and queues | rabbitmq, nats, kafka |
monitoring | Metrics, alerting, and visualization | prometheus, grafana, telegraf |
storage | Object storage and file hosting | minio, registry, nextcloud |
Network Configuration
| Field | Description |
|---|---|
network.external | Port mappings exposed directly on the host. Keys are host ports, values are container ports. Both are "port" strings and may contain @variable@ templates. |
network.internal | Port mappings reachable only from inside the package's own network (and from the shared HTTP ingress). Keys are host-side/forwarded ports, values are container ports. |
network.domains | Optional list of additional, internet-facing FQDNs the HTTP ingress should obtain a publicly-trusted (Let’s Encrypt / ACME) certificate for. May contain @variable@ templates. |
Port values must be integers between 1 and 65535 after template substitution.
Omit external, internal, or domains entirely if unused — do not use empty maps or lists.
A port entry’s key is either a numeric port string
("2222": "22") or a semantic name matching
^[a-zA-Z][a-zA-Z0-9_]*$ (http: "3000"). Naming a port lets
parents reference it by role (@dep_KEY_port_http@) instead of by number,
and — for the special name http — opts the port into the shared HTTP
ingress described below.
HTTP ingress (the http named port)
Town OS runs a single shared ingress on port :443 that
terminates TLS for every package and reverse-proxies to the package’s plain-HTTP
container port. A package joins the ingress by naming an internal port
http:
network:
internal:
http: "3000" # container's plain-HTTP port When you do this:
-
The service is reachable at
https://<PACKAGE_DNS>/— no port in the URL. There is no host port to choose; do not also map the HTTP port underexternal. -
The ingress serves a locally-trusted leaf certificate (issued by the
built-in Rolodex CA) for
<PACKAGE_DNS>(e.g.gitea.default.home), and Rolodex publishes DANETLSArecords on_443so DANE-aware clients can pin the certificate. -
Because the ingress owns
:443, any URL the app generates must be port-less HTTPS. Set the app’s public URL accordingly (e.g. gitea’sGITEA__server__ROOT_URL: "https://@PACKAGE_DNS@/"), and leave the app listening on plain HTTP inside the container.
Non-HTTP protocols (SSH, databases, etc.) must not use the
http name — they are not HTTP and cannot be TLS-terminated by the ingress.
Forward them untouched with a numeric mapping instead:
network:
internal:
http: "3000" # fronted by the :443 ingress, TLS-terminated
"@sshport@": "22" # raw TCP forward, never wrapped in TLS Internet-facing names (domains)
By default the ingress only serves the locally-trusted <PACKAGE_DNS>
name. To additionally expose a service to the public internet with a
publicly-trusted certificate, list the real FQDN(s) under
network.domains and point that name’s public DNS at this host. The
ingress obtains an ACME (Let’s Encrypt) certificate for each listed domain:
network:
internal:
http: "3000"
domains:
- git.example.com Volumes
| Field | Description |
|---|---|
mountpoint | Required. Absolute path inside the container where the volume is mounted (must start with /). |
quota | Optional size limit (e.g. 512mb, 2gb, 1tb). Supports mb, gb, tb suffixes. May contain @variable@ templates. |
archive | Optional archive filename to pre-populate the volume with. |
git | Optional git repository URL to clone into the volume. |
uid | Optional numeric user ID for volume ownership. |
gid | Optional numeric group ID for volume ownership. |
Volume names must start with an alphanumeric character and contain only alphanumerics,
dots, hyphens, and underscores (pattern: ^[a-zA-Z0-9][a-zA-Z0-9._-]*$).
Omit volumes entirely if the package has none.
Archives
The archives field extracts files from container images into volumes at install time:
archives:
- image: nginx:latest
directory: /usr/share/nginx/html
volume: html | Field | Description |
|---|---|
image | Required. Container image to extract files from. |
directory | Required. Absolute path in the container image to extract. |
volume | Required. Name of a volume defined in this package to extract into. |
If the target volume is empty during install or reconcile, Podman pulls the image, creates a temporary container, and copies the specified directory into the volume.
Git Sources
The git_sources field clones git repositories into volumes:
git_sources:
- url: "https://github.com/example/config.git"
branch: main
volume: config | Field | Description |
|---|---|
url | Required. Git repository URL (http, https, or ssh). May contain @variable@ templates. |
branch | Branch to clone. May contain @variable@ templates. |
volume | Required. Name of a volume defined in this package to clone into. |
Questions
Questions define interactive prompts shown during package installation. User responses
replace @name@ template markers throughout the definition.
questions:
port:
query: "What external port should nginx listen on?"
type: port
default: "8080" | Field | Description |
|---|---|
query | Required. The prompt text shown to the user. |
type | Optional validation type. Omit for free-form text. |
default | Optional default value suggested to the user. |
optional | Set to true to let the question be left blank. Every other question must be answered with a non-empty value. |
oauth | Required for type: oauth, and only valid there. The device flow the install dialog runs to obtain a token. |
show_if | Names a boolean question in the same package. This question is hidden in the install dialog until that box is checked, and compiles to the empty string (and is exempt from the required check) while it is off — letting a package tuck an advanced group behind one switch. The referenced question must exist, be a boolean, and not itself be conditional. |
Question Types
| Type | Validates |
|---|---|
hostname | Lowercase letter followed by lowercase alphanumerics and hyphens (pattern: ^[a-z][a-z0-9-]*$). Auto-generates <package-name>-<4-char-hex> when empty. |
port | Integer between 1 and 65535. Auto-generates a random available port in the range 10000-60000 when empty or set to "auto". |
bytes | Integer or number with tb, gb, or mb suffix (case-insensitive) |
volume | Alphanumeric characters, hyphens, and underscores (pattern: ^[a-zA-Z0-9-_]+$) |
archive | Any non-empty string |
secret | Auto-generates a 64-character hex string (256 bits) when empty or set to "auto". Can be overridden with an explicit value. |
duration | Integer or number with d, h, m, or s suffix (case-insensitive). Converted to seconds. |
boolean | true, false, t, f, 1, or 0 (case-insensitive). Normalized to the string true or false. Rendered as a checkbox in the install dialog; an unanswered question resolves to the default, or to false when none is declared. |
oauth | A token obtained by running an OAuth device flow from the install dialog rather than by typing. Rendered as a Connect button; the token that comes back is the answer. Stored and displayed like a secret. |
| (omitted) | Any string — no validation |
Boolean Questions
A boolean question is shown as a checkbox rather than a text field, and its
answer is substituted as the literal string true or false.
Because an unchecked box is still a real answer, a question with
default: "true" can be turned off by the user — the explicit
false wins over the default.
environment:
REGISTRATION_OPEN: "@open@"
METRICS_ENABLED: "@metrics@"
questions:
open:
query: "Allow open registration?"
type: boolean # unanswered -> "false"
metrics:
query: "Enable metrics?"
type: boolean
default: "true" # unanswered -> "true"
The normalized value is what file templates see too, so
{{ .Responses.metrics }} renders as true or
false and can be tested with
{{ if eq .Responses.metrics "true" }}.
Optional Questions
Every question must be answered with a non-empty value unless it declares
optional: true. Without it, a setting the application can genuinely do
without — an SMTP relay, an API key — has no honest expression: the author has to invent
a placeholder default and hope the operator overwrites it.
environment:
SMTP_HOST: "@smtp_host@"
SMTP_PORT: "@smtp_port@"
questions:
smtp_host:
query: "SMTP server hostname"
optional: true
smtp_port:
query: "SMTP server port"
type: port
optional: true
default: "587"
Left blank, an optional question substitutes the empty string at its
@marker@ sites, so the application sees the variable empty rather than set
to something nobody chose. It is never auto-generated: a blank optional
secret stays blank instead of becoming a random string the application
would dutifully try to authenticate with.
optional composes with type — an answered optional port is
still validated as a port, while a blank one compiles away to nothing. It is meaningless
on a boolean, which is a checkbox and always resolves to one of its two
values.
OAuth Questions
Some applications are configured with a credential only their vendor can mint — a Plex
account token, a GitHub personal token — and the usual way to get one is to run a script
in a terminal and paste what it prints. An oauth question runs that flow
from the install dialog instead: the operator clicks Connect, approves
in a browser tab, and the token that comes back becomes the answer.
There is no provider registry. The question carries an
oauth block naming the provider’s own URLs, so any vendor with a
device-style flow works without a change to Town OS.
environment:
PLEX_TOKEN: "@plextoken@"
questions:
plextoken:
query: "Plex account"
type: oauth
oauth:
start:
method: POST
url: "https://plex.tv/api/v2/pins?strong=true"
headers:
X-Plex-Client-Identifier: "{{client_id}}"
extract:
id: id
code: code
approve: "https://app.plex.tv/auth#?clientID={{client_id}}&code={{code}}"
poll:
url: "https://plex.tv/api/v2/pins/{{id}}"
headers:
X-Plex-Client-Identifier: "{{client_id}}"
token: authToken
interval: 2s
timeout: 10m | Field | Description |
|---|---|
start | Required. The request that opens the flow: method (default GET), url, and optional headers and form body. |
extract | JSON fields to pull out of the start response and make available to the templates below, written as name: json_field. |
approve | Required. The URL the operator opens to approve. It is opened in a new tab and also shown as a link, in case a popup blocker eats it. |
user_code | Optional template for a short code the operator must type on the approval page. GitHub shows one; Plex does not. |
poll | Required. The request repeated until approval. Same shape as start. |
token | Required. The JSON field in the poll response holding the token. Absent or null is how a provider says “not approved yet”. |
interval | How often to poll. Defaults to 5s; honor the provider’s documented rate limit. |
timeout | How long to keep polling before giving up. Defaults to 5m. |
{{...}} placeholders in URLs, headers, and form values resolve
against whatever extract named, plus {{client_id}} —
a random identifier Town OS generates per flow and sends on every step, which is what
Plex ties its pin to.
The token is a credential, so it is treated as one: masked in the package info panel, copyable but never printed. It is cached like any other response, so reinstalling or upgrading reuses it rather than sending the operator back to the provider.
Because it is the system controller — not the browser — that calls these URLs, they must
be https and must not resolve to a loopback, private, link-local, or CGNAT
address. The check runs when the connection is actually made, and again on every
redirect, so a package cannot use a flow to make the controller reach into the
host’s own network.
Templates
The templates field defines files that are rendered using Go text/template
and written to volumes during installation. Templates are only written if the target file does
not already exist, preserving user modifications across upgrades.
templates:
config:
volume: data
path: config.yaml
content: |
host: {{ .Responses.hostname }}
port: {{ .Responses.port }}
name: {{ .Package.Name }}
system: {{ .System.Hostname }} | Field | Description |
|---|---|
volume | Required. Name of a volume defined in this package. |
path | Required. Relative path within the volume. Must not start with / or contain ... |
content | Required. Go text/template content to render. |
Template Data Context
The following data is available inside {{ }} expressions:
| Expression | Description |
|---|---|
.Responses.<name> | User's answer to the named question |
.Package.Name | Package name |
.Package.Version | Package version |
.Package.Repo | Repository name |
.Package.Image | Compiled container image reference |
.Package.Description | Package description |
.System.Hostname | System hostname |
.System.ExternalIP | External IP address (if known) |
.System.InternalIP | Internal/LAN IP address (if known) |
Template names follow the same rules as volume names. Files are written with mode 0600
and parent directories are created with mode 0750.
Notes
Notes provide key-value metadata displayed after installation.
notes:
URL:
value: "http://localhost:@port@"
type: url
Info:
value: "Default admin credentials are admin/admin" | Field | Description |
|---|---|
value | Required. The note text. Supports @variable@ template substitution. |
type | Optional validation type: url, phone, or email. Omit for plain text. |
Dependencies
Packages can declare dependencies on other packages. Dependencies share the parent's podman network, allowing containers in the same dependency tree to communicate directly by container name via podman's built-in DNS.
dependencies:
db:
package: postgres
responses:
password: "@dbpass@"
user: "mattermost"
database: "mattermost"
port: "5432" | Field | Description |
|---|---|
package | Required. Name of the dependency package to install. |
repo | Repository containing the dependency. Defaults to the parent's repository. |
version | Version to install. Defaults to the latest available version. |
responses | Question responses for the dependency. Values support @variable@ syntax from parent questions. |
Parent packages receive environment variables for each dependency at runtime:
TOWNOS_DEP_{KEY}_HOST (the container name) and
TOWNOS_DEP_{KEY}_PORT_{port} (container-side port number).
Parents can also use @dep_KEY_host@ and @dep_KEY_port_N@
template variables in their environment values (see Template System).
Example: A Mattermost package with a db dependency on PostgreSQL
can reference the database host in its datasource URL:
environment:
MM_SQLSETTINGS_DATASOURCE: "postgres://mattermost:@dbpass@@@dep_db_host@:@dep_db_port_5432@/mattermost?sslmode=disable" Template System
Town OS has two template systems that operate at different stages of compilation.
@variable@ Substitution
The @variable@ syntax is substituted during package compilation across all
configurable fields: environment values, network port mappings, network domains, volume
mountpoints, volume quotas, volume git URLs, git source URLs and branches, template
volume and path fields, VM image and memory, Proton configuration, and note values.
Each question name becomes a variable. To include a literal @ character
(e.g. for a git SSH URL like git@@domain@), use @@ — two
consecutive @ characters produce one literal @.
Unresolved variables (referencing a name with no matching question response) are left as-is in the output.
Built-in Variables
| Variable | Description |
|---|---|
@LOCAL_EXTERNAL_HOST@ | The external hostname or IP of the Town OS host |
@LOCAL_INTERNAL_HOST@ | The internal hostname or IP of the Town OS host |
@PACKAGE_DNS@ | The DNS name assigned to this package on the internal network |
@dep_KEY_host@ | Container hostname for dependency KEY (resolvable via podman DNS on the shared network). Only available when the package declares dependencies. |
@dep_KEY_port_N@ | Container port N for dependency KEY. Only available when the package declares dependencies. |
Built-in variables are substituted before user question responses, so they take precedence.
Dependency template variables (@dep_*@) are resolved after dependency
installation and applied to the parent's environment values. KEY is the lowercase
dependency key name, and N is the container port number.
Go Templates (in templates field)
The templates field uses Go text/template syntax
({{ .Responses.name }}) for rendering files written to volumes.
See the Templates section for available data context.
VM Runtime
Packages can run virtual machines instead of containers by specifying a vm
field instead of image:
vm:
image: "https://example.com/my-vm.qcow2"
memory: 2gb
cpus: 2
description: A virtual machine package | Field | Description |
|---|---|
image | Required. URL (http/https) or filename of the VM disk image. May contain @variable@ templates. |
memory | Memory allocation using byte suffixes (e.g. 1gb, 512mb). Defaults to 1gb. May contain @variable@ templates. |
cpus | Number of virtual CPUs. Defaults to 1. Must be non-negative. |
The vm field is mutually exclusive with image and proton.
VM images are not normalized like container images.
Proton Runtime
Packages can run Windows applications via Proton by specifying a proton field:
image:
type: oci
proton:
app_image: "mycompany/windows-app:1.0"
app_directory: /app
volume: app
exe: /app/myapp.exe
args: ["-fullscreen", "-config", "/app/config.ini"]
volumes:
app:
mountpoint: /app | Field | Description |
|---|---|
app_image | Required. Container image containing the Windows application. |
app_directory | Required. Absolute path where the application is located. |
volume | Required. Name of a volume defined in this package. |
exe | Required. Path to the Windows executable. |
args | Optional list of command-line arguments. |
When proton is set, the container command is automatically generated as
["proton", "run", <exe>, ...<args>]. The container image
defaults to the system-wide proton_image setting
(quay.io/town/proton:latest), which can be overridden per-package by
setting image. The app_image field is normalized during
compilation using the same rules as regular container image references.
The proton field is mutually exclusive with vm and command.
Compilation
Compilation transforms a package definition and user responses into a fully resolved, runnable configuration. The compile pipeline performs these steps:
- Validates all question responses against their declared types
- Applies type-specific validation (port ranges, hostname patterns, byte parsing, etc.)
- Substitutes all
@variable@template markers with resolved values - Normalizes container image URLs to fully qualified references
- Produces a resolved package ready for installation
For VM packages, memory strings (e.g. 2gb) are parsed to byte counts and
CPU defaults are applied. Validation errors are collected across all fields and returned
together, so users can fix everything in one pass rather than hitting errors one at a time.
Response Persistence
Responses are saved per version at
responses/<repo>/<pkg>/<version>.json.
A last copy is also saved at
responses/last/<repo>/<pkg>.json for reuse during upgrades
and reinstallation from uninstalled volumes.
Last responses are cleared after a successful install. This means that if a package is uninstalled and later reinstalled, the previous answers are offered as defaults. Once the new install succeeds, the cached copy is removed.
Installation Workflow
Installing a package runs through a defined sequence of steps. The process handles everything from file setup through service startup:
- Hard link creation from the repository package file to the installed directory
- Response persistence (version-specific and
lastcopy) - Volume creation with quotas and optional UID/GID ownership
- Volume seeding from archives and git sources (container runtime only)
- Template application (files rendered into volumes)
- Systemd unit generation (Podman-based for containers, QEMU-based for VMs)
- Network state file creation
- Service start
- Last response clearing on success
Two optional flags control volume behavior:
reuse_volumes— reuse volumes from a previous uninstalled version of the same packageimport_from_version— import volumes from a specific prior version
Uninstallation
Uninstalling a package preserves volume data by default.
Volumes are moved from the installed/ prefix to the uninstalled/
prefix rather than being deleted. This allows reinstallation with the original data intact.
The uninstall process also removes the network state file and stops, disables, and uninstalls the associated systemd units.
To delete volumes immediately instead of preserving them, use the purge_volumes
flag. Purged volumes cannot be recovered.
Install Preview
Before committing to an install, you can preview what would be created.
The install preview endpoint (POST /packages/install-preview) returns a
summary of the planned installation without making any changes:
- Volumes that would be created
- Ports that would be mapped
- Upgrade information (if upgrading from a previous version)
- Runtime type (container or VM)
- Whether the package has questions to answer
- VM configuration details (image, memory, CPUs) for VM packages
Featured Packages
A package repository can include a featured.json file at its root
(alongside the packages/ directory) to highlight selected packages in the UI.
The file contains a JSON array of package name strings — no version or repository prefix:
["wordpress", "nextcloud", "postgres"]
Packages listed in featured.json appear with featured: true in
the API's package list response, allowing the UI to highlight them. The file is optional
— if absent, no packages are featured.
Importing Repositories
Once you've created a package repository, add it to your Town OS instance using either the web UI or direct filesystem configuration.
Via the UI
Log in to the Town OS dashboard, navigate to Packages, select the Repositories tab, and click Add Repository. Provide a name, the git URL, and optional credentials. Click Refresh to pull package metadata immediately.
Via the Filesystem
Edit repositories.json in the btrfs package data directory. The file is a
JSON array of objects:
[
{"name": "default", "url": "https://gitea.com/town-os/default-packages"},
{"name": "my-packages", "url": "https://github.com/myuser/my-packages"}
] Order matters — later entries override earlier ones when package names collide. For private repositories, embed credentials in the URL. Restart the service or hit Refresh in the UI for changes to take effect.
Validation Rules
The following constraints are enforced during package validation and compilation:
| Field | Rule |
|---|---|
| Image URL | Non-empty; characters must match ^[a-zA-Z0-9@][a-zA-Z0-9._:/@-]*$ |
| Image type | Empty (defaults to oci) or oci |
| Environment keys | Must match ^[a-zA-Z_][a-zA-Z0-9_]*$ (POSIX convention) |
| Question names | Must match ^[a-zA-Z0-9]+$ |
| Volume names | Must match ^[a-zA-Z0-9][a-zA-Z0-9._-]*$ |
| Mountpoints | Must start with / |
| Template names | Same rules as volume names |
| Template paths | Non-empty, relative (no leading /), no .. traversal |
| Archive directory | Must be an absolute path |
| Archive/git volume | Must reference a volume defined in the package |
| Git URLs | Must have a valid scheme and host (except file://) |
| VM CPUs | Must be non-negative |
| Proton app_directory | Must be an absolute path |
| Runtime | Exactly one of image/proton (container) or vm |
Validation runs before template substitution. Fields containing @variable@
markers skip path and URL validation until after compilation resolves the templates.
Style Guidelines
- Omit empty maps (
environment:,internal:,volumes:) — leave them out entirely. - Omit
typeon questions that accept free-form text — do not writetype:with no value. - Include
descriptionwith a short summary of what the package is. - Include
supplieswith relevant capability tags when the package provides a well-known service. - Include
noteswith connection URLs and any important post-install information. - Use
@variable@templates to let users customize ports, hostnames, and credentials at install time. - Use
templatesfor config files that need Go template logic — they are only written once and preserve user edits.