Infrastructure as Code

11 min read Updated 1 month ago

Infrastructure as code

Define and manage your entire application infrastructure using YAML configuration files. This allows you to version control your infrastructure, automate deployments, and ensure consistency across environments.

How it works

Infrastructure as code lets you describe your application, services, domains, and secrets in a single YAML file. When you apply this configuration, the platform automatically creates or updates all resources to match your desired state.

Configuration structure

Your infrastructure configuration follows this structure:

apiVersion: v1
kind: Infrastructure
metadata:
  name: your-app-name
  team: your-team-name-or-id
spec:
  application: {...}
  domains: [...]
  secrets: [...]
  volumes: [...]
  services: [...]
  container_services: [...]
  managed_databases: [...]
  object_storage: [...]

Complete example

Here's a comprehensive example showing all available configuration options:

apiVersion: v1
kind: Infrastructure
metadata:
  name: production-app
  team: 1  # Your team ID or name
spec:
  application:
    name: production-app
    type: laravel  # laravel, nodejs, or wordpress
    version: "11.x"
    label: Production App  # Display name (optional)
    tags:                  # Tags for organizing apps (optional)
      - production
      - api
    repository:
      url: https://github.com/yourorg/yourrepo
      owner: yourorg
      name: yourrepo
      branch: main
    runtime:
      php_version: "8.4"
      nodejs_version: "22"
    commands:
      build:
        - composer install --no-dev --optimize-autoloader
        - npm ci
        - npm run build
      init:
        - php artisan migrate --force
        - php artisan config:cache
        - php artisan route:cache
      start: php artisan serve --host=0.0.0.0 --port=8000
    settings:
      health_check_path: /health
      health_check_enabled: true
      scheduler_enabled: true
      replicas: 3
      memory: 1024Mi
      scheduled_deletion_at: "2026-12-01T00:00:00Z"  # Optional: auto-delete on this date
    php:
      extensions:
        - redis
        - imagick
        - gd
        - zip
      settings:
        - memory_limit=512M
        - max_execution_time=120
        - upload_max_filesize=100M
        - post_max_size=100M

  domains:
    - domain: app.example.com
    - domain: www.example.com
    - domain: api.example.com

  secrets:
    - key: APP_KEY
      value: base64:your_generated_key_here
    - key: DB_PASSWORD
      value: your_secure_password
    - key: STRIPE_KEY
      value: sk_live_your_stripe_key
    - key: AWS_ACCESS_KEY_ID
      value: your_aws_key
    - key: AWS_SECRET_ACCESS_KEY
      value: your_aws_secret

  volumes:
    - name: storage
      mount_path: /var/www/html/storage
      volume_size: 20
    - name: uploads
      mount_path: /var/www/html/public/uploads
      volume_size: 10

  services:
    # Database services
    - name: database
      type: mysql
      version: "8.4"
      memory: 2Gi
      volume_size: 50Gi
      settings:
        database: production

    - name: postgres
      type: postgresql
      version: "15"
      memory: 2Gi
      volume_size: 50Gi
      settings:
        database: app_production
        extensions:
          - postgis
          - pg_trgm
          - uuid-ossp

    # Cache services
    - name: cache
      type: redis
      version: "7.2"
      memory: 512Mi
      volume_size: 1Gi

    - name: sessions
      type: valkey
      version: "7.2"
      memory: 256Mi
      volume_size: 1Gi

    # Queue services
    - name: queue
      type: rabbitmq
      version: "3.12"
      memory: 1Gi
      volume_size: 5Gi

    # Storage services
    - name: files
      type: minio
      version: latest
      memory: 1Gi
      volume_size: 100Gi

    - name: ftp
      type: sftp
      version: latest
      memory: 256Mi
      volume_size: 50Gi
      settings:
        username: ftpuser
        password: secure_ftp_password

    # Worker services
    - name: default-worker
      type: worker
      memory: 1Gi
      command: php artisan queue:work --queue=default --tries=3

    - name: email-worker
      type: worker
      memory: 512Mi
      command: php artisan queue:work --queue=emails --tries=5

    - name: heavy-worker
      type: worker
      memory: 2Gi
      command: php artisan queue:work --queue=heavy --timeout=3600

  container_services:
    - name: pdf-generator
      type: gotenberg
      version: "8"
      memory: 1Gi
      settings:
        LOG_LEVEL: info
        DEFAULT_WAIT_TIMEOUT: "60"

    - name: browser
      type: chrome-headless
      version: "1.61.1-chrome-stable"
      memory: 2Gi
      settings:
        BLOCK_ADS: "false"
        MAX_CONCURRENT_SESSIONS: "20"

    - name: analytics
      type: clickhouse
      version: "23.8"
      memory: 4Gi
      volume_size: 100Gi

    - name: search
      type: meilisearch
      version: v1.32
      memory: 1Gi
      volume_size: 5Gi

