Skip to content

Configuration Files ​

YAML, .env, nginx and TOML files from deployment data. Most of these formats depend on indentation, and Tsuku's whitespace control is built for that: a line that holds only directives (@if(...), @for(...), @end) prints nothing at all, so directives can be indented like the configuration around them, while content lines keep their own indentation.

Each example has the template, the data, the generated output and the PHP call (how the examples work).

Application settings (YAML) ​

Booleans come from @?{app.debug "true" : "false"}: Tsuku has no boolean literals, and {app.debug} would print 1 or nothing. Strings that can contain YAML syntax - : , #, quotes - go through @json(); YAML 1.2 reads JSON strings, so "Kumo Tea: Shop" and the password stay intact. The cache section is one of two blocks, and the retry block is left out for a queue without retries.

tsuku-yaml
app:
  name: @json(app.name)
  env: {app.env}
  debug: @?{app.debug "true" : "false"}
  url: {app.url}

database:
  host: {db.host}
  port: {db.port}
  name: {db.name}
  user: {db.user}
  password: @json(db.password)

cache:
  @if(cache.driver == "redis")
  driver: redis
  host: {cache.host}
  port: {cache.port}
  @else
  driver: file
  path: {cache.path}
  @end

queues:
  @for(queues as queue)
  - name: {queue.name}
    workers: {queue.workers}
    @if(queue.retry)
    retry:
      attempts: {queue.retry.attempts}
      backoff: {queue.retry.backoff}s
    @end
  @end
php
<?php

return [
    'app' => ['name' => 'Kumo Tea: Shop', 'env' => 'production', 'debug' => false, 'url' => 'https://shop.example.com'],
    'db' => ['host' => 'db.internal', 'port' => 3306, 'name' => 'shop', 'user' => 'shop', 'password' => 'p4ss: #1 "x"'],
    'cache' => ['driver' => 'redis', 'host' => 'redis.internal', 'port' => 6379, 'path' => null],
    'queues' => [
        ['name' => 'emails', 'workers' => 2, 'retry' => ['attempts' => 5, 'backoff' => 30]],
        ['name' => 'feeds', 'workers' => 1, 'retry' => null],
    ],
];
yaml
app:
  name: "Kumo Tea: Shop"
  env: production
  debug: false
  url: https://shop.example.com

database:
  host: db.internal
  port: 3306
  name: shop
  user: shop
  password: "p4ss: #1 \u0022x\u0022"

cache:
  driver: redis
  host: redis.internal
  port: 6379

queues:
  - name: emails
    workers: 2
    retry:
      attempts: 5
      backoff: 30s
  - name: feeds
    workers: 1
php
echo $tsuku->process($template, $data);

Docker Compose ​

Every optional section of a service - build or image, ports, environment, volumes, depends_on - is an @if around a nested @for, indented exactly like the YAML it produces. A missing key and an empty list both count as false, so redis gets only its image. @for(service.environment as value, key) writes a map from an associative array.

tsuku-yaml
services:
  @for(services as service)
  {service.name}:
    @if(service.build)
    build: {service.build}
    @else
    image: {service.image}
    @end
    @if(service.ports)
    ports:
      @for(service.ports as port)
      - "{port}"
      @end
    @end
    @if(service.environment)
    environment:
      @for(service.environment as value, key)
      {key}: @json(value)
      @end
    @end
    @if(service.volumes)
    volumes:
      @for(service.volumes as volume)
      - {volume}
      @end
    @end
    @if(service.depends_on)
    depends_on:
      @for(service.depends_on as dependency)
      - {dependency}
      @end
    @end
  @end

volumes:
  @for(volumes as volume)
  {volume}:
  @end
php
<?php

return [
    'services' => [
        [
            'name' => 'web',
            'build' => '.',
            'ports' => ['8080:80'],
            'environment' => ['APP_ENV' => 'dev', 'DB_HOST' => 'db'],
            'volumes' => ['./src:/var/www/html'],
            'depends_on' => ['db', 'redis'],
        ],
        [
            'name' => 'db',
            'image' => 'mysql:8.4',
            'environment' => ['MYSQL_DATABASE' => 'shop', 'MYSQL_ROOT_PASSWORD' => 'change-me'],
            'volumes' => ['db-data:/var/lib/mysql'],
        ],
        ['name' => 'redis', 'image' => 'redis:7-alpine'],
    ],
    'volumes' => ['db-data'],
];
yaml
services:
  web:
    build: .
    ports:
      - "8080:80"
    environment:
      APP_ENV: "dev"
      DB_HOST: "db"
    volumes:
      - ./src:/var/www/html
    depends_on:
      - db
      - redis
  db:
    image: mysql:8.4
    environment:
      MYSQL_DATABASE: "shop"
      MYSQL_ROOT_PASSWORD: "change-me"
    volumes:
      - db-data:/var/lib/mysql
  redis:
    image: redis:7-alpine

volumes:
  db-data:
php
echo $tsuku->process($template, $data);

Kubernetes deployment ​

Deeply nested YAML with a list of containers. The second container has no port and no resource limits, and those sections disappear without leaving blank lines or stray indentation behind.

tsuku-yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {app.name}
  labels:
    @for(app.labels as value, key)
    {key}: {value}
    @end
spec:
  replicas: {app.replicas}
  selector:
    matchLabels:
      app: {app.name}
  template:
    metadata:
      labels:
        app: {app.name}
    spec:
      containers:
        @for(app.containers as container)
        - name: {container.name}
          image: "{container.image}:{container.tag}"
          @if(container.port)
          ports:
            - containerPort: {container.port}
          @end
          env:
            @for(container.env as value, name)
            - name: {name}
              value: @json(value)
            @end
          @if(container.resources)
          resources:
            limits:
              cpu: {container.resources.cpu}
              memory: {container.resources.memory}
          @end
        @end
php
<?php

return [
    'app' => [
        'name' => 'shop-web',
        'labels' => ['app' => 'shop-web', 'team' => 'commerce'],
        'replicas' => 3,
        'containers' => [
            [
                'name' => 'php-fpm',
                'image' => 'registry.example.com/shop/php-fpm',
                'tag' => '2026.10.3',
                'port' => 9000,
                'env' => ['APP_ENV' => 'production', 'REDIS_HOST' => 'redis.internal'],
                'resources' => ['cpu' => '500m', 'memory' => '768Mi'],
            ],
            [
                'name' => 'log-shipper',
                'image' => 'fluent/fluent-bit',
                'tag' => '3.1',
                'port' => null,
                'env' => ['FLB_LOG_LEVEL' => 'warn'],
                'resources' => null,
            ],
        ],
    ],
];
yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: shop-web
  labels:
    app: shop-web
    team: commerce
spec:
  replicas: 3
  selector:
    matchLabels:
      app: shop-web
  template:
    metadata:
      labels:
        app: shop-web
    spec:
      containers:
        - name: php-fpm
          image: "registry.example.com/shop/php-fpm:2026.10.3"
          ports:
            - containerPort: 9000
          env:
            - name: APP_ENV
              value: "production"
            - name: REDIS_HOST
              value: "redis.internal"
          resources:
            limits:
              cpu: 500m
              memory: 768Mi
        - name: log-shipper
          image: "fluent/fluent-bit:3.1"
          env:
            - name: FLB_LOG_LEVEL
              value: "warn"
php
echo $tsuku->process($template, $data);

Environment file ​

A .env file per environment. @match(cache.driver) writes the settings of the configured cache only; the # line is plain text to Tsuku and is copied into the file. Values with spaces are quoted in the template. Values that can contain ", $ or line breaks need a registered function that escapes them the way your dotenv parser expects.

tsuku
# {app.env} settings, generated @date("Y-m-d", generated_at). Do not edit by hand.
APP_ENV={app.env}
APP_DEBUG=@?{app.debug "true" : "false"}
APP_URL={app.url}

DB_HOST={db.host}
DB_PORT={db.port}
DB_DATABASE={db.name}

CACHE_DRIVER={cache.driver}
@match(cache.driver)
@case("redis")
REDIS_HOST={cache.host}
REDIS_PORT={cache.port}
@case("memcached")
MEMCACHED_HOST={cache.host}
@end

MAIL_FROM_ADDRESS={mail.address}
MAIL_FROM_NAME="{mail.name}"
php
<?php

// Values from your own deployment config. Quote the ones with spaces; values that can
// contain '"', '$' or line breaks need a function that escapes them for your dotenv parser.
return [
    'generated_at' => strtotime('2026-10-25'),
    'app' => ['env' => 'staging', 'debug' => true, 'url' => 'https://staging.shop.example.com'],
    'db' => ['host' => '10.0.2.15', 'port' => 3306, 'name' => 'shop_staging'],
    'cache' => ['driver' => 'redis', 'host' => '10.0.2.16', 'port' => 6379],
    'mail' => ['address' => '[email protected]', 'name' => 'Kumo Tea (staging)'],
];
dotenv
# staging settings, generated 2026-10-25. Do not edit by hand.
APP_ENV=staging
APP_DEBUG=true
APP_URL=https://staging.shop.example.com

DB_HOST=10.0.2.15
DB_PORT=3306
DB_DATABASE=shop_staging

CACHE_DRIVER=redis
REDIS_HOST=10.0.2.16
REDIS_PORT=6379

MAIL_FROM_ADDRESS=[email protected]
MAIL_FROM_NAME="Kumo Tea (staging)"
php
echo $tsuku->process($template, $data);

nginx server block ​

A staging host: @unless(site.production) adds password protection, a loop writes one location per redirect, and @first(site.domains) names the certificate directory. nginx's own $uri and $args and the braces of its blocks are plain text to Tsuku. The blank lines between blocks live inside the @unless and the @for, so a production host without redirects gets no double blank lines.

tsuku
server {
    listen 443 ssl;
    http2 on;
    server_name @join(site.domains, " ");
    root {site.root};

    ssl_certificate     /etc/letsencrypt/live/@first(site.domains)/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/@first(site.domains)/privkey.pem;
    @unless(site.production)

    auth_basic "Staging";
    auth_basic_user_file /etc/nginx/htpasswd/{site.name};
    @end

    @for(site.redirects as redirect)
    location = {redirect.from} {
        return 301 {redirect.to};
    }

    @end
    location / {
        try_files $uri $uri/ /index.php?$args;
    }

    location ~ \.php$ {
        include fastcgi_params;
        fastcgi_pass unix:/run/php/php{site.php}-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }
}
php
<?php

return [
    'site' => [
        'name' => 'shop-staging',
        'production' => false,
        'domains' => ['staging.shop.example.com', 'staging.shop.example.nl'],
        'root' => '/var/www/shop/current/pub',
        'php' => '8.3',
        'redirects' => [
            ['from' => '/thee', 'to' => '/tea'],
            ['from' => '/sale', 'to' => '/offers'],
        ],
    ],
];
nginx
server {
    listen 443 ssl;
    http2 on;
    server_name staging.shop.example.com staging.shop.example.nl;
    root /var/www/shop/current/pub;

    ssl_certificate     /etc/letsencrypt/live/staging.shop.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/staging.shop.example.com/privkey.pem;

    auth_basic "Staging";
    auth_basic_user_file /etc/nginx/htpasswd/shop-staging;

    location = /thee {
        return 301 /tea;
    }

    location = /sale {
        return 301 /offers;
    }

    location / {
        try_files $uri $uri/ /index.php?$args;
    }

    location ~ \.php$ {
        include fastcgi_params;
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }
}
php
echo $tsuku->process($template, $data);

Cargo.toml ​

TOML arrays and inline tables written on one line: [@for(...)@if(index > 0), @end"{feature}"@end] puts a separator in front of every element but the first. The { version = ... } braces of the inline table are text, because only {plain.path} is a variable. The values here are package names and versions; free text in TOML strings would need a function that escapes " and \.

tsuku
[package]
name = "{package.name}"
version = "{package.version}"
edition = "2021"
authors = [@for(package.authors as author, index)@if(index > 0), @end"{author}"@end]

[dependencies]
@for(dependencies as dependency)
@if(dependency.features)
{dependency.name} = { version = "{dependency.version}", features = [@for(dependency.features as feature, index)@if(index > 0), @end"{feature}"@end] }
@else
{dependency.name} = "{dependency.version}"
@end
@end
php
<?php

return [
    'package' => [
        'name' => 'feed-worker',
        'version' => '0.4.2',
        'authors' => ['Anna de Vries <[email protected]>', 'Jan Kowalski <[email protected]>'],
    ],
    'dependencies' => [
        ['name' => 'serde', 'version' => '1.0', 'features' => ['derive']],
        ['name' => 'tokio', 'version' => '1.40', 'features' => ['rt-multi-thread', 'macros']],
        ['name' => 'quick-xml', 'version' => '0.36', 'features' => []],
    ],
];
toml
[package]
name = "feed-worker"
version = "0.4.2"
edition = "2021"
authors = ["Anna de Vries <[email protected]>", "Jan Kowalski <[email protected]>"]

[dependencies]
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1.40", features = ["rt-multi-thread", "macros"] }
quick-xml = "0.36"
php
echo $tsuku->process($template, $data);

Checking the result ​

Parse a generated file before it is deployed, for example with symfony/yaml:

php
use Symfony\Component\Yaml\Yaml;

$yaml = $tsuku->process($template, $data);
Yaml::parse($yaml); // throws a ParseException for invalid YAML

file_put_contents('docker-compose.yml', $yaml);

Next Steps ​

Released under the MIT License.