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 to the host. Keys are host ports, values are container ports. Both are "port" strings and may contain @variable@ templates. |
network.internal | Port mappings available only between containers on the internal network. |
network.domains | List of domain names associated with this package. 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.
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. |
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. |
| (omitted) | Any string — no validation |
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.