Managed databases

You can provision a managed database alongside your application, or on its own. Each entry in spec.managed_databases creates or updates one managed database for your team.

spec:
  managed_databases:
    - name: analytics-db
      type: pg
      # type is mysql, pg, or valkey
      plan: 1x1xCPU-2GB-25GB
      zone: nl-ams1
      users:
        - username: analyst
          authentication: scram-sha-256
      logical_databases:
        - name: analytics
          character_set: UTF8
          collation: en_US.UTF-8
  • name — the name of the managed database, unique within your team
  • type — the database engine: mysql, pg, or valkey (MySQL, PostgreSQL, or Valkey). redis is accepted as an alias for valkey, which speaks the same protocol; it is stored and exported as valkey. Any other value is rejected
  • plan — the compute and storage size of the instance. It must be a plan the platform offers for that engine; see the plan list in the panel when you create a database
  • zone — the location to provision in, using one of the zones listed under Locations below
  • users — database users to create, each with an optional username and authentication
  • logical_databases — databases to create on the instance, each with an optional name, character_set, and collation

The database password is generated by the platform and is not part of the YAML you apply.

Object storage

Object storage is a team-level resource defined in spec.object_storage.

spec:
  object_storage:
    - name: assets
      zone: europe-1
      buckets:
        - media
      users:
        - username: deploy-bot
          policies: []
  • name — the name of the object storage, unique within your team
  • zone — the region to create it in, using a region the platform offers, for example europe-1 or us-1
  • plan — accepted but ignored. Object storage has no plan; the field is kept only so an older configuration still applies
  • buckets — bucket names to create
  • users — object storage users, each with an optional username and policies

The access key and secret key are generated by the platform and are not part of the YAML you apply.

Rejected values

A type, zone, or plan the platform cannot provision is rejected before anything is created. The request fails with 422 Unprocessable Entity and nothing in the document is applied, not the managed services and not the application, domains, secrets, volumes, or services alongside them. Re-apply once the value is corrected.

Every invalid entry is reported in one response rather than one at a time, so a large document tells you everything wrong with it in a single run. Each message names the resource, the field, and what the accepted values are:

{
  "message": "YAML validation failed",
  "errors": {
    "spec.managed_databases.analytics-db.type": [
      "Managed database 'analytics-db': 'mariadb' is not a supported type. Accepted values are mysql, pg, valkey."
    ],
    "spec.object_storage.assets.zone": [
      "Object storage 'assets': 'europe-99' is not a supported zone. Accepted values are apac-1, europe-1, europe-2, europe-3, europe-4, us-1."
    ]
  }
}

This applies to a dry run too, so dry_run=true is a reliable check that a document will apply.

Locations

The zone value must be a location the platform offers. A location the platform does not offer will fail when the resource is provisioned. Use the same values shown in the location dropdown when you create the resource in the panel.

Managed database zones:

  • au-syd1, de-fra1, dk-cph1, es-mad1, fi-hel1, fi-hel2, nl-ams1, no-svg1, pl-waw1, se-sto1, sg-sin1, uk-lon1, us-chi1, us-nyc1, us-sjo1

Object storage regions, used in the zone field of an object storage entry:

  • apac-1 (Singapore)
  • europe-1 (Helsinki)
  • europe-2 (Frankfurt)
  • europe-3 (Stockholm)
  • europe-4 (Copenhagen)
  • us-1 (Chicago)

Exporting a team

You can download all managed databases and object storage for a team as a single YAML document:

curl -X GET https://api.ploi.cloud/api/v1/teams/1/export-yaml \
  -H "Authorization: Bearer YOUR_API_TOKEN"

The exported document includes every non-secret setting. Credentials are replaced with placeholders: database passwords become \${DB_<NAME>_PASSWORD} and object storage secret keys become \${STORAGE_<NAME>_SECRET_KEY}. Applying the exported document reproduces the same configuration, and the real credentials are regenerated by the platform.

Applying your configuration

You can apply your infrastructure configuration using the API. Send a POST request with your YAML content:

curl -X POST https://api.ploi.cloud/api/v1/infrastructure/apply \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/yaml" \
  --data-binary @your-infrastructure.yaml

Dry run mode

Preview what changes would be made without actually applying them:

curl -X POST "https://api.ploi.cloud/api/v1/infrastructure/apply?dry_run=true" \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/yaml" \
  --data-binary @your-infrastructure.yaml

