Ansible Lesson 30 of 42

Ansible for Databases, In Depth: PostgreSQL, MySQL, MongoDB Lifecycle, Replication, Backups & Schema Management

In a nutshell

Most Ansible you’ve written so far manages things you can throw away and rebuild: a web server, a container, a config file. Databases are different — they hold the one thing you cannot regenerate from a Git repo: your data. Automating them is like renovating the plumbing of an apartment block while the tenants are still living in it, showering, and flushing. You do it carefully, one flat at a time, always with a way to undo.

The good news is that Ansible talks to databases through three purpose-built collections — community.postgresql, community.mysql, and community.mongodb — that give you a tidy module for each job: make a database, make a user, grant a privilege, install an extension, set up a replica, take a backup. You describe the end state (“this database exists, owned by this user, with these grants”) and Ansible makes it so, skipping the work when it’s already done.

The catch is that not everything a database does is safely repeatable. CREATE DATABASE appdb is safe to run a hundred times — it either exists or it doesn’t. ALTER TABLE users ADD COLUMN ... is not — the second run errors out. Half of getting good at database automation is knowing which operations are naturally idempotent (let the module handle them) and which are one-way changes that need a real migration tool or a guard. The other half is operational nerve: run in check mode first, roll changes out one host at a time, keep every credential in Ansible Vault, and never aim a state: absent task at the wrong host.

If you can already write a role and use Ansible Vault, you have what you need to start. By the end you’ll install and secure all three engines, manage users and grants idempotently, stand up replication, wire in backups, and run schema changes without taking the business offline.

Level: Advanced · Time: ~45 min · You’ll need: roles, inventories, handlers, Ansible Vault, and working SQL.

Stateful services are the long pole of every infrastructure project. You can re-pour stateless apps from a Dockerfile in 30 seconds; you cannot re-pour the customer database. This makes database automation simultaneously the most important and the most dangerous category of Ansible work — a buggy command: task on a stateless web tier rolls back with a redeploy, but a buggy community.mysql.mysql_db: state: absent task on the production primary takes down the company.

This lesson covers the three database collections that handle 90% of real production: community.postgresql, community.mysql, and community.mongodb. You’ll learn install/configure/secure flows for each engine, replication topologies (streaming replication for Postgres, group replication for MySQL, replica sets for Mongo), backup tooling integration (pgbackrest, xtrabackup, mongodump), schema migration patterns that work safely against a running database, role/user/grant management, password rotation, and the operational discipline you need to sleep at night while Ansible runs against your databases.

Learning Objectives

By the end you will be able to:

Prerequisites

Mental Model: Databases as Ansible Targets

1. Databases are SSH targets that happen to listen on a database port

The control node SSHes into the database host, becomes root (or the DB superuser), and runs Python modules that import the matching DB driver (psycopg2 for Postgres, pymysql for MySQL, pymongo for Mongo). Auth, sudo, and inventory work like any Linux host. The DB connection itself is local: host: localhost from the perspective of the module.

2. Each engine has its own collection — names line up

community.postgresql ships postgresql_db, postgresql_user, postgresql_privs, postgresql_query, and ~20 others. community.mysql ships mysql_db, mysql_user, mysql_query, etc. community.mongodb ships mongodb_user, mongodb_replicaset, mongodb_oplog, etc. The pattern is consistent: one module per “thing you do with the engine.”

3. Idempotence in DB modules is real but partial

postgresql_db: name=app state=present is idempotent — exists or doesn’t. postgresql_query: query="ALTER TABLE foo ADD COLUMN bar INT" is not idempotent — running it twice fails the second time. For DDL changes, either guard with when: based on a query that checks for the column’s existence, or use a proper migration tool like Flyway/Liquibase orchestrated by Ansible.

4. Replication setup is a one-shot bootstrap problem

Spinning up a replica is a sequence (snapshot primary, ship to replica, configure recovery, start) that’s mostly idempotent the first time and not at all idempotent on the second run. The right pattern is a replica-bootstrap role that runs once per replica and a state.json flag on the host to prevent re-runs.

5. Production databases need playbook discipline more than fancy modules

The hardest part of DB automation isn’t choosing the right module — it’s running the playbook safely: --check first, --diff to preview, serial: 1 for rolling changes, any_errors_fatal: true to stop on the first failure, fail_when: guards to enforce invariants. Every battle-tested team has these in their DB plays; every team that’s had a midnight incident has them after that incident.

6. Every DB module authenticates with login_* parameters, not SSH

Once Ansible is on the database host, it still has to log in to the database engine — a second, separate authentication. That’s what the login_* family does, and it’s near-identical across the three collections:

