# Custom templates
URL: https://synquic.tech/docs/guides/custom-templates.md

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](/docs/reference/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.

<Callout title="Checked as you type" type="info">
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.
</Callout>

## 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](/docs/guides/one-click-apps).

## 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](https://simpleicons.org) 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](/docs/reference/app-catalog).

## 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.

```yaml
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

```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

| 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`. |

<Callout title="Commands are not templated" type="warn">
`{{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.
</Callout>

## 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](/docs/guides/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](/docs/guides/compose-multi-service#when-a-host-port-is-already-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](/docs/guides/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:

```json
{ "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](/docs/reference/app-catalog#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](/docs/mcp) can validate templates, convert compose
  files and create templates through the same [Apps API](/docs/api/apps#custom-apps) the builder uses.

```bash
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

- [Add an app](/docs/guides/add-an-app): contribute a template to the curated catalog
- [App catalog JSON](/docs/reference/app-catalog): every field, in detail
- [Apps API](/docs/api/apps): catalog, install, custom apps, settings, connection