Manual deployment

By default, changes are automatically deployed. To apply changes without triggering a deployment:

curl -X POST "https://api.ploi.cloud/api/v1/infrastructure/apply?auto_deploy=false" \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/yaml" \
  --data-binary @your-infrastructure.yaml

Response format

The API returns details about what was changed:

{
  "success": true,
  "application_id": 123,
  "application_name": "production-app",
  "team": "Your Team",
  "dry_run": false,
  "changes": [
    "Application 'production-app' created",
    "Domain 'app.example.com' added",
    "Secret 'APP_KEY' created",
    "Service 'database' (mysql) created",
    "Managed database 'analytics-db' created, provisioning started",
    "Object storage 'assets' created, provisioning started"
  ],
  "structured_changes": {
    "application": { "action": "created", "name": "production-app" },
    "domains": [{ "action": "created", "domain": "app.example.com" }],
    "secrets": [{ "action": "created", "key": "APP_KEY" }],
    "services": [{ "action": "created", "name": "database", "type": "mysql" }],
    "managed_databases": [{ "action": "created", "name": "analytics-db" }],
    "object_storage": [{ "action": "created", "name": "assets" }]
  },
  "errors": [],
  "needs_deployment": true,
  "auto_deploy_enabled": true,
  "deployment_id": 456
}

Important notes

  • The application name must be unique within your team
  • Some fields like application type cannot be changed after creation
  • Services and volumes are automatically connected to your application
  • All resources are managed together - updating the configuration will update all resources
  • Removing items from the configuration will not delete them (for safety)
  • Managed databases and object storage can be applied on their own, without an application
  • Removing a managed database or object storage from the configuration will not delete it (for safety)
  • Changing the plan or zone of an existing managed database or object storage is not applied by the API; it is reported as a change that requires action in the panel
  • Credentials such as managed database passwords and object storage secret keys are never returned by the team export; they are replaced with placeholders
  • A managed resource is provisioned asynchronously after you apply it, so provisioning takes a little time

Automatic deletion (scheduled_deletion_at)

Setting spec.application.settings.scheduled_deletion_at to an ISO 8601 timestamp marks the application for automatic, permanent deletion at that time. Useful for ephemeral or preview environments that should clean themselves up.

When the timestamp passes, the application and all of its data — databases, persistent files, secrets, domains, and deployments — are removed. Owners receive warning emails 7 days and 1 day before deletion so they can extend or cancel.

Behavior worth knowing:

  • The field can only be enabled via the API or ploi.yaml. The dashboard cannot enable a schedule on an application that does not already have one. Once a schedule exists, the dashboard's settings tab can update the date or cancel the schedule.
  • Unlike most other items in your YAML, removing this field clears the schedule. The schedule is treated as a desired-state value, so omitting it means "no schedule".
  • Changing the date resets the warning email markers, so a re-extended schedule will warn again at the 7-day and 1-day marks.
  • Cancelling via the API: PATCH /api/v1/applications/{id} with body { "scheduled_deletion_at": null }.
spec:
  application:
    name: preview-pr-1234
    type: laravel
    settings:
      scheduled_deletion_at: "2026-05-15T03:00:00Z"  # gone three weeks after merge

Health checks (health_check_enabled)

By default every application is monitored on a health check path, and an application that stops responding there is restarted. spec.application.settings.health_check_path chooses that path.

Set health_check_enabled: false to run the application with no health checks at all. This is intended for applications whose first boot takes long enough that the platform would otherwise consider them unhealthy before they finish starting, such as a preview environment that seeds a large database on startup.

spec:
  application:
    name: preview-pr-1234
    type: laravel
    settings:
      health_check_enabled: false

With health checks disabled the application is no longer restarted automatically when it stops responding, and its status cannot be monitored. Prefer a health check path that returns quickly once the application is ready.

Three rules apply:

  • Disabling requires the explicit health_check_enabled: false. Omitting the setting leaves an existing application unchanged and gives a new application the default path for its type.
  • health_check_enabled: false cannot be combined with a health_check_path. Declaring both is rejected, rather than one silently winning.
  • health_check_path must be a non-empty path starting with /. An empty value is rejected; use health_check_enabled: false to disable instead.

Exporting an application that has health checks disabled produces health_check_enabled: false, so an exported configuration re-applies to the same state.

A long first boot can also exceed the platform's own deployment timeout, which is independent of health checks. If a deployment is still reported as failed with health checks disabled, the startup is taking longer than the platform waits for it.

For complete API documentation and additional options, see the API reference.