We've finally reached our first beta. Please ensure you are using our DNS server.

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

FieldDescription
imageRequired (container runtime). Container image reference. Can be a string or an object with url and optional type. Mutually exclusive with vm.
descriptionShort human-readable summary of the package.
suppliesList of semantic capability tags this package provides (e.g. ["database"], ["http"]).
commandOptional command override for the container. Mutually exclusive with proton.
environmentEnvironment variables passed to the container. Keys must match ^[a-zA-Z_][a-zA-Z0-9_]*$. Values may contain @variable@ template markers.
networkPort mapping and domain configuration.
volumesNamed volumes with mount configuration.
questionsInteractive prompts shown during installation. Names must be alphanumeric (^[a-zA-Z0-9]+$).
archivesArchive extraction specs to pre-populate volumes from container images.
git_sourcesGit repositories to clone into volumes.
templatesFile templates rendered with Go text/template and written to volumes on install.
notesKey-value metadata displayed after installation. Supports template substitution.
vmVirtual machine configuration. Mutually exclusive with image and proton.
protonWindows 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:

InputNormalized To
nginxdocker.io/library/nginx:latest
myuser/myappdocker.io/myuser/myapp:latest
ghcr.io/org/appghcr.io/org/app:latest
nginx:1.26-alpinedocker.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:

TagUsed ForExamples
httpWeb servers, CMS platforms, web applicationsnginx, wordpress, gitea
databaseRelational and NoSQL databasespostgres, mysql, mongo
cacheCaching and key-value storesredis, memcached, valkey
searchSearch and analytics engineselasticsearch, opensearch, solr
messagingMessage brokers and queuesrabbitmq, nats, kafka
monitoringMetrics, alerting, and visualizationprometheus, grafana, telegraf
storageObject storage and file hostingminio, registry, nextcloud

Network Configuration

FieldDescription
network.externalPort mappings exposed to the host. Keys are host ports, values are container ports. Both are "port" strings and may contain @variable@ templates.
network.internalPort mappings available only between containers on the internal network.
network.domainsList 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

FieldDescription
mountpointRequired. Absolute path inside the container where the volume is mounted (must start with /).
quotaOptional size limit (e.g. 512mb, 2gb, 1tb). Supports mb, gb, tb suffixes. May contain @variable@ templates.
archiveOptional archive filename to pre-populate the volume with.
gitOptional git repository URL to clone into the volume.
uidOptional numeric user ID for volume ownership.
gidOptional 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
FieldDescription
imageRequired. Container image to extract files from.
directoryRequired. Absolute path in the container image to extract.
volumeRequired. 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
FieldDescription
urlRequired. Git repository URL (http, https, or ssh). May contain @variable@ templates.
branchBranch to clone. May contain @variable@ templates.
volumeRequired. 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"
FieldDescription
queryRequired. The prompt text shown to the user.
typeOptional validation type. Omit for free-form text.
defaultOptional default value suggested to the user.

Question Types

TypeValidates
hostnameLowercase letter followed by lowercase alphanumerics and hyphens (pattern: ^[a-z][a-z0-9-]*$). Auto-generates <package-name>-<4-char-hex> when empty.
portInteger between 1 and 65535. Auto-generates a random available port in the range 10000-60000 when empty or set to "auto".
bytesInteger or number with tb, gb, or mb suffix (case-insensitive)
volumeAlphanumeric characters, hyphens, and underscores (pattern: ^[a-zA-Z0-9-_]+$)
archiveAny non-empty string
secretAuto-generates a 64-character hex string (256 bits) when empty or set to "auto". Can be overridden with an explicit value.
durationInteger 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 }}
FieldDescription
volumeRequired. Name of a volume defined in this package.
pathRequired. Relative path within the volume. Must not start with / or contain ...
contentRequired. Go text/template content to render.

Template Data Context

The following data is available inside {{ }} expressions:

ExpressionDescription
.Responses.<name>User's answer to the named question
.Package.NamePackage name
.Package.VersionPackage version
.Package.RepoRepository name
.Package.ImageCompiled container image reference
.Package.DescriptionPackage description
.System.HostnameSystem hostname
.System.ExternalIPExternal IP address (if known)
.System.InternalIPInternal/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"
FieldDescription
valueRequired. The note text. Supports @variable@ template substitution.
typeOptional 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"
FieldDescription
packageRequired. Name of the dependency package to install.
repoRepository containing the dependency. Defaults to the parent's repository.
versionVersion to install. Defaults to the latest available version.
responsesQuestion 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

VariableDescription
@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
FieldDescription
imageRequired. URL (http/https) or filename of the VM disk image. May contain @variable@ templates.
memoryMemory allocation using byte suffixes (e.g. 1gb, 512mb). Defaults to 1gb. May contain @variable@ templates.
cpusNumber 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
FieldDescription
app_imageRequired. Container image containing the Windows application.
app_directoryRequired. Absolute path where the application is located.
volumeRequired. Name of a volume defined in this package.
exeRequired. Path to the Windows executable.
argsOptional 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 last copy)
  • 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 package
  • import_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

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:

FieldRule
Image URLNon-empty; characters must match ^[a-zA-Z0-9@][a-zA-Z0-9._:/@-]*$
Image typeEmpty (defaults to oci) or oci
Environment keysMust match ^[a-zA-Z_][a-zA-Z0-9_]*$ (POSIX convention)
Question namesMust match ^[a-zA-Z0-9]+$
Volume namesMust match ^[a-zA-Z0-9][a-zA-Z0-9._-]*$
MountpointsMust start with /
Template namesSame rules as volume names
Template pathsNon-empty, relative (no leading /), no .. traversal
Archive directoryMust be an absolute path
Archive/git volumeMust reference a volume defined in the package
Git URLsMust have a valid scheme and host (except file://)
VM CPUsMust be non-negative
Proton app_directoryMust be an absolute path
RuntimeExactly 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 type on questions that accept free-form text — do not write type: with no value.
  • Include description with a short summary of what the package is.
  • Include supplies with relevant capability tags when the package provides a well-known service.
  • Include notes with connection URLs and any important post-install information.
  • Use @variable@ templates to let users customize ports, hostnames, and credentials at install time.
  • Use templates for config files that need Go template logic — they are only written once and preserve user edits.