Custom templates
Build your own one-click app templates in the dashboard, from a form or a docker-compose file, with generated secrets, endpoints and connection details. Includes a complete Node.js and PostgreSQL example.
A custom template describes a whole app stack once, so anyone in your organization can install it in one click. It is the same JSON format as the curated app catalog, kept private to your organization and marked Custom in the Apps catalog. It installs exactly like a built-in app.
To get there, open the app catalog (New Project → Apps, or /apps/new) and press
Custom templates at the top right (/apps/templates). That page lists your organization's templates,
each with Install, Edit, Duplicate, Export JSON and Delete, plus New template and
Import JSON. With no templates yet it offers Start from the example. The in-app version of this
guide is How to make a template in the catalog's ⋮ menu (/apps/templates/guide).
Deleting a template does not touch apps already installed from it; they keep running.
Checked as you type
The builder validates the template on every change with the same function the server runs when you
save. Every problem is shown with its path (services[1].image, configFields[0].key), so a
template the builder accepts is a template the API accepts.
Build a template in the builder
Press New template (or Start from the example, or Load the example inside the builder). The builder has two views, Form and Template JSON, and a side panel with a Catalog preview of the card, a Check list (Ready to save, or the number of errors and warnings), and Save template.
- Basics: Name, ID (lowercase letters, digits and hyphens; it can't change after saving),
Description, Logo (a simpleicons.org slug such as
postgresql, or anhttps://image URL), Category and Tags (comma separated, used by the catalog search). - Services: paste a Compose file in docker-compose mode, or edit the array in Services JSON mode. Every service needs an image. The builder converts the Compose file, lists what it changed ("Converted with notes"), and offers ports it found as endpoints ("Add them").
- Config fields: Add config field for every password, key or setting that changes from one install
to the next. Pick the Key, the service it is Written to, a Label, and either a
Default value or Generate a random secret. Tick Store encrypted for secrets. Copy reference
copies
{{config:KEY}}for use in environment values. - Endpoints: Add endpoint per port the install wizard should offer: Public web (HTTP), Private TCP (internal only), or TCP port that can be published.
- Connection details: the card shown after install. Add output per URL or credential; mark
credentials Secret (masked) and URLs Open as a link, and set an Env key (such as
DATABASE_URL) so another project can receive the value.
Press Save template. The template appears in the Apps catalog marked Custom, and installs like any one-click app.
Anatomy of a template
| Part | What it is |
|---|---|
id, name, description, logo, category, tags | How the app appears in the catalog. id is a lowercase slug and cannot change after saving. logo is a simpleicons slug or an https:// image URL. |
services | The containers. Each has an image and optionally ports, environment, secretEnv, volumes, dependsOn, command, healthcheck and restart. |
configFields | Values decided at install, generated or taken from a default, and written into one service's environment. |
endpoints | What the install wizard offers to expose: public web (HTTP) or private TCP. |
connection | The URLs and credentials shown after install and handed to projects that connect to the app. |
Fields the form doesn't show (settings, prepare, files, provides, requires) can be edited in
the builder's Template JSON view. They are documented in the
field reference.
A complete example
A Node.js web app with its own PostgreSQL database. The database password is generated at install,
shared by both services, and handed out as a DATABASE_URL. In the builder, Load the example
fills in exactly this template.
As a docker-compose file
Paste this into the builder's docker-compose editor. Every ${VAR} in an environment value
becomes a config field; a name that looks like a credential (POSTGRES_PASSWORD, SESSION_SECRET)
is generated at install and stored encrypted.
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_DB: app
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: pg_isready -U app -d app
interval: 5s
retries: 10
restart: unless-stopped
web:
image: ghcr.io/your-org/node-app:1.0.0
expose:
- "3000"
depends_on:
- db
environment:
NODE_ENV: production
PORT: "3000"
DATABASE_URL: postgresql://app:${POSTGRES_PASSWORD}@db:5432/app
SESSION_SECRET: ${SESSION_SECRET}
restart: unless-stopped
volumes:
db_data: {}As template JSON
{
"$schema": "https://synquic.tech/app.schema.json",
"id": "node-postgres",
"name": "Node app + Postgres",
"description": "A Node.js web app with its own PostgreSQL database and a password generated at install.",
"kind": "template",
"logo": "nodedotjs",
"category": "backend",
"tags": ["node", "postgres"],
"services": [
{
"name": "db",
"image": "postgres:16-alpine",
"environment": { "POSTGRES_USER": "app", "POSTGRES_DB": "app" },
"secretEnv": ["POSTGRES_PASSWORD"],
"volumes": ["db_data:/var/lib/postgresql/data"],
"healthcheck": { "test": "pg_isready -U app -d app", "interval": "5s", "retries": 10 },
"restart": "unless-stopped"
},
{
"name": "web",
"image": "ghcr.io/your-org/node-app:1.0.0",
"exposed": true,
"exposedPort": 3000,
"dependsOn": ["db"],
"environment": {
"NODE_ENV": "production",
"PORT": "3000",
"DATABASE_URL": "postgresql://app:{{config:POSTGRES_PASSWORD}}@db:5432/app"
},
"secretEnv": ["DATABASE_URL", "SESSION_SECRET"],
"restart": "unless-stopped"
}
],
"configFields": [
{ "key": "POSTGRES_PASSWORD", "service": "db", "label": "Database password",
"help": "Generated at install. Also used inside the app's DATABASE_URL.",
"generate": "secret", "secret": true },
{ "key": "SESSION_SECRET", "service": "web", "label": "Session secret",
"help": "Signs login sessions. Generated at install.",
"generate": "secret", "secret": true }
],
"endpoints": [
{ "service": "web", "port": 3000, "label": "Web app", "kind": "http" },
{ "service": "db", "port": 5432, "label": "Database (private)", "kind": "tcp",
"scope": "internal", "defaultMode": "internal", "allowedModes": ["internal"] }
],
"connection": {
"title": "Node app",
"description": "Open the app, or hand its database to another project.",
"outputs": [
{ "id": "url", "label": "App URL", "source": "publicUrl:web", "kind": "url", "recommended": true },
{ "id": "database-url", "label": "DATABASE_URL",
"source": "template:postgresql://app:{{env:db:POSTGRES_PASSWORD}}@db:5432/app",
"service": "db", "envKey": "DATABASE_URL", "secret": true }
]
}
}What each part does
dbrunspostgres:16-alpine. Its data lives in the named volumedb_data, and itshealthcheckletswebwait until the database accepts connections.POSTGRES_PASSWORDis a config field that generates a random secret on servicedb, so the database starts with that password.webgets the same value through{{config:POSTGRES_PASSWORD}}inside itsDATABASE_URL.secretEnvlists the variables stored encrypted.DATABASE_URLis listed because it contains the password.- Endpoints:
web:3000is public web, so the installer can give it a domain.db:5432is private: other services reach it atdb:5432and it is never published. - The connection output rebuilds
DATABASE_URLfrom the database's stored password, so another project can connect to it in one click.
Placeholders
| Placeholder | Where | Becomes |
|---|---|---|
{{config:KEY}} | environment values, files, inline build files | The value of the config field KEY. |
{{publicUrl:service}} / {{publicUrl:service:port}} | environment, files, connection values | The service's public URL, filled in at deploy once a domain or port is chosen. |
{{env:service:KEY}} | Connection values (template: sources) | A stored environment variable of that service. |
{{host}} | Connection values | The server's address, for apps installed without a domain. |
$KEY | command, healthcheck | Expanded by the container's shell at runtime. Config fields are already in the service's environment. |
${VAR} | docker-compose editor only | Converted to {{config:VAR}} plus a config field named VAR. |
Commands are not templated
{{config:KEY}} is replaced in environment values and files, not in command. Substituting a
secret into a command would store it in plain text on the service. Write $KEY instead and make sure
the config field writes to that same service; the builder warns when it sees {{config: in a command.
Ports and domains
- Services talk to each other over the project network, by service name and container port. They
need no
portsfor that. - An endpoint is what the install wizard asks about. A public web endpoint can get a domain (routed through the edge, with HTTPS) or a published port. A private TCP endpoint stays inside the project network.
- No domain is ever invented. The person installing chooses. Ports declared on a service are only published on the server's loopback interface; the wizard's choice decides what is reachable from outside.
- A host port in
portsis a preference. When another app on the same server already uses it, the install (and the app's first deploy) moves it to the next free port and says which port it took, so two installs of one template on one server both work. See when a host port is taken.
In the builder, endpoints are the single source of truth: each service's exposed, exposedPort and
routes are derived from its public web endpoints when the template is saved.
Volumes
Use named volumes (name:/path/in/container) for data that must survive redeploys. Named volumes
belong to the installed project, so two installs never share data. Avoid host paths such as ./data
or /srv/app: they depend on one server's disk. See Persistent storage.
Secrets
Never write a password into a template. Add a config field with Generate a random secret, reference
it with {{config:KEY}}, and list every variable that contains it in the service's secretEnv so it is
stored encrypted.
Each install generates its own values. Two config fields with the same generateGroup get the same
value, which is how a database password matches on the database and in the app's connection string.
Secret formats
A generated secret is 32 random bytes written as 64 hex characters unless the field says otherwise. When
an app checks the shape of a key (a base64 key, a Laravel APP_KEY, a UUID, "exactly 32 characters"), set
secretFormat on the config field in the Template JSON view; the Generate a random secret checkbox
in the form always makes the default shape. For example:
{ "key": "APP_KEY", "service": "app", "label": "App key",
"generate": "secret", "secretFormat": "laravel-app-key", "secret": true }The available formats (hex, base64, base64url, alphanumeric, laravel-app-key, uuid), their
size options (secretBytes, secretLength) and the matching minEngine rule are listed under
Secret formats in the App reference. The builder checks them with the same
rules as the server.
Common mistakes
- An unpinned image (
nginx,nginx:latest). Every install gets whatever is newest that day. Pin a tag. localhostbetween services. Each service is its own container; use the service name as the host.{{config:KEY}}in a command. Use$KEY(see above).{{config:KEY}}with no config field namedKEY. The value would be empty, so it is rejected.- A fixed password in
environment. Generate it with a config field. - No endpoint for the web interface. The app installs, but nobody is offered a URL.
- A database as a public web endpoint. Use a private TCP endpoint.
- Planning to rename the id. Installed apps point at it, so it is fixed once saved. Duplicate the template to use a new id.
Save a project as a template
Open a project's Advanced tab; the project menu (⋮) next to its name has Save as template. It opens the builder ("Drafted from a project") with the project's image-based services, ports, volumes and environment filled in. Secret values are never copied: each secret becomes a config field generated at install. Services built from source are skipped, because a template cannot carry your code; push their image to a registry first.
Import, export and AI agents
- Export downloads the template as
<id>.json, with"$schema"set for editor autocomplete. Keep it in git, or import it on another Synq instance. - Import accepts a file or pasted JSON and checks it with the same rules before anything is saved. You can save it directly or open it in the builder.
- Trust: templates are per organization and always marked Custom. Synq never reviews them, so only install templates you trust.
- API and MCP: AI agents connected over MCP can validate templates, convert compose files and create templates through the same Apps API the builder uses.
synq app custom from-compose docker-compose.yml # services, config fields, endpoints
synq app custom validate my-app.json # every error and warning, with paths
synq app custom add my-app.json # create (or replace) the template
synq app custom export my-app > my-app.json # the JSON as authoredSee also
- Add an app: contribute a template to the curated catalog
- App catalog JSON: every field, in detail
- Apps API: catalog, install, custom apps, settings, connection
Add an app
Write the catalog JSON that makes a one-click Synq app, built up field by field from a bare service to a complete submission — plus how to ship it as a pull request or upload it privately to your own organization.
The dashboard
A tour of the Synq dashboard — the sidebar that navigates everything, the home overview, and a map to each area.