Ansible

Polycrate's Ansible integration: snapshot variables in playbooks and templates, the workspace inventory, Ansible output configuration and the ansible-polycrate callback, Docker and Kubernetes examples, advanced playbooks and best practices.

Ansible

Polycrate offers a special integration with Ansible. The workspace snapshot, which is exported in YAML format and added to the Polycrate container, is processed automatically by Ansible. As a result, the snapshot is directly available as top-level variables in Ansible and can be used in playbooks and templates.

The following example shows:

  • the default configuration (block.poly) of a block named traefik
  • the configuration provided by the user for the block in workspace.poly
  • the Ansible playbook that uses the exposed variables (block.config...)
  • an Ansible template that uses the exposed variables (templates/docker-compose.yml.j2)
  • the resulting file /polycrate/docker-compose.yml that is transferred to a remote host

The block variable contains the configuration of the current block, which is invoked by

polycrate run traefik install

In addition, there is a variable named workspace that contains the fully compiled workspace including additional blocks available in the workspace.

Polycrate uses a special Ansible vars plugin to read the YAML snapshot and make it available as a top-level variable in the Ansible facts.

# block.poly

name: traefik

config:
  image: "traefik:v2.6"
  letsencrypt:
    email: ""
    resolver: letsencrypt

actions:
  - name: install
    playbook: install.yml
  - name: uninstall
    playbook: uninstall.yml
  - name: prune
    playbook: prune.yml
# workspace.poly

name: ansible-traefik-demo

blocks:
  - name: traefik
    config:
      letsencrypt:
        email: info@example.com
      image: traefik:2.7
# install.yml
- name: "install"
  hosts: all
  gather_facts: yes
  tasks:
    - name: Create remote block directory
      ansible.builtin.file:
        path: "{{ item }}"
        state: directory
        mode: '0755'
      with_items:
        - "/polycrate/{{ block.name }}"

    - name: Copy compose file
      ansible.builtin.template:
        src: docker-compose.yml.j2
        dest: "/polycrate/{{ block.name }}/docker-compose.yml"

    - name: Deploy compose stack
      docker_compose:
        project_src: "/polycrate/{{ block.name }}"
        remove_orphans: true
        files:
          - docker-compose.yml
# templates/docker-compose.yml.j2

version: "3.9"

services:
  traefik:
    image: "{{ block.config.image }}"
    container_name: "traefik"
    command:
      - "--providers.docker=true"
      - "--providers.docker.exposedbydefault=false"
      - "--entrypoints.web.address=:80"
      - "--entrypoints.websecure.address=:443"
      - "--certificatesresolvers.{{ block.config.letsencrypt.resolver }}.acme.email={{ block.config.letsencrypt.email }}"
      - "--certificatesresolvers.{{ block.config.letsencrypt.resolver }}.acme.storage=/letsencrypt/acme.json"
      - "--certificatesresolvers.{{ block.config.letsencrypt.resolver }}.acme.tlschallenge=true"
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - "/var/run/docker.sock:/var/run/docker.sock:ro"
      - "traefik-letsencrypt:/letsencrypt"
    networks:
      - traefik

networks:
  traefik:
    name: traefik

volumes:
  traefik-letsencrypt:
# /polycrate/traefik/docker-compose.yml

version: "3.9"

services:
  traefik:
    image: "traefik:2.7"
    container_name: "traefik"
    command:
      - "--providers.docker=true"
      - "--providers.docker.exposedbydefault=false"
      - "--entrypoints.web.address=:80"
      - "--entrypoints.websecure.address=:443"
      - "--certificatesresolvers.letsencrypt.acme.email=info@example.com"
      - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
      - "--certificatesresolvers.letsencrypt.acme.tlschallenge=true"
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - "/var/run/docker.sock:/var/run/docker.sock:ro"
      - "traefik-letsencrypt:/letsencrypt"
    networks:
      - traefik

networks:
  traefik:
    name: traefik

volumes:
  traefik-letsencrypt:

Ansible inventory

The Ansible inventory is stored as inventory.yml directly in the workspace root. Polycrate sets ANSIBLE_INVENTORY to this path automatically, so Ansible knows which hosts to address without further configuration.

The inventory must be in YAML format (no INI):

# inventory.yml  (in the workspace root)

all:
  hosts:
    host-1:
      ansible_host: 1.2.3.4
      ansible_ssh_port: 22
      ansible_python_interpreter: "/usr/bin/python3"
      ansible_user: root
    host-2:
      ansible_host: 1.2.3.5
      ansible_user: root
  children:
    webservers:
      hosts:
        host-1:
        host-2:

The corresponding workspace.poly does not need an inventory block – the inventory from the workspace root is used automatically:

# workspace.poly

name: acme-corp-automation

blocks:
  - name: my-block
    config:
      foo: bar

Playbooks address the hosts via their group:

# my-playbook.yml
- name: Configure webservers
  hosts: webservers
  gather_facts: true
  become: true
  tasks:
    - name: Ensure nginx is installed
      ansible.builtin.apt:
        name: nginx
        state: present

Ansible output configuration

Polycrate controls the Ansible output independently of its own CLI output format. The settings can be configured persistently via ~/.polycrate/polycrate.yml and overridden per run with CLI flags.

ansible-polycrate callback (default since CLI 0.33.0)

Since CLI 0.33.0 the Polycrate container image ships its own Ansible callback plugin ansible-polycrate, which is active by default. The callback summarizes the task output considerably more compactly than the stock default callback:

  • One line per host and task (status, optionally changed diff)
  • Redundant task metadata is suppressed
  • Failed tasks are shown with full context including traceback

The plugin is automatically registered as stdout_callback in the ansible.cfg of the Polycrate container – no configuration required. If you need the behavior of the stock callbacks (e.g. for external consumers of the output), you can override the callback at any time via CLI flag (--ansible-stdout-callback ansible.builtin.default) or via ~/.polycrate/polycrate.yml.

Defaults

Setting Default Description
stdout_callback ansible-polycrate Callback plugin that formats the output (fallback: ansible.builtin.default)
callback_result_format json Result format for the stock callback: json (compact) or yaml (verbose)
display_ok_hosts true Shows hosts with status ok
display_skipped_hosts false Shows skipped hosts

The json format prints a single line per host — much more compact than the earlier yaml format, which produced 3–10 lines per host for each task.

Compact JSON output (default):

changed: [node1] => {"msg": "nginx installed", "changed": true}
changed: [node2] => {"msg": "nginx installed", "changed": true}
ok: [node3] => {"msg": "already installed", "changed": false}

Verbose YAML output (optional):

changed: [node1] =>
  msg: nginx installed
  changed: true
changed: [node2] =>
  msg: nginx installed
  changed: true

Configuration via polycrate.yml

Persistent settings are stored in ~/.polycrate/polycrate.yml under the key ansible:

# ~/.polycrate/polycrate.yml

ansible:
  stdout_callback: ansible.builtin.default   # Callback plugin
  callback_result_format: json               # json | yaml
  display_ok_hosts: true
  display_skipped_hosts: false

Example: maximum compactness with the dense callback

The dense callback condenses all hosts of a task into a single line and is ideal for playbooks with many hosts:

ansible:
  stdout_callback: ansible.builtin.dense

Example: verbose output for debugging

ansible:
  callback_result_format: yaml
  display_ok_hosts: true
  display_skipped_hosts: true

CLI flags

The following flags override the polycrate.yml configuration for a single run:

Flag Description
--ansible-stdout-callback <plugin> Sets the Ansible stdout callback
--ansible-callback-result-format <format> Sets the result format (json or yaml)

Available for polycrate run and polycrate actions run.

# Single run with full YAML output (e.g. for debugging)
polycrate run myblock myaction --ansible-callback-result-format yaml

# Maximum compactness for a single run
polycrate run myblock myaction --ansible-stdout-callback ansible.builtin.dense

# Unix-style output (requires the community.general collection)
polycrate run myblock myaction --ansible-stdout-callback community.general.unixy

Task progress in real time (stock callback)

When using the stock default callback, the task banner (TASK [name]) may only appear with the next task – especially in playbooks with many conditionals. The reason: the default plugin suppresses the banner as long as display_skipped_hosts=False (CLI 0.35.0 analysis).

Remedy for real-time banners:

ansible:
  display_skipped_hosts: true

Or via environment variable:

ANSIBLE_DISPLAY_SKIPPED_HOSTS=True polycrate run myblock myaction

With the default callback ansible-polycrate (since CLI 0.33.0) the problem is not relevant – banners are printed in real time regardless of display_skipped_hosts.

Ansible for Docker

Ansible offers comprehensive modules for Docker and Docker Compose management. With Polycrate you can elegantly manage Docker containers and Docker Compose stacks via Ansible playbooks.

Deploying Docker Compose with Ansible

The Traefik example above already shows a typical Docker Compose workflow with Ansible. Here are further details:

Complete block with Docker Compose:

# block.poly
name: my-docker-stack
kind: dockerapp

config:
  app:
    image: myapp:latest
    port: 8080
  database:
    image: postgres:14
    user: appuser
    password: "{{ vault_db_password }}"
    name: appdb

actions:
  - name: install
    playbook: docker-compose.yml

  - name: uninstall
    playbook: docker-compose-down.yml

Playbook:

# docker-compose.yml
---
- name: Deploy Docker Compose Stack
  hosts: all
  become: yes

  tasks:
    - name: Create App Directory
      file:
        path: "/opt/{{ block.name }}"
        state: directory
        mode: '0755'

    - name: Copy docker-compose.yml
      template:
        src: templates/docker-compose.yml.j2
        dest: "/opt/{{ block.name }}/docker-compose.yml"

    - name: Start Docker Compose Stack
      community.docker.docker_compose:
        project_src: "/opt/{{ block.name }}"
        state: present
        pull: yes
        remove_orphans: yes

Template:

# templates/docker-compose.yml.j2
version: '3.8'

services:
  web:
    image: {{ block.config.app.image }}
    ports:
      - "{{ block.config.app.port }}:80"
    environment:
      - DATABASE_URL=postgresql://{{ block.config.database.user }}:{{ block.config.database.password }}@db:5432/{{ block.config.database.name }}
    depends_on:
      - db

  db:
    image: {{ block.config.database.image }}
    environment:
      - POSTGRES_USER={{ block.config.database.user }}
      - POSTGRES_PASSWORD={{ block.config.database.password }}
      - POSTGRES_DB={{ block.config.database.name }}
    volumes:
      - postgres-data:/var/lib/postgresql/data

volumes:
  postgres-data:

Ansible for Kubernetes

Ansible offers powerful modules for Kubernetes management. With Polycrate you can elegantly manage Kubernetes resources via Ansible playbooks - whether with raw manifests, Helm charts or the Kubernetes module.

1. Kubernetes manifests with Ansible

Deploying a service with Kubernetes manifests:

# block.poly
name: my-k8s-app
kind: k8sapp

config:
  namespace: production
  app:
    name: myapp
    image: myapp:1.0.0
    replicas: 3
    port: 8080

actions:
  - name: install
    playbook: install.yml

  - name: uninstall
    playbook: uninstall.yml

Playbook:

# install.yml
---
- name: Deploy Application to Kubernetes
  hosts: localhost
  gather_facts: no

  tasks:
    - name: Create Namespace
      kubernetes.core.k8s:
        state: present
        definition:
          apiVersion: v1
          kind: Namespace
          metadata:
            name: "{{ block.config.namespace }}"

    - name: Deploy Application from Template
      kubernetes.core.k8s:
        state: present
        namespace: "{{ block.config.namespace }}"
        template: "templates/deployment.yml.j2"

    - name: Deploy Service from Template
      kubernetes.core.k8s:
        state: present
        namespace: "{{ block.config.namespace }}"
        template: "templates/service.yml.j2"

    - name: Deploy Ingress from Template
      kubernetes.core.k8s:
        state: present
        namespace: "{{ block.config.namespace }}"
        template: "templates/ingress.yml.j2"

    - name: Wait for Deployment to be ready
      kubernetes.core.k8s_info:
        kind: Deployment
        namespace: "{{ block.config.namespace }}"
        name: "{{ block.config.app.name }}"
      register: deployment
      until: deployment.resources[0].status.readyReplicas == block.config.app.replicas
      retries: 30
      delay: 10

Templates:

# templates/deployment.yml.j2
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ block.config.app.name }}
  labels:
    app: {{ block.config.app.name }}
spec:
  replicas: {{ block.config.app.replicas }}
  selector:
    matchLabels:
      app: {{ block.config.app.name }}
  template:
    metadata:
      labels:
        app: {{ block.config.app.name }}
    spec:
      containers:
      - name: {{ block.config.app.name }}
        image: {{ block.config.app.image }}
        ports:
        - containerPort: {{ block.config.app.port }}
        resources:
          requests:
            memory: "128Mi"
            cpu: "100m"
          limits:
            memory: "512Mi"
            cpu: "500m"
# templates/service.yml.j2
apiVersion: v1
kind: Service
metadata:
  name: {{ block.config.app.name }}
spec:
  selector:
    app: {{ block.config.app.name }}
  ports:
  - protocol: TCP
    port: 80
    targetPort: {{ block.config.app.port }}
  type: ClusterIP
# templates/ingress.yml.j2
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: {{ block.config.app.name }}
  annotations:
    cert-manager.io/cluster-issuer: "letsencrypt-prod"
spec:
  ingressClassName: nginx
  rules:
  - host: {{ block.config.app.domain }}
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: {{ block.config.app.name }}
            port:
              number: 80
  tls:
  - hosts:
    - {{ block.config.app.domain }}
    secretName: {{ block.config.app.name }}-tls

2. Helm charts with Ansible

Deployment via Helm:

# block.poly
name: postgres-helm
kind: k8sapp

config:
  namespace: databases
  chart:
    name: postgresql
    repo:
      name: bitnami
      url: https://charts.bitnami.com/bitnami
    version: 12.12.0
  app:
    auth:
      username: pguser
      password: "{{ vault_pg_password }}"
      database: mydb
    primary:
      persistence:
        size: 50Gi
        storageClass: fast-ssd
    replication:
      enabled: true
      replicas: 2

actions:
  - name: install
    playbook: helm-install.yml

  - name: uninstall
    playbook: helm-uninstall.yml

Playbook:

# helm-install.yml
---
- name: Install Helm Chart
  hosts: localhost
  gather_facts: no

  tasks:
    - name: Add Helm Repository
      kubernetes.core.helm_repository:
        name: "{{ block.config.chart.repo.name }}"
        repo_url: "{{ block.config.chart.repo.url }}"

    - name: Create Namespace
      kubernetes.core.k8s:
        state: present
        definition:
          apiVersion: v1
          kind: Namespace
          metadata:
            name: "{{ block.config.namespace }}"

    - name: Install Helm Chart
      kubernetes.core.helm:
        name: "{{ block.name }}"
        chart_ref: "{{ block.config.chart.repo.name }}/{{ block.config.chart.name }}"
        chart_version: "{{ block.config.chart.version }}"
        release_namespace: "{{ block.config.namespace }}"
        values:
          auth:
            username: "{{ block.config.app.auth.username }}"
            password: "{{ block.config.app.auth.password }}"
            database: "{{ block.config.app.auth.database }}"
          primary:
            persistence:
              size: "{{ block.config.app.primary.persistence.size }}"
              storageClass: "{{ block.config.app.primary.persistence.storageClass }}"
          readReplicas:
            replicaCount: "{{ block.config.app.replication.replicas }}"
        wait: yes
        wait_timeout: 10m

    - name: Wait for PostgreSQL to be ready
      kubernetes.core.k8s_info:
        kind: StatefulSet
        namespace: "{{ block.config.namespace }}"
        name: "{{ block.name }}-postgresql"
      register: statefulset
      until: statefulset.resources[0].status.readyReplicas >= 1
      retries: 30
      delay: 10

3. Complex multi-resource deployments

PostgreSQL with backup and monitoring:

# block.poly
name: postgres-complete
kind: k8sapp

config:
  namespace: databases
  postgres:
    version: "14"
    storage: 100Gi
    replicas: 3
  backup:
    enabled: true
    schedule: "0 2 * * *"
    retention: 7
  monitoring:
    enabled: true
    prometheus:
      enabled: true

actions:
  - name: install
    playbook: install-complete.yml

Playbook:

# install-complete.yml
---
- name: Deploy Complete PostgreSQL Stack
  hosts: localhost
  gather_facts: no

  tasks:
    - name: Create Namespace
      kubernetes.core.k8s:
        state: present
        definition:
          apiVersion: v1
          kind: Namespace
          metadata:
            name: "{{ block.config.namespace }}"
            labels:
              monitoring: "true"

    - name: Deploy CloudNativePG Operator (if not exists)
      kubernetes.core.k8s:
        state: present
        src: "https://raw.githubusercontent.com/cloudnative-pg/cloudnative-pg/release-1.21/releases/cnpg-1.21.0.yaml"

    - name: Deploy PostgreSQL Cluster
      kubernetes.core.k8s:
        state: present
        namespace: "{{ block.config.namespace }}"
        definition:
          apiVersion: postgresql.cnpg.io/v1
          kind: Cluster
          metadata:
            name: "{{ block.name }}"
          spec:
            instances: "{{ block.config.postgres.replicas }}"
            imageName: "ghcr.io/cloudnative-pg/postgresql:{{ block.config.postgres.version }}"
            storage:
              size: "{{ block.config.postgres.storage }}"
            monitoring:
              enabled: "{{ block.config.monitoring.enabled }}"
            backup:
              barmanObjectStore:
                destinationPath: "s3://my-backup-bucket/{{ block.name }}"
                s3Credentials:
                  accessKeyId:
                    name: backup-s3-creds
                    key: ACCESS_KEY_ID
                  secretAccessKey:
                    name: backup-s3-creds
                    key: SECRET_ACCESS_KEY
              retentionPolicy: "{{ block.config.backup.retention }}d"

    - name: Create Scheduled Backup (if enabled)
      kubernetes.core.k8s:
        state: present
        namespace: "{{ block.config.namespace }}"
        definition:
          apiVersion: postgresql.cnpg.io/v1
          kind: ScheduledBackup
          metadata:
            name: "{{ block.name }}-backup"
          spec:
            schedule: "{{ block.config.backup.schedule }}"
            backupOwnerReference: self
            cluster:
              name: "{{ block.name }}"
      when: block.config.backup.enabled

    - name: Deploy ServiceMonitor for Prometheus (if enabled)
      kubernetes.core.k8s:
        state: present
        namespace: "{{ block.config.namespace }}"
        definition:
          apiVersion: monitoring.coreos.com/v1
          kind: ServiceMonitor
          metadata:
            name: "{{ block.name }}-metrics"
          spec:
            selector:
              matchLabels:
                cnpg.io/cluster: "{{ block.name }}"
            endpoints:
            - port: metrics
      when: block.config.monitoring.prometheus.enabled

    - name: Wait for PostgreSQL Cluster to be ready
      kubernetes.core.k8s_info:
        api_version: postgresql.cnpg.io/v1
        kind: Cluster
        namespace: "{{ block.config.namespace }}"
        name: "{{ block.name }}"
      register: pg_cluster
      until: pg_cluster.resources[0].status.phase == "Cluster in healthy state"
      retries: 60
      delay: 10

4. ConfigMaps and secrets management

# deploy-with-config.yml
---
- name: Deploy Application with Config
  hosts: localhost
  gather_facts: no

  tasks:
    - name: Create ConfigMap from Template
      kubernetes.core.k8s:
        state: present
        namespace: "{{ block.config.namespace }}"
        definition:
          apiVersion: v1
          kind: ConfigMap
          metadata:
            name: "{{ block.config.app.name }}-config"
          data:
            app.conf: |
              {{ lookup('template', 'templates/app.conf.j2') }}
            database.url: "postgresql://{{ block.config.database.host }}:5432/{{ block.config.database.name }}"

    - name: Create Secret from Vault
      kubernetes.core.k8s:
        state: present
        namespace: "{{ block.config.namespace }}"
        definition:
          apiVersion: v1
          kind: Secret
          metadata:
            name: "{{ block.config.app.name }}-secrets"
          type: Opaque
          stringData:
            db-password: "{{ vault_db_password }}"
            api-key: "{{ vault_api_key }}"

    - name: Deploy Application with Config
      kubernetes.core.k8s:
        state: present
        namespace: "{{ block.config.namespace }}"
        template: "templates/deployment-with-config.yml.j2"
# templates/deployment-with-config.yml.j2
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ block.config.app.name }}
spec:
  replicas: {{ block.config.app.replicas }}
  selector:
    matchLabels:
      app: {{ block.config.app.name }}
  template:
    metadata:
      labels:
        app: {{ block.config.app.name }}
    spec:
      containers:
      - name: {{ block.config.app.name }}
        image: {{ block.config.app.image }}
        envFrom:
        - configMapRef:
            name: {{ block.config.app.name }}-config
        - secretRef:
            name: {{ block.config.app.name }}-secrets
        volumeMounts:
        - name: config
          mountPath: /etc/config
      volumes:
      - name: config
        configMap:
          name: {{ block.config.app.name }}-config

5. Multi-cluster deployments

With Polycrate you can deploy to several Kubernetes clusters with different kubeconfigs:

# workspace.poly
name: multi-cluster-workspace

blocks:
  # Cluster 1: Production EU
  - name: prod-eu-cluster
    kind: k8scluster
    # Generates kubeconfig to artifacts/blocks/prod-eu-cluster/kubeconfig.yml

  # Cluster 2: Production US
  - name: prod-us-cluster
    kind: k8scluster
    # Generates kubeconfig to artifacts/blocks/prod-us-cluster/kubeconfig.yml

  # App deployment to the EU cluster
  - name: app-eu
    from: my-app-base
    kubeconfig:
      from: prod-eu-cluster
    config:
      namespace: production
      region: eu

  # App deployment to the US cluster
  - name: app-us
    from: my-app-base
    kubeconfig:
      from: prod-us-cluster
    config:
      namespace: production
      region: us

workflows:
  - name: deploy-global
    steps:
      - name: deploy-eu
        block: app-eu
        action: deploy

      - name: deploy-us
        block: app-us
        action: deploy

Ansible automatically uses the correct kubeconfig for each block.

Advanced Ansible examples

Ansible Vault for secrets

# Encrypt secrets
ansible-vault encrypt_string 'secret-password' --name 'vault_db_password'

# Reference in workspace.poly
polycrate run my-app install --ask-vault-pass
# block.poly with Vault variables
config:
  database:
    password: "{{ vault_db_password }}"
    api_key: "{{ vault_api_key }}"

Dynamic inventories

# create-inventory.yml
---
- name: Create Dynamic Inventory from Cloud Provider
  hosts: localhost
  gather_facts: no

  tasks:
    - name: Get EC2 Instances
      amazon.aws.ec2_instance_info:
        region: eu-central-1
        filters:
          "tag:Environment": "production"
          "instance-state-name": "running"
      register: ec2_instances

    - name: Build Inventory
      set_fact:
        dynamic_inventory:
          all:
            hosts: "{{ ec2_instances.instances | map(attribute='private_ip_address') | list }}"

    - name: Write Inventory to Artifacts
      copy:
        content: "{{ dynamic_inventory | to_nice_yaml }}"
        dest: "{{ workspace.config.artifacts_root }}/blocks/{{ block.name }}/inventory.yml"

Rollback playbook

# rollback.yml
---
- name: Rollback Kubernetes Deployment
  hosts: localhost
  gather_facts: no

  tasks:
    - name: Get Deployment History
      kubernetes.core.k8s_info:
        kind: Deployment
        namespace: "{{ block.config.namespace }}"
        name: "{{ block.config.app.name }}"
      register: deployment

    - name: Rollback to Previous Revision
      kubernetes.core.k8s:
        state: present
        kind: Deployment
        namespace: "{{ block.config.namespace }}"
        name: "{{ block.config.app.name }}"
        definition:
          spec:
            template:
              metadata:
                annotations:
                  kubectl.kubernetes.io/restartedAt: "{{ ansible_date_time.iso8601 }}"
      when: deployment.resources | length > 0

    - name: Wait for Rollback
      kubernetes.core.k8s_info:
        kind: Deployment
        namespace: "{{ block.config.namespace }}"
        name: "{{ block.config.app.name }}"
      register: deployment_status
      until: deployment_status.resources[0].status.updatedReplicas == block.config.app.replicas
      retries: 30
      delay: 10

Advanced Docker examples

Multi-container applications

Orchestrating complex multi-container stacks with Ansible:

# block.poly
name: microservices-docker
kind: dockerapp

config:
  app:
    frontend:
      image: myapp-frontend:latest
      port: 3000
    backend:
      image: myapp-backend:latest
      port: 8080
    database:
      image: postgres:14
      user: appuser
      password: "{{ vault_db_password }}"
      database: appdb
    redis:
      image: redis:7-alpine
    nginx:
      port: 80

actions:
  - name: install
    playbook: docker-deploy.yml

  - name: update
    playbook: docker-update.yml

  - name: backup
    playbook: docker-backup.yml

  - name: uninstall
    playbook: docker-down.yml

Deployment playbook:

# docker-deploy.yml
---
- name: Deploy Microservices Stack
  hosts: all
  become: yes

  tasks:
    - name: Create application directory structure
      file:
        path: "{{ item }}"
        state: directory
        mode: '0755'
      loop:
        - "/opt/{{ block.name }}"
        - "/opt/{{ block.name }}/nginx"
        - "/opt/{{ block.name }}/data/postgres"
        - "/opt/{{ block.name }}/data/redis"

    - name: Copy NGINX configuration
      template:
        src: templates/nginx.conf.j2
        dest: "/opt/{{ block.name }}/nginx/nginx.conf"
      notify: Reload NGINX

    - name: Copy docker-compose file
      template:
        src: templates/docker-compose.yml.j2
        dest: "/opt/{{ block.name }}/docker-compose.yml"

    - name: Copy environment file
      template:
        src: templates/env.j2
        dest: "/opt/{{ block.name }}/.env"
        mode: '0600'

    - name: Pull latest images
      community.docker.docker_compose:
        project_src: "/opt/{{ block.name }}"
        pull: yes
      register: pull_result

    - name: Start Docker Compose stack
      community.docker.docker_compose:
        project_src: "/opt/{{ block.name }}"
        state: present
        remove_orphans: yes
      register: compose_result

    - name: Wait for backend to be healthy
      uri:
        url: "http://localhost:{{ block.config.app.backend.port }}/health"
        status_code: 200
      register: result
      until: result.status == 200
      retries: 30
      delay: 5

    - name: Wait for frontend to be healthy
      uri:
        url: "http://localhost:{{ block.config.app.frontend.port }}"
        status_code: 200
      register: result
      until: result.status == 200
      retries: 30
      delay: 5

  handlers:
    - name: Reload NGINX
      community.docker.docker_compose:
        project_src: "/opt/{{ block.name }}"
        services:
          - nginx
        restarted: yes

Docker Compose template:

# templates/docker-compose.yml.j2
version: '3.8'

services:
  frontend:
    image: {{ block.config.app.frontend.image }}
    container_name: {{ block.name }}-frontend
    ports:
      - "{{ block.config.app.frontend.port }}:3000"
    environment:
      - BACKEND_URL=http://backend:8080
      - NODE_ENV=production
    depends_on:
      - backend
    networks:
      - app-network
    restart: unless-stopped

  backend:
    image: {{ block.config.app.backend.image }}
    container_name: {{ block.name }}-backend
    ports:
      - "{{ block.config.app.backend.port }}:8080"
    environment:
      - DATABASE_URL=postgresql://{{ block.config.database.user }}:{{ block.config.database.password }}@database:5432/{{ block.config.database.database }}
      - REDIS_URL=redis://redis:6379
    depends_on:
      - database
      - redis
    networks:
      - app-network
    restart: unless-stopped

  database:
    image: {{ block.config.database.image }}
    container_name: {{ block.name }}-database
    environment:
      - POSTGRES_USER={{ block.config.database.user }}
      - POSTGRES_PASSWORD={{ block.config.database.password }}
      - POSTGRES_DB={{ block.config.database.database }}
    volumes:
      - /opt/{{ block.name }}/data/postgres:/var/lib/postgresql/data
    networks:
      - app-network
    restart: unless-stopped
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U {{ block.config.database.user }}"]
      interval: 10s
      timeout: 5s
      retries: 5

  redis:
    image: {{ block.config.redis.image }}
    container_name: {{ block.name }}-redis
    volumes:
      - /opt/{{ block.name }}/data/redis:/data
    networks:
      - app-network
    restart: unless-stopped

  nginx:
    image: nginx:alpine
    container_name: {{ block.name }}-nginx
    ports:
      - "{{ block.config.nginx.port }}:80"
    volumes:
      - /opt/{{ block.name }}/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
    depends_on:
      - frontend
      - backend
    networks:
      - app-network
    restart: unless-stopped

networks:
  app-network:
    driver: bridge

volumes:
  postgres-data:
  redis-data:

Docker image building with Ansible

# docker-build-and-push.yml
---
- name: Build and Push Docker Images
  hosts: localhost
  gather_facts: no

  tasks:
    - name: Build Frontend Image
      community.docker.docker_image:
        name: "{{ block.config.app.frontend.image }}"
        source: build
        build:
          path: "{{ workspace.root }}/src/frontend"
          dockerfile: Dockerfile
          args:
            NODE_ENV: production
        push: yes
        force_source: yes

    - name: Build Backend Image
      community.docker.docker_image:
        name: "{{ block.config.app.backend.image }}"
        source: build
        build:
          path: "{{ workspace.root }}/src/backend"
          dockerfile: Dockerfile
        push: yes
        force_source: yes

    - name: Tag images with version
      community.docker.docker_image:
        name: "{{ item.name }}"
        repository: "{{ item.name }}"
        tag: "{{ block.version }}"
        source: local
        push: yes
      loop:
        - { name: "{{ block.config.app.frontend.image }}" }
        - { name: "{{ block.config.app.backend.image }}" }

Docker backup and restore

# docker-backup.yml
---
- name: Backup Docker Volumes
  hosts: all
  become: yes

  vars:
    backup_dir: "/backup/{{ block.name }}"
    backup_timestamp: "{{ ansible_date_time.epoch }}"

  tasks:
    - name: Create backup directory
      file:
        path: "{{ backup_dir }}"
        state: directory
        mode: '0700'

    - name: Stop containers for consistent backup
      community.docker.docker_compose:
        project_src: "/opt/{{ block.name }}"
        stopped: yes
      when: block.config.backup.stop_containers | default(true)

    - name: Backup PostgreSQL data
      archive:
        path: "/opt/{{ block.name }}/data/postgres"
        dest: "{{ backup_dir }}/postgres-{{ backup_timestamp }}.tar.gz"
        format: gz

    - name: Backup Redis data
      archive:
        path: "/opt/{{ block.name }}/data/redis"
        dest: "{{ backup_dir }}/redis-{{ backup_timestamp }}.tar.gz"
        format: gz

    - name: Create PostgreSQL dump
      community.docker.docker_container_exec:
        container: "{{ block.name }}-database"
        command: >
          pg_dump -U {{ block.config.database.user }}
          {{ block.config.database.database }}
        register: pg_dump

    - name: Save PostgreSQL dump
      copy:
        content: "{{ pg_dump.stdout }}"
        dest: "{{ backup_dir }}/database-{{ backup_timestamp }}.sql"

    - name: Restart containers
      community.docker.docker_compose:
        project_src: "/opt/{{ block.name }}"
        state: present
      when: block.config.backup.stop_containers | default(true)

    - name: Clean old backups (keep last 7)
      shell: |
        cd {{ backup_dir }}
        ls -t | tail -n +15 | xargs -r rm
# docker-restore.yml
---
- name: Restore Docker Volumes
  hosts: all
  become: yes

  vars:
    backup_dir: "/backup/{{ block.name }}"

  tasks:
    - name: List available backups
      find:
        paths: "{{ backup_dir }}"
        patterns: "postgres-*.tar.gz"
      register: backup_files

    - name: Get latest backup
      set_fact:
        latest_backup: "{{ backup_files.files | sort(attribute='mtime') | last }}"

    - name: Stop Docker Compose stack
      community.docker.docker_compose:
        project_src: "/opt/{{ block.name }}"
        state: absent

    - name: Remove old data
      file:
        path: "/opt/{{ block.name }}/data/postgres"
        state: absent

    - name: Extract backup
      unarchive:
        src: "{{ latest_backup.path }}"
        dest: "/opt/{{ block.name }}/data/"
        remote_src: yes

    - name: Start Docker Compose stack
      community.docker.docker_compose:
        project_src: "/opt/{{ block.name }}"
        state: present

Docker health checks and monitoring

# docker-health-check.yml
---
- name: Check Docker Container Health
  hosts: all
  become: yes

  tasks:
    - name: Get container status
      community.docker.docker_container_info:
        name: "{{ item }}"
      register: container_info
      loop:
        - "{{ block.name }}-frontend"
        - "{{ block.name }}-backend"
        - "{{ block.name }}-database"
        - "{{ block.name }}-redis"
        - "{{ block.name }}-nginx"

    - name: Check if all containers are running
      assert:
        that:
          - item.container.State.Running
        fail_msg: "Container {{ item.item }} is not running!"
      loop: "{{ container_info.results }}"

    - name: Check container health
      assert:
        that:
          - item.container.State.Health.Status == "healthy" or item.container.State.Health is not defined
        fail_msg: "Container {{ item.item }} is unhealthy!"
      loop: "{{ container_info.results }}"

    - name: Check resource usage
      community.docker.docker_container_info:
        name: "{{ item }}"
      register: resource_info
      loop:
        - "{{ block.name }}-backend"
        - "{{ block.name }}-database"

    - name: Display resource usage
      debug:
        msg: "{{ item.container.Name }}: CPU: {{ item.container.HostConfig.CpuShares }}, Memory: {{ item.container.HostConfig.Memory }}"
      loop: "{{ resource_info.results }}"

    - name: Check disk usage of volumes
      shell: |
        du -sh /opt/{{ block.name }}/data/*
      register: disk_usage

    - name: Display disk usage
      debug:
        var: disk_usage.stdout_lines

Zero-downtime updates

# docker-update.yml
---
- name: Zero-Downtime Docker Update
  hosts: all
  become: yes

  tasks:
    - name: Pull latest images
      community.docker.docker_compose:
        project_src: "/opt/{{ block.name }}"
        pull: yes
      register: pull_result

    - name: Check if images were updated
      set_fact:
        images_updated: "{{ pull_result.changed }}"

    - name: Create backup before update
      include_tasks: docker-backup.yml
      when: images_updated

    - name: Update backend (rolling)
      block:
        - name: Scale backend to 2 replicas
          community.docker.docker_compose:
            project_src: "/opt/{{ block.name }}"
            services:
              - backend
            scale:
              backend: 2

        - name: Wait for new backend to be healthy
          uri:
            url: "http://localhost:{{ block.config.app.backend.port }}/health"
            status_code: 200
          register: result
          until: result.status == 200
          retries: 30
          delay: 5

        - name: Remove old backend container
          community.docker.docker_compose:
            project_src: "/opt/{{ block.name }}"
            services:
              - backend
            scale:
              backend: 1
      when: images_updated

    - name: Update frontend (rolling)
      block:
        - name: Scale frontend to 2 replicas
          community.docker.docker_compose:
            project_src: "/opt/{{ block.name }}"
            services:
              - frontend
            scale:
              frontend: 2

        - name: Wait for new frontend to be healthy
          uri:
            url: "http://localhost:{{ block.config.app.frontend.port }}"
            status_code: 200
          register: result
          until: result.status == 200
          retries: 30
          delay: 5

        - name: Remove old frontend container
          community.docker.docker_compose:
            project_src: "/opt/{{ block.name }}"
            services:
              - frontend
            scale:
              frontend: 1
      when: images_updated

    - name: Verify all services are running
      include_tasks: docker-health-check.yml

Docker network management

# docker-network-setup.yml
---
- name: Setup Docker Networks
  hosts: all
  become: yes

  tasks:
    - name: Create custom networks
      community.docker.docker_network:
        name: "{{ item.name }}"
        driver: "{{ item.driver | default('bridge') }}"
        ipam_config:
          - subnet: "{{ item.subnet }}"
        state: present
      loop: "{{ block.config.networks }}"

    - name: Connect containers to networks
      community.docker.docker_container:
        name: "{{ item.container }}"
        networks:
          - name: "{{ item.network }}"
        state: started
      loop: "{{ block.config.container_network_mapping }}"

Best practices for Ansible with Polycrate

1. Write idempotent playbooks

Make sure playbooks can be executed multiple times without unwanted side effects:

- name: Ensure directory exists
  file:
    path: "/opt/{{ block.name }}"
    state: directory  # Idempotent

- name: Ensure service is running
  service:
    name: myapp
    state: started  # Idempotent

2. Error handling

- name: Deploy with error handling
  block:
    - name: Deploy application
      kubernetes.core.k8s:
        state: present
        template: deployment.yml.j2

  rescue:
    - name: Rollback on failure
      kubernetes.core.k8s:
        state: absent
        name: "{{ block.config.app.name }}"

    - name: Notify team
      uri:
        url: "{{ block.config.slack_webhook }}"
        method: POST
        body_format: json
        body:
          text: "Deployment of {{ block.name }} failed!"

  always:
    - name: Clean up temporary files
      file:
        path: "/tmp/{{ block.name }}"
        state: absent

3. Use Ansible tags

# install.yml
- name: Complete Installation
  hosts: all

  tasks:
    - name: Install dependencies
      tags: [dependencies, install]
      apt:
        name: "{{ item }}"
      loop: "{{ block.config.dependencies }}"

    - name: Deploy application
      tags: [deploy, install]
      include_tasks: deploy.yml

    - name: Configure monitoring
      tags: [monitoring]
      include_tasks: monitoring.yml
# Run only specific tags
polycrate run my-app install -- --tags deploy

4. Use Ansible facts

- name: Use gathered facts
  hosts: all
  gather_facts: yes

  tasks:
    - name: Install packages based on OS
      package:
        name: "{{ item }}"
      loop: "{{ block.config.packages[ansible_os_family] }}"

    - name: Configure based on available memory
      template:
        src: config.j2
        dest: /etc/app/config.yml
      vars:
        memory_limit: "{{ (ansible_memtotal_mb * 0.8) | int }}Mi"

5. Handle secrets securely

# Never put secrets directly in block.poly
# ❌ Bad:
config:
  database:
    password: "mysecretpassword"

# ✅ Good: use Ansible Vault
config:
  database:
    password: "{{ vault_db_password }}"
# Use Vault-encrypted secrets
polycrate run my-app install --ask-vault-pass