Two consequences trip people up. First, become_user: postgres (an OS identity) and login_user: postgres (a DB identity) are different things — Postgres’s default peer auth ties them together over the local socket, which is why so many Postgres tasks use become_user: postgres and no login_* at all. Second, MySQL treats login_host: localhost (socket) and login_host: 127.0.0.1 (TCP) as two different accounts — a grant to one is not a grant to the other.

The community.postgresql Collection

Ships modules for the full Postgres lifecycle. The most-used:

Module Purpose
postgresql_db Create/drop databases
postgresql_user Create/drop roles (a.k.a. users)
postgresql_privs Manage GRANT/REVOKE
postgresql_schema Create schemas
postgresql_table Create simple tables (rarely used — DDL via SQL is cleaner)
postgresql_query Run arbitrary SQL (for DDL, DML, admin queries)
postgresql_info Read DB metadata (gather facts)
postgresql_set Set runtime config parameters (work_mem, max_connections)
postgresql_pg_hba Manage pg_hba.conf rules
postgresql_ext Manage extensions (pgcrypto, pg_stat_statements)
postgresql_publication, postgresql_subscription Logical replication
postgresql_membership Add/remove users from roles

Install and bootstrap (RHEL 9 example)

- hosts: postgres_primary
  become: true
  vars:
    pg_version: "15"
  tasks:

    - name: Install Postgres repo
      ansible.builtin.dnf:
        name: "https://download.postgresql.org/pub/repos/yum/reporpms/EL-9-x86_64/pgdg-redhat-repo-latest.noarch.rpm"
        state: present
        disable_gpg_check: true

    - name: Install Postgres server
      ansible.builtin.dnf:
        name:
          - "postgresql{{ pg_version }}-server"
          - "postgresql{{ pg_version }}-contrib"
          - "python3-psycopg2"
        state: present

    - name: Initialize cluster (idempotent  checks if PGDATA exists)
      ansible.builtin.command:
        cmd: "/usr/pgsql-{{ pg_version }}/bin/postgresql-{{ pg_version }}-setup initdb"
        creates: "/var/lib/pgsql/{{ pg_version }}/data/PG_VERSION"

    - name: Configure listen_addresses
      community.postgresql.postgresql_set:
        name: listen_addresses
        value: "*"
      become_user: postgres

    - name: Configure pg_hba (allow internal subnet)
      community.postgresql.postgresql_pg_hba:
        dest: "/var/lib/pgsql/{{ pg_version }}/data/pg_hba.conf"
        contype: host
        users: all
        databases: all
        source: 10.0.0.0/8
        method: scram-sha-256

    - name: Enable and start Postgres
      ansible.builtin.systemd:
        name: "postgresql-{{ pg_version }}"
        enabled: true
        state: started

    - name: Create application database
      community.postgresql.postgresql_db:
        name: appdb
        owner: appuser
        encoding: UTF8
        lc_collate: en_US.UTF-8
        lc_ctype: en_US.UTF-8
        template: template0
      become_user: postgres

    - name: Create application user
      community.postgresql.postgresql_user:
        name: appuser
        password: "{{ vault_appuser_password }}"
        role_attr_flags: "LOGIN"
      become_user: postgres
      no_log: true

    - name: Grant on database
      community.postgresql.postgresql_privs:
        database: appdb
        roles: appuser
        type: database
        privs: CONNECT,TEMPORARY
        state: present
      become_user: postgres

    - name: Install pg_stat_statements extension
      community.postgresql.postgresql_ext:
        name: pg_stat_statements
        db: appdb
      become_user: postgres

Why this is idempotent (mostly). Re-run the play and every declarative task reports ok, not changed: postgresql_db checks pg_database, postgresql_user checks pg_roles, postgresql_privs diffs the live ACL against what you declared, postgresql_ext checks pg_extension. The one blind spot is the password — Postgres stores it hashed, so the module can’t tell your declared password from the stored one. By default postgresql_user therefore treats the password as always-potentially-changed and re-sets it every run. Add update_password: on_create to set the password only when the role is first created; without it, a steady-state convergence run needlessly re-hashes the credential every time (harmless, but it reports a permanent false changed and defeats drift detection). Note the ordering above too: the user task follows the db task even though the db declares owner: appuser — Postgres tolerates declaring an owner that will exist, but in a role you’d create the role first to be safe.

Streaming replication

# On the primary
- name: Create replication user
  community.postgresql.postgresql_user:
    name: replicator
    password: "{{ vault_replicator_password }}"
    role_attr_flags: "LOGIN,REPLICATION"
  become_user: postgres
  no_log: true

- name: Allow replication connections
  community.postgresql.postgresql_pg_hba:
    dest: "/var/lib/pgsql/15/data/pg_hba.conf"
    contype: host
    databases: replication
    users: replicator
    source: "{{ replica_subnet }}"
    method: scram-sha-256

# On the replica (run from a different play targeting replicas)
- name: Stop Postgres on replica before pg_basebackup
  ansible.builtin.systemd:
    name: postgresql-15
    state: stopped

- name: Wipe PGDATA (only on first bootstrap!)
  ansible.builtin.file:
    path: /var/lib/pgsql/15/data
    state: absent
  when: replica_bootstrap | default(false)

- name: Run pg_basebackup
  ansible.builtin.command:
    cmd: >
      /usr/pgsql-15/bin/pg_basebackup -h {{ primary_ip }} -U replicator
      -D /var/lib/pgsql/15/data -P -v -R -X stream -C -S replica_{{ inventory_hostname_short }}
    creates: /var/lib/pgsql/15/data/standby.signal
  become_user: postgres
  environment:
    PGPASSWORD: "{{ vault_replicator_password }}"
  no_log: true

- name: Start Postgres on replica
  ansible.builtin.systemd:
    name: postgresql-15
    state: started

pg_basebackup -R writes the standby.signal file and primary connection info into postgresql.auto.conf, making it a one-shot replica bootstrap. The creates: parameter ensures the task is idempotent — it won’t re-run if standby.signal exists.

Schema migrations

Ansible-native DDL is a dead end past trivial cases. The right pattern is to call a real migration tool from Ansible:

- name: Run Flyway migrations
  ansible.builtin.command:
    cmd: >
      flyway -url=jdbc:postgresql://localhost/appdb
      -user=migrator -password={{ vault_migrator_password }}
      -locations=filesystem:/opt/app/db/migrations migrate
  register: flyway
  changed_when: "'Successfully applied' in flyway.stdout"
  no_log: true

Flyway tracks applied migrations in flyway_schema_history table — running twice is idempotent. Same pattern for golang-migrate, Liquibase, Alembic, etc.

This lesson covers the mechanics of running a migration from a play; for the zero-downtime playbook patterns — expand/contract, online backfills in batches, blue-green cutovers — see Online & blue-green database migrations.

For simple “add a column if missing” tasks, you can stay in pure Ansible:

- name: Check if column exists
  community.postgresql.postgresql_query:
    db: appdb
    query: >
      SELECT 1 FROM information_schema.columns
      WHERE table_name = 'users' AND column_name = 'last_login_at'
  register: col_check
  become_user: postgres

- name: Add column if missing
  community.postgresql.postgresql_query:
    db: appdb
    query: ALTER TABLE users ADD COLUMN last_login_at TIMESTAMPTZ
  when: col_check.rowcount == 0
  become_user: postgres

Backup with pgBackRest

- name: Install pgBackRest
  ansible.builtin.dnf:
    name: pgbackrest
    state: present

- name: Configure pgBackRest
  ansible.builtin.copy:
    dest: /etc/pgbackrest/pgbackrest.conf
    content: |
      [global]
      repo1-path=/var/lib/pgbackrest
      repo1-retention-full=2
      log-level-console=info
      log-level-file=detail

      [main]
      pg1-path=/var/lib/pgsql/15/data
    owner: postgres
    group: postgres
    mode: '0640'

- name: Initialize backup stanza
  ansible.builtin.command:
    cmd: pgbackrest --stanza=main --log-level-console=info stanza-create
    creates: /var/lib/pgbackrest/backup/main/backup.info
  become_user: postgres

- name: Schedule daily full backups via systemd timer
  ansible.builtin.copy:
    dest: /etc/systemd/system/pgbackrest-full.service
    content: |
      [Unit]
      Description=pgBackRest full backup
      [Service]
      Type=oneshot
      User=postgres
      ExecStart=/usr/bin/pgbackrest --stanza=main --type=full backup

- name: Schedule daily full backup timer
  ansible.builtin.copy:
    dest: /etc/systemd/system/pgbackrest-full.timer
    content: |
      [Unit]
      Description=Daily pgBackRest full backup
      [Timer]
      OnCalendar=02:00
      Persistent=true
      [Install]
      WantedBy=timers.target

- name: Enable timer
  ansible.builtin.systemd:
    name: pgbackrest-full.timer
    enabled: true
    state: started
    daemon_reload: true

The community.mysql Collection

Mirrors community.postgresql for MySQL/MariaDB.

Module Purpose
mysql_db Create/drop databases
mysql_user Create/drop users with grants
mysql_query Run arbitrary SQL
mysql_replication Configure replica/source roles
mysql_role MySQL 8.0+ roles
mysql_variables Manage runtime variables
mysql_info Read MySQL state (gather facts)

Install and bootstrap (Ubuntu 22.04)

- hosts: mysql_primary
  become: true
  tasks:

    - name: Install MySQL 8.0 server and Python client
      ansible.builtin.apt:
        name:
          - mysql-server-8.0
          - python3-pymysql
        state: present
        update_cache: true

    - name: Set root authentication via socket (Debian/Ubuntu default)
      community.mysql.mysql_user:
        name: root
        host: localhost
        password: "{{ vault_mysql_root_password }}"
        login_unix_socket: /var/run/mysqld/mysqld.sock
        plugin: caching_sha2_password
      no_log: true

    - name: Drop anonymous users
      community.mysql.mysql_user:
        name: ""
        host_all: true
        state: absent
        login_user: root
        login_password: "{{ vault_mysql_root_password }}"
      no_log: true

    - name: Drop test database
      community.mysql.mysql_db:
        name: test
        state: absent
        login_user: root
        login_password: "{{ vault_mysql_root_password }}"
      no_log: true

    - name: Create application database
      community.mysql.mysql_db:
        name: appdb
        encoding: utf8mb4
        collation: utf8mb4_0900_ai_ci
        state: present
        login_user: root
        login_password: "{{ vault_mysql_root_password }}"
      no_log: true

    - name: Create application user
      community.mysql.mysql_user:
        name: appuser
        password: "{{ vault_appuser_password }}"
        host: "10.%"
        priv: "appdb.*:SELECT,INSERT,UPDATE,DELETE"
        plugin: caching_sha2_password
        state: present
        login_user: root
        login_password: "{{ vault_mysql_root_password }}"
      no_log: true

MySQL Group Replication

MySQL 8.0’s group replication provides automatic primary failover. Setup is involved — three nodes minimum:

- hosts: mysql_cluster
  become: true
  tasks:
    - name: Configure group replication (each node)
      community.mysql.mysql_variables:
        variable: "{{ item.name }}"
        value: "{{ item.value }}"
        login_user: root
        login_password: "{{ vault_mysql_root_password }}"
      loop:
        - { name: server_id, value: "{{ groups['mysql_cluster'].index(inventory_hostname) + 1 }}" }
        - { name: gtid_mode, value: "ON" }
        - { name: enforce_gtid_consistency, value: "ON" }
        - { name: binlog_format, value: "ROW" }
        - { name: binlog_checksum, value: "NONE" }
      no_log: true

For full group-replication setup, the practical answer is: use a tool like Vitess, Orchestrator, or MySQL InnoDB Cluster (which provides mysqlsh as a higher-level orchestration tool) rather than building it by hand in Ansible. Ansible’s job is to install MySQL, drop the cluster definition file, and run mysqlsh --execute "dba.createCluster(...)" once.

Backup with xtrabackup / mariabackup

- name: Install xtrabackup
  ansible.builtin.apt:
    name: percona-xtrabackup-80
    state: present

- name: Run a full backup
  ansible.builtin.command:
    cmd: >
      xtrabackup --backup --target-dir=/var/backup/mysql/{{ ansible_date_time.date }}
      --user=backup --password={{ vault_backup_password }} --no-lock
    creates: "/var/backup/mysql/{{ ansible_date_time.date }}/xtrabackup_info"
  no_log: true

The creates: ensures idempotence within a single day. For a production scheduler, use a systemd timer like the pgBackRest example above.

The community.mongodb Collection

Smaller than the Postgres/MySQL collections but covers the essentials.

Module Purpose
mongodb_user Create/drop users with roles
mongodb_replicaset Initialize and configure replica sets
mongodb_shutdown Cleanly stop a Mongo instance
mongodb_oplog Configure oplog size
mongodb_index Create/drop indexes
mongodb_shard Add shards to a sharded cluster
mongodb_balancer Enable/disable the sharding balancer

Install and bootstrap

- hosts: mongo_replica_set
  become: true
  tasks:

    - name: Add MongoDB repo (Ubuntu)
      ansible.builtin.apt_repository:
        repo: "deb [arch=amd64 signed-by=/etc/apt/keyrings/mongodb.gpg] https://repo.mongodb.org/apt/ubuntu jammy/mongodb-org/7.0 multiverse"
        state: present
        filename: mongodb-org-7

    - name: Install MongoDB
      ansible.builtin.apt:
        name:
          - mongodb-org
          - python3-pymongo
        state: present

    - name: Configure replica set name
      ansible.builtin.lineinfile:
        path: /etc/mongod.conf
        regexp: '^#?replication:'
        line: |
          replication:
            replSetName: "rs0"

    - name: Bind to 0.0.0.0 (cluster needs network access)
      ansible.builtin.lineinfile:
        path: /etc/mongod.conf
        regexp: '^  bindIp:'
        line: '  bindIp: 0.0.0.0'

    - name: Restart mongod
      ansible.builtin.systemd:
        name: mongod
        state: restarted

    - name: Initialize replica set (only on first node)
      community.mongodb.mongodb_replicaset:
        login_host: localhost
        replica_set: rs0
        members:
          - "{{ groups['mongo_replica_set'][0] }}:27017"
          - "{{ groups['mongo_replica_set'][1] }}:27017"
          - "{{ groups['mongo_replica_set'][2] }}:27017"
      run_once: true

    - name: Wait for replica set to elect primary
      community.mongodb.mongodb_status:
        login_host: "{{ groups['mongo_replica_set'][0] }}"
        replica_set: rs0
      register: rs_status
      until: rs_status.replicaset.members | selectattr('state','equalto','PRIMARY') | list | length == 1
      retries: 30
      delay: 5
      run_once: true

    - name: Create application user (only on primary)
      community.mongodb.mongodb_user:
        login_host: "{{ groups['mongo_replica_set'][0] }}"
        replica_set: rs0
        database: admin
        name: app
        password: "{{ vault_mongo_app_password }}"
        roles:
          - { db: "appdb", role: "readWrite" }
        state: present
      no_log: true
      run_once: true

Backup with mongodump

- name: Run a logical dump (small DBs only)
  ansible.builtin.command:
    cmd: >
      mongodump --host=rs0/{{ groups['mongo_replica_set'][0] }}:27017
      --username=backup --password={{ vault_backup_password }}
      --authenticationDatabase=admin
      --out=/var/backup/mongo/{{ ansible_date_time.date }}
      --gzip
    creates: "/var/backup/mongo/{{ ansible_date_time.date }}"
  no_log: true

For larger Mongo clusters use mongodump with --oplog for point-in-time, or commercial tools like Ops Manager.

Connections, credentials, and Vault

Every example above quietly did two things right that deserve to be pulled into the open: it authenticated with login_* parameters, and it kept passwords out of the codebase with Vault plus no_log. Get these right and database automation is safe; get them wrong and you either can’t connect or you leak the crown jewels into a CI log.

Where credentials come from

Never write a database password as a literal in a playbook. Store it encrypted with Ansible Vault and reference it as a variable. The community convention is a two-file split — the real secret lives in a vault_-prefixed name inside an encrypted file, and a plaintext vars file indirects to it:

# group_vars/postgres/vault.yml   (encrypt: ansible-vault encrypt group_vars/postgres/vault.yml)
vault_appuser_password: "S3cr3t-App-Passw0rd"
vault_replicator_password: "S3cr3t-Repl-Passw0rd"
# group_vars/postgres/vars.yml    (plaintext — indirects to the encrypted var)
appuser_password: "{{ vault_appuser_password }}"
replicator_password: "{{ vault_replicator_password }}"

Tasks reference the plain appuser_password, and a single grep -r vault_ group_vars/ audit shows you every secret in the repo. See Ansible Vault & vault IDs for multi-environment vault IDs and rekeying.

no_log is not optional

Vault protects a secret at rest in your repo. It does nothing at runtime: the moment a task executes, the value is decrypted and handed to the module, and Ansible will happily print it in verbose (-vvv) output, in --diff, or in a failed task’s result dump. Every task that touches a credential needs no_log: true:

- name: Create application user
  community.postgresql.postgresql_user:
    name: appuser
    password: "{{ appuser_password }}"
    role_attr_flags: LOGIN
    update_password: on_create
  become_user: postgres
  no_log: true          # without this, -vvv or a failure prints the password

The trade-off: no_log also hides the task’s diagnostics, so when a credential task fails you’ll see the output has been hidden due to the fact that 'no_log: true' and nothing else. Debug by temporarily removing no_log on a throwaway host with a throwaway password — never on production.

Connecting to managed databases (RDS, Cloud SQL, Atlas)

You don’t always have SSH to the database host — with RDS, Cloud SQL, or Mongo Atlas there is no host to SSH into. Run the DB modules locally and let the Python driver reach the endpoint over the network:

- hosts: localhost
  connection: local
  gather_facts: false
  tasks:
    - name: Create a database on RDS PostgreSQL
      community.postgresql.postgresql_db:
        name: appdb
        login_host: mydb.abc123.us-east-1.rds.amazonaws.com
        login_user: "{{ rds_admin_user }}"
        login_password: "{{ rds_admin_password }}"
        ssl_mode: require
      no_log: true

Here become is irrelevant (nothing runs on the DB host); the login_* parameters and the driver do all the work. The control node itself needs psycopg2 / pymysql / pymongo installed and network reachability to the endpoint.

Hands-on Free Lab: PostgreSQL Primary + Replica with Backups

Free, runs on two VMs (or two kind containers, or two Multipass instances).

# On your control node
mkdir -p ~/ansible-postgres-lab && cd ~/ansible-postgres-lab

cat > inventory.yml <<'EOF'
all:
  children:
    pg_primary:
      hosts:
        pg-primary:
          ansible_host: 192.168.64.10
    pg_replicas:
      hosts:
        pg-replica-1:
          ansible_host: 192.168.64.11
EOF

cat > group_vars/all.yml <<'EOF'
pg_version: "15"
vault_appuser_password: "AppPassw0rd!"
vault_replicator_password: "ReplPassw0rd!"
EOF

# (Use the install + replication playbooks shown earlier)
ansible-playbook -i inventory.yml install-primary.yml
ansible-playbook -i inventory.yml setup-replication.yml -e replica_bootstrap=true
ansible-playbook -i inventory.yml install-pgbackrest.yml

# Verify
ansible -i inventory.yml pg_primary -m community.postgresql.postgresql_query \
  -a "db=appdb query='SELECT now()'" --become --become-user=postgres
ansible -i inventory.yml pg_replicas -m community.postgresql.postgresql_query \
  -a "db=appdb query='SELECT pg_is_in_recovery()'" --become --become-user=postgres

A working primary+replica with backup = the core of any production Postgres deployment.

Going deeper

Check mode against a live database: what it really does

--check tells Ansible to predict changes without making them. For database modules the support is real but uneven, and knowing the boundary is what keeps a dry run honest:

Operation Check-mode behaviour
postgresql_db, mysql_db (present/absent) Fully supported — connects, checks the catalog, reports would-change
postgresql_user, mysql_user Supported for existence and attributes; the password shows as changed under default update_password (the module can’t read the stored hash)
postgresql_privs, grants Supported — diffs the live ACL
postgresql_schema, postgresql_ext Supported — checks the catalog
postgresql_query, mysql_query (your DDL/DML) Not previewed — the module can’t know what arbitrary SQL would do without running it, so in check mode it reports skipped / a nominal result

The practical rule: --check --diff is trustworthy for the declarative modules (db/user/privs/schema/ext) and blind for the imperative one (*_query). Never treat a clean check run as proof that a migration is safe — that safety lives in the migration tool, not in Ansible. For a deep tour of dry runs and diffs, see Check mode, diff & debugging.

Idempotency has a password-shaped hole

Because Postgres, MySQL, and Mongo all store passwords hashed (or salted-hashed), no module can compare your declared password to the stored one. Every *_user module resolves this the same way — via update_password:

This single option is the most common source of “why does my DB play always say changed?” tickets.

Replication is a bootstrap, not a convergence

Config management assumes convergence: describe the end state, re-run safely forever. Replica setup breaks that assumption. pg_basebackup clones a primary’s data directory exactly once; run it against a populated PGDATA and it refuses. The disciplined patterns:

Scale changes the tool, not the play

At laptop scale pg_basebackup and mongodump are fine. At production scale they become footguns:

Match forks and serial to the blast radius: a fleet-wide mysql_variables read-tweak can run at high forks; anything that restarts a primary should be serial: 1 with any_errors_fatal: true.

Authentication internals worth knowing

Collection and version caveats

Common beginner mistakes

Common Mistakes & Troubleshooting

1. psycopg2 import error on the target The Python interpreter Ansible chose doesn’t have psycopg2. Either install it (dnf install python3-psycopg2) or set ansible_python_interpreter to a venv that has it.

2. mysql_user succeeds but the user can’t log in You probably set host: localhost but the connection comes via TCP/IP (which counts as 127.0.0.1, not localhost). MySQL distinguishes them. Use host: "%" for any host or specify the actual source.

3. postgresql_db errors with “must be owner of database” You’re running as postgres user but trying to drop a DB owned by another role. Use state: absent after become_user: postgres and ensure the play’s user is the actual owner, or use force: true (Postgres 15+ has this option).

4. Mongo replica set initialization hangs The members can’t reach each other on port 27017. Check bindIp: (must be 0.0.0.0, not 127.0.0.1) and firewall rules.

5. postgresql_query keeps reporting changed: true for SELECT statements SELECTs don’t modify state but the module reports changed based on rows. Use changed_when: false for read-only queries.

6. Schema migration runs twice and fails on duplicate key error Pure postgresql_query for DDL is not idempotent. Use Flyway/Liquibase, or wrap with IF NOT EXISTS SQL constructs (CREATE TABLE IF NOT EXISTS, ALTER TABLE ... ADD COLUMN IF NOT EXISTS).

7. Password leaks into Ansible logs You forgot no_log: true on the user-creation task. Vault doesn’t help here — the value is decrypted before the task runs and ends up in stdout. Always no_log: true on credential tasks.

Best Practices

Security Notes

Practice challenges

Six graded exercises, beginner → advanced. Try each before opening the solution. Every playbook fragment uses FQCN and assumes the login_* / Vault patterns from this lesson.

1. (Beginner) Idempotent app database + user. Write two tasks so that appdb exists, owned by a login role appuser, and a second run of the play reports ok, not changed.

<details><summary>Solution</summary>

- name: App login role exists (password set only on create)
  community.postgresql.postgresql_user:
    name: appuser
    password: "{{ appuser_password }}"
    role_attr_flags: LOGIN
    update_password: on_create
  become_user: postgres
  no_log: true

- name: App database exists, owned by appuser
  community.postgresql.postgresql_db:
    name: appdb
    owner: appuser
  become_user: postgres

Why: update_password: on_create plus the declarative postgresql_db/postgresql_user modules mean the second run finds the desired state already present and reports ok. </details>

2. (Beginner) Read-only reporting user in MySQL. Create a reporter account that can only SELECT on appdb, reachable only from the 10.% app subnet.

<details><summary>Solution</summary>

- name: Read-only reporting user on appdb
  community.mysql.mysql_user:
    name: reporter
    password: "{{ reporter_password }}"
    host: "10.%"
    priv: "appdb.*:SELECT"
    plugin: caching_sha2_password
    state: present
    login_user: root
    login_password: "{{ mysql_root_password }}"
  no_log: true

Why: priv: "appdb.*:SELECT" grants exactly one privilege on one database, and scoping host to 10.% avoids the classic '%' wildcard over-grant. </details>

3. (Intermediate) Idempotent column add. Add last_login_at TIMESTAMPTZ to users, but only if it doesn’t already exist, using pure Ansible (no migration tool).

<details><summary>Solution</summary>

- name: Does last_login_at already exist?
  community.postgresql.postgresql_query:
    db: appdb
    query: >
      SELECT 1 FROM information_schema.columns
      WHERE table_name = 'users' AND column_name = 'last_login_at'
  register: col
  become_user: postgres
  changed_when: false

- name: Add last_login_at when missing
  community.postgresql.postgresql_query:
    db: appdb
    query: ALTER TABLE users ADD COLUMN last_login_at TIMESTAMPTZ
  when: col.rowcount == 0
  become_user: postgres

Why: the changed_when: false probe plus when: col.rowcount == 0 guard turns non-idempotent DDL into a re-runnable pair. (Postgres also accepts ADD COLUMN IF NOT EXISTS — either works.) </details>

4. (Intermediate) Encrypt a credential and consume it safely. Store reporter’s password in Vault, indirect it through a plaintext var, and use it with no_log.

<details><summary>Solution</summary>

# 1. Create the encrypted file
ansible-vault create group_vars/db/vault.yml
#    vault_reporter_password: "R3port-Only!"
# 2. group_vars/db/vars.yml  (plaintext indirection)
reporter_password: "{{ vault_reporter_password }}"
# 3. Run — every consuming task still carries no_log: true
ansible-playbook site.yml --ask-vault-pass

Why: the vault_-in-vault + plain-name-in-vars split keeps a greppable audit trail; the vault password (or a vault-id) decrypts at runtime, and no_log stops the decrypted value reaching the logs. </details>

5. (Advanced) One-shot replica bootstrap. Write the pg_basebackup task so it clones the primary exactly once and no-ops on every later run — no external state file of your own.

<details><summary>Solution</summary>

- name: Bootstrap standby from primary (runs once)
  ansible.builtin.command:
    cmd: >
      /usr/pgsql-15/bin/pg_basebackup -h {{ primary_ip }} -U replicator
      -D /var/lib/pgsql/15/data -R -X stream -C -S {{ inventory_hostname_short }}
    creates: /var/lib/pgsql/15/data/standby.signal
  become_user: postgres
  environment:
    PGPASSWORD: "{{ replicator_password }}"
  no_log: true

Why: pg_basebackup -R writes standby.signal, and creates: keys idempotence off that file — a genuine one-shot bootstrap that is safe to leave in a convergence play. -C -S also creates a replication slot so the primary retains WAL for this replica. </details>

6. (Advanced) Rotate a password without a stampede. Write the rotation play that sets a new appuser password (already updated in Vault) on the primary, one host at a time.

<details><summary>Solution</summary>

- hosts: db_primary
  serial: 1
  become: true
  tasks:
    - name: Rotate appuser password to the new Vault value
      community.postgresql.postgresql_user:
        name: appuser
        password: "{{ appuser_password }}"   # new value already in Vault
        update_password: always
      become_user: postgres
      no_log: true

Why: update_password: always forces the new hash (the module can’t detect the change on its own), serial: 1 limits blast radius, and the app rollout that consumes the new secret must follow immediately so no tier is left authenticating with the stale credential. </details>

Q&A — 13 Questions

Q1. Should I run schema migrations from Ansible or from a CI tool? From CI, ideally — but invoked through Ansible if your deployment pipeline is Ansible-based. The migration tool (Flyway/Liquibase/golang-migrate) tracks state; Ansible just calls it.

Q2. Why does postgresql_query not support check mode for DML? Because the module doesn’t know whether your INSERT or UPDATE would change rows without running it. SELECTs are check-mode-friendly with changed_when: false.

Q3. How do I do an online column add? ALTER TABLE users ADD COLUMN x INT is online for nullable columns in modern Postgres/MySQL/Mongo. For non-nullable with a default, run two migrations: add nullable, backfill in batches, alter to NOT NULL.

Q4. Should I use community.postgresql.postgresql_table for DDL? Rarely. It only handles simple cases. Real DDL belongs in a migration tool, not in Ansible.

Q5. How do I bootstrap a Postgres replica with TB-scale data? pg_basebackup works up to a point but locks WAL retention on the primary while it streams. For huge clusters use pgbackrest to restore from a backup repository (decoupled from primary), then catch up via WAL.

Q6. Can Ansible promote a replica? Yes — community.postgresql.postgresql_query: query="SELECT pg_promote()" on the replica. But promoting a replica during an incident usually requires more than Ansible — it needs failover orchestration (Patroni, repmgr, pg_auto_failover).

Q7. What’s the safe pattern for password rotation?

  1. Set new password in Vault. 2. Run a play that sets it on the DB with update_password: always. 3. Update apps that reference it (rolling deploy). 4. Don’t drop the old password until step 3 completes. The window between (2) and (3) is when both passwords are invalid for half the apps.

Q8. How do I dump just one schema in MySQL with Ansible? community.mysql.mysql_db: state=dump name=appdb target=/tmp/dump.sql ignore_tables=appdb.audit_log. For more control, shell out to mysqldump.

Q9. Mongo replica set — how do I add a fourth member? community.mongodb.mongodb_replicaset with the new member added to members:. The module computes the diff and adds it. Note the new member needs to do an initial sync, which can take hours.

Q10. Should I encrypt backups? Yes — if your DB has any PII or business-sensitive data. pgbackrest supports repo1-cipher-type=aes-256-cbc, MySQL with xtrabackup --encrypt, or use file-system-level encryption (LUKS) on the backup disk.

Q11. How do I run a play against an RDS instance (no SSH)? hosts: localhost, connection: local, and use the DB modules with login_host: <rds-endpoint>. The Python DB driver reaches out to RDS over TCP. No SSH required since you’re not running anything on the DB host.

Q12. What’s the equivalent of flyway info in Ansible? There isn’t one — but community.postgresql.postgresql_query against flyway_schema_history works: SELECT version, description, success FROM flyway_schema_history ORDER BY installed_rank.

Q13. How do I handle community.mongodb.mongodb_replicaset failing on a re-run? The module is mostly idempotent but members: ordering matters. Use mongodb_status to read the existing config first, then only run mongodb_replicaset if the desired state differs.

Quick Check

  1. Which collection ships postgresql_db?
  2. What does creates: do in the pg_basebackup task?
  3. How do you mark a SELECT query as not changing state?
  4. Which Mongo module initializes a replica set?
  5. What’s the safe pattern for rotating DB passwords without breaking apps?
  6. Why is no_log: true mandatory on credential tasks?
  7. Should DDL migrations live in Ansible or in Flyway/Liquibase?
  8. What does update_password: on_create mean?

Exercise

Build a complete role postgres_cluster that:

  1. Installs Postgres 15 on every host in pg_cluster group.
  2. Designates one host as primary (group var pg_role: primary) and the rest as replicas.
  3. Configures streaming replication with replication slots.
  4. Installs and configures pgBackRest with daily full + 6-hourly incremental backups, retaining 7 days.
  5. Creates appdb and appuser with appropriate grants.
  6. Installs pg_stat_statements and pg_repack extensions.
  7. Configures pg_hba.conf to allow connections only from app subnets.
  8. Includes a validate.yml task list that confirms the primary is writable and each replica is replicating (pg_is_in_recovery() = true and replay_lag < 1 second).

Test it on a 3-node setup (1 primary, 2 replicas) and verify failover by stopping the primary and running pg_promote() on a replica via Ansible.

Cert Mapping

Glossary

Next Steps

You can now bring databases into your Ansible-managed infrastructure with the same discipline as stateless services. The next lesson covers Ansible for VMware: the community.vmware collection, vCenter automation, VM lifecycle, templates, and NSX networking — the patterns that turn vSphere from a click-ops platform into infrastructure-as-code.

ansibledatabasespostgresqlmysqlmongodbreplicationbackupschema-migrationhigh-availabilitykloudvin
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments