Guides

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.

  1. Basics: Name, ID (lowercase letters, digits and hyphens; it can't change after saving), Description, Logo (a simpleicons.org slug such as postgresql, or an https:// image URL), Category and Tags (comma separated, used by the catalog search).
  2. 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").
  3. 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.
  4. Endpoints: Add endpoint per port the install wizard should offer: Public web (HTTP), Private TCP (internal only), or TCP port that can be published.
  5. 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

PartWhat it is
id, name, description, logo, category, tagsHow 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.
servicesThe containers. Each has an image and optionally ports, environment, secretEnv, volumes, dependsOn, command, healthcheck and restart.
configFieldsValues decided at install, generated or taken from a default, and written into one service's environment.
endpointsWhat the install wizard offers to expose: public web (HTTP) or private TCP.
connectionThe 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

  • db runs postgres:16-alpine. Its data lives in the named volume db_data, and its healthcheck lets web wait until the database accepts connections.
  • POSTGRES_PASSWORD is a config field that generates a random secret on service db, so the database starts with that password. web gets the same value through {{config:POSTGRES_PASSWORD}} inside its DATABASE_URL.
  • secretEnv lists the variables stored encrypted. DATABASE_URL is listed because it contains the password.
  • Endpoints: web:3000 is public web, so the installer can give it a domain. db:5432 is private: other services reach it at db:5432 and it is never published.
  • The connection output rebuilds DATABASE_URL from the database's stored password, so another project can connect to it in one click.

Placeholders

PlaceholderWhereBecomes
{{config:KEY}}environment values, files, inline build filesThe value of the config field KEY.
{{publicUrl:service}} / {{publicUrl:service:port}}environment, files, connection valuesThe 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 valuesThe server's address, for apps installed without a domain.
$KEYcommand, healthcheckExpanded by the container's shell at runtime. Config fields are already in the service's environment.
${VAR}docker-compose editor onlyConverted 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 ports for 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 ports is 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.
  • localhost between 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 named KEY. 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 authored

See also

On this page