Sync Files Between Linux Servers with Ansible

Keeping files synchronized between Linux servers is a common task. You may want to copy application configuration, NGINX settings, SSL certificates, or other files from one server to another to make both look identical.

Ansible provides several ways to copy files, but for server-to-server synchronization, ansible.posix.synchronize is usually the best choice. It uses rsync, so only changed files are transferred, making it much faster than copying everything every time.

The playbook performs four steps:

  • Check whether the target server already has an SSH key.
  • Upload the SSH key if it is missing.
  • Allow the target server to connect to the source server.
  • Synchronize the required directories or files.

Prerequisites: network connection between servers, configured sudoers , generated a pair of ssh keys, defined variables used in the playbook

Step 1 – Check whether an SSH key already exists

Before uploading a key, the playbook checks whether one is already present.

- name: Check if id_rsa key already exists (target)
ansible.builtin.stat:
path: ~/.ssh/id_rsa
register: id_rsa
become: true
become_user: "{{ target_host_user }}"
when: ('target_host' in inventory_hostname)

The important parts are:

  • register: id_rsa stores the result of the stat module so later tasks can check whether the file exists.
  • become_user: "{{ target_host_user }}" runs the task as the application user instead of root. This checks the correct home directory.
  • when ensures this task runs only on the target server.

Step 2 – Upload the SSH key

If the key does not exist, create the .ssh directory and upload both the private and public keys.

- name: Set target ssh
when: ('target_host' in inventory_hostname) and (not id_rsa.stat.exists)
become: true
become_user: "{{ target_host_user }}"
block:
- name: Set target ssh | Create directories for ssh (target)
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: "{{ target_host_user }}"
group: "{{ target_host_user }}"
mode: "0700"
loop:
- ~/.ssh
- name: Set target ssh | Upload keys to ssh dir
no_log: true
ansible.builtin.copy:
dest: "~/.ssh/{{ item.file }}"
content: "{{ item.content }}"
mode: "{{ item.mode }}"
loop:
- { file: "id_rsa", content: "{{ target_host_private_key }}", mode: "0600" }
- { file: "id_rsa.pub", content: "{{ target_host_public_key }}", mode: "0644" }

The interesting options are:

  • block groups related tasks under a single when condition.
  • no_log: true prevents the private SSH key from appearing in the Ansible output or logs.
  • loop allows us to interact with multiple files in just a one task
  • The task only runs when id_rsa.stat.exists is false, making the playbook idempotent.

Step 3 – Allow the target server to connect

The source server must trust the target server’s SSH key.

- name: Add target public key to source host
ansible.posix.authorized_key:
user: "{{ source_host_user }}"
key: "{{ target_host_ssh_public_key }}"
state: present
register: auth_key
when: ('source_host' in inventory_hostname)

The authorized_key module adds the target server’s public key to the source server’s authorized_keys file. After this, the target server can connect over SSH without a password.

Step 4 – Synchronize the files

Finally, synchronize the required directories.

- name: Synchronize source and target files (target)
ansible.posix.synchronize:
src: "{{ source_host_user }}@{{ source_host }}:{{ item.src }}"
dest: "{{ item.dest }}"
mode: pull
rsync_path: sudo rsync
rsync_opts:
- "-e 'ssh -i /home/{{ target_host_user }}/.ssh/id_rsa'"
- "--mkpath"
- "--delete"
loop:
- { src: "/etc/nginx/", dest: "/etc/nginx/" }
- { src: "{{ app_dir }}/config/", dest: "{{ app_dir }}/config/" }
- { src: "/home/{{ app_user }}/.somefile", dest: "/home/{{ app_user }}/" }
delegate_to: "{{ inventory_hostname }}"
register: rsync_result
failed_when: rsync_result.rc not in [0, 23]
become: true
when: ('target_host' in inventory_hostname) and (auth_key is defined)

A few options are worth explaining:

  • mode: pull means the target server connects to the source server and downloads the files.
  • src: "{{ source_host_user }}@{{ source_host }}:{{ item.src }}" tells rsync to read the files directly from the remote server over SSH.
  • rsync_path: sudo rsync runs rsync with sudo on the source server, allowing access to protected directories such as /etc/nginx.
  • -e 'ssh -i ...' tells rsync which SSH private key to use.
  • --mkpath creates the destination directory if it does not already exist.
  • --delete removes files that no longer exist on the source server, keeping both servers synchronized.
  • delegate_to: "{{ inventory_hostname }}" makes sure the synchronization runs on the target server. Since we use mode: pull, the target server is responsible for opening the SSH connection.
  • failed_when: rsync_result.rc not in [0, 23] ignores rsync exit code 23, which commonly indicates that a source file or directory does not exist. Depending on your environment, this may be expected and should not fail the playbook.

Push vs Pull

The synchronize module supports two synchronization modes.

  • Push (mode: push) – the source server connects to the destination server and sends the files
  • Pull (mode: pull) – the destination server connects to the source server and downloads the files.

This example uses pull mode because the target server initiates the SSH connection. This is often preferred when the source server should not connect to other machines.

Other ways to copy files

The synchronize module is not the only option.

  • copy – Copies files from the Ansible control node to managed hosts. Best for small files or configuration files.
  • template – Similar to copy, but processes Jinja2 templates before copying.
  • fetch – Copies files from managed hosts back to the Ansible control node.
  • command or shell – Runs rsync directly if you need options that are not supported by the synchronize module.

I hope the post was useful.

Cheers.

How to fix Allure TestOps configuration errors (PostgreSQL, Redis, Spring, RabbitMQ)

Recently, I have migrated our highly available Allure TestOps environment from Kubernetes (AWS) to an RPM-based setup running in on-premise environment. During the migration, I encountered several configuration problems related to task executors, Redis Sentinel with TLS, authentication handling, and RabbitMQ quorum queues.

Before diving into the problems, here is the architecture used in the target environment (on-premise):

  • RabbitMQ cluster (3 nodes, initially using classic queue; later migrated to quorum queues)
  • Redis Sentinel (3 nodes, TLS enabled, internal CA)
  • PostgreSQL (3 nodes, Patroni, dedicated etcd cluster with also 3 nodes)
  • Stateless Allure TestOps (3 “replicas”; 26.1.1 version and then 26.2.1.5)

Issue #1 — Spring Task Executor Initialization Failure

After the migration and startup of the new RPM-based Allure instance, the application failed during initialization with the following error:

org.springframework.beans.factory.UnsatisfiedDependencyException:
Error creating bean with name 'exportController'

...

Failed to instantiate
[org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor]:

Factory method 'taskExecutor' threw exception with message: null

The issue was related to changes in task executor logic introduced in the 26.1.1 release. The default thread pool configuration was no longer sufficient for our workload. Although existing executor settings were already present, additional pool sizing parameters became mandatory in practice.

Adding the following parameters resolved the startup issue:

ALLURE_TASKEXECUTOR_MAXPOOLSIZE=200
ALLURE_TASKEXECUTOR_QUEUECAPACITY=1000

Issue #2 — Redis Sentinel TLS Connection Failures

The next issue involved connecting Allure TestOps to Redis Sentinel with TLS enabled:

Caused by: io.lettuce.core.RedisConnectionException:
Cannot connect to Redis Sentinel at redis://1.redisdb.corp.net:26379

java.net.SocketException: Connection reset

The issue was caused by an incorrect Redis SSL property.

Initially configured:

SPRING_REDIS_SSL=true

However, Spring Data Redis implementations expect a different parameter when Redis Sentinel used:

SPRING_DATA_REDIS_SSL_ENABLED=true

After updating the property, the TLS connection to Redis Sentinel started working correctly.

Issue #3 — Redis Sentinel Authentication Problems

After fixing TLS, another Redis-related issue appeared during startup:

NOAUTH HELLO must be called with the client already authenticated.Alternatively, the HELLO <proto> AUTH <user> <pass> option can be usedto authenticate the client and select the RESP protocol version at the same time.

Authentication for Redis Sentinel itself was missing.

Even though the main Redis password was configured, Sentinel authentication requires its own dedicated parameter.

Adding the following parameter resolved the issue:

SPRING_DATA_REDIS_SENTINEL_PASSWORD=pass

And the final Redis configuration looked like this:

# Redis
SPRING_DATA_REDIS_SENTINEL_MASTER=redis
SPRING_DATA_REDIS_SENTINEL_NODES=1.redisdb.corp.net:26379,2.redisdb.corp.net:26379,3.redisdb.corp.net:26379
SPRING_DATA_REDIS_SENTINEL_PASSWORD=pass
SPRING_DATA_REDIS_PASSWORD=pass
SPRING_DATA_REDIS_SSL_ENABLED=true
SPRING_SESSION_STORE_TYPE=REDIS
SPRING_DATA_REDIS_DATABASE=0
ALLURE_REDIS_SESSIONTTL=10d

Issue #4 — RabbitMQ Quorum Queues Not Being Created

The last major issue appeared after upgrading to Allure TestOps 26.2.1.4.

This release introduced support for quorum queues in RabbitMQ.

RabbitMQ cluster was already configured with:

"default_queue_type": "quorum"

However, Allure continued creating classic queues.

Even though RabbitMQ supported quorum queues globally, Allure TestOps still required explicit quorum queue activation through application configuration.

The following parameters must be configured:

ALLURE_UPLOAD_QUORUM_ENABLED=true # Enables quorum queues for all declared queues.
ALLURE_UPLOAD_QUORUM_INITIALGROUPSIZE=3 # Defines the number of replicas for quorum queues.
ALLURE_UPLOAD_QUORUM_DELIVERYLIMIT=5 # Controls the maximum number of message redeliveries.

Bonus Observation: RabbitMQ Messages Stuck in Ready State

During the migration, I also encountered an unusual issue with RabbitMQ 4.3.1 and Allure TestOps 26.2.4 which occurred a few days after migrating to quorum queues. At this point, it’s unclear whether this was caused by a product bug, a RabbitMQ-specific behavior, or a configuration-related issue.

The environment had been operating normally with no signs of instability. However, on one occasion, approximately 4,000 messages accumulated in the Ready state and were not consumed by Allure TestOps. Perfect logs, no connectivity issues, no performance bottlenecks and etc.

The issue was resolved by simply restarting the Allure TestOps application instances, after which message consumption resumed immediately and the queue was processed successfully.

Since the problem occurred only once and has not been reproduced, I could not unable to determine the exact root cause. Teams running large-scale Allure TestOps deployments with RabbitMQ quorum queues may want to monitor queue consumer activity and message backlog metrics closely after upgrades.

I hope these undocumented findings will save someone hours or days during migration or upgrading Allure TestOps.