Introduction

Need to create a file with specific content on remote hosts? Ansible offers several approaches: copy with content for simple text, template for dynamic files with variables, lineinfile for single-line edits, and blockinfile for multi-line insertions. This guide shows when to use each.

Method 1: copy with content

# Create file with static content
- name: Create environment file
  ansible.builtin.copy:
    content: |
      APP_ENV=production
      APP_PORT=8080
      LOG_LEVEL=info
    dest: /etc/myapp/.env
    owner: app
    group: app
    mode: '0600'

With Variables

- name: Create config with variables
  ansible.builtin.copy:
    content: |
      [database]
      host = {{ db_host }}
      port = {{ db_port }}
      name = {{ db_name }}
      user = {{ db_user }}
    dest: /etc/myapp/database.ini
    mode: '0640'

JSON Content

- name: Create JSON config
  ansible.builtin.copy:
    content: "{{ config | to_nice_json }}\n"
    dest: /etc/myapp/config.json
  vars:
    config:
      server:
        host: "0.0.0.0"
        port: 8080
      database:
        url: "postgresql://{{ db_host }}:5432/{{ db_name }}"
      logging:
        level: info
        file: /var/log/myapp/app.log

YAML Content

- name: Create YAML config
  ansible.builtin.copy:
    content: "{{ config | to_nice_yaml }}"
    dest: /etc/myapp/config.yml
  vars:
    config:
      redis:
        host: redis.internal
        port: 6379
      workers: 4

Method 2: template Module

# templates/app.conf.j2
server {
    listen {{ http_port }};
    server_name {{ server_name }};

    location / {
        proxy_pass http://127.0.0.1:{{ app_port }};
    }

{% if ssl_enabled %}
    listen 443 ssl;
    ssl_certificate {{ ssl_cert_path }};
    ssl_certificate_key {{ ssl_key_path }};
{% endif %}
}
- name: Deploy from template
  ansible.builtin.template:
    src: app.conf.j2
    dest: /etc/nginx/conf.d/app.conf
    owner: root
    mode: '0644'
  notify: Reload nginx

Method 3: lineinfile (Single Line)

# Add or modify a single line
- name: Set hostname in /etc/hosts
  ansible.builtin.lineinfile:
    path: /etc/hosts
    line: "{{ ansible_default_ipv4.address }} {{ inventory_hostname }}"
    regexp: "^.*{{ inventory_hostname }}$"
    state: present

# Ensure a line exists
- name: Enable IP forwarding
  ansible.builtin.lineinfile:
    path: /etc/sysctl.conf
    line: "net.ipv4.ip_forward = 1"
    regexp: "^net.ipv4.ip_forward"

Method 4: blockinfile (Multi-line Block)

- name: Add SSH config block
  ansible.builtin.blockinfile:
    path: /etc/ssh/sshd_config
    block: |
      Match Group developers
        AllowTcpForwarding yes
        X11Forwarding no
        PermitTunnel no
    marker: "# {mark} ANSIBLE MANAGED - developers access"
  notify: Restart sshd

Method 5: Create Empty File

# Touch (create if missing, update timestamp if exists)
- name: Create empty file
  ansible.builtin.file:
    path: /var/log/myapp/app.log
    state: touch
    owner: app
    mode: '0644'

# Create only if missing (don't update timestamp)
- name: Create file only if absent
  ansible.builtin.copy:
    content: ""
    dest: /etc/myapp/custom.conf
    force: false  # Won't overwrite existing
    mode: '0644'

Comparison: When to Use Each

MethodUse Case
copy + contentSimple text, env files, small configs
templateComplex files with logic (loops, conditionals)
lineinfileModify one line in existing file
blockinfileInsert/update a multi-line section
file + state: touchCreate empty file

Advanced Patterns

Dynamic File from Loop

- name: Generate hosts file entries
  ansible.builtin.copy:
    content: |
      # Managed by Ansible
      {% for host in groups['webservers'] %}
      {{ hostvars[host].ansible_host }} {{ host }}
      {% endfor %}
    dest: /etc/hosts.d/webservers

Multiline String Formats

# Literal block (preserves newlines)
- ansible.builtin.copy:
    content: |
      line 1
      line 2
      line 3
    dest: /tmp/literal.txt
# Result: "line 1\nline 2\nline 3\n"

# Folded block (joins lines)
- ansible.builtin.copy:
    content: >
      This is a single
      long line that gets
      folded together.
    dest: /tmp/folded.txt
# Result: "This is a single long line that gets folded together.\n"

# Strip trailing newline
- ansible.builtin.copy:
    content: |-
      no trailing newline
    dest: /tmp/no-newline.txt

Create File from Command Output

- name: Get system info
  ansible.builtin.command: uname -a
  register: system_info
  changed_when: false

- name: Write system info to file
  ansible.builtin.copy:
    content: "{{ system_info.stdout }}\n"
    dest: /etc/system-info.txt

Atomic File Creation (Validate First)

- name: Create sudoers entry (validated)
  ansible.builtin.copy:
    content: "deploy ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart myapp\n"
    dest: /etc/sudoers.d/deploy
    mode: '0440'
    validate: "visudo -cf %s"

Troubleshooting

IssueFix
File always "changed"Check for trailing newlines — add \n to content
Variables not expandedUse template module, not copy for Jinja2 logic
Permission deniedAdd become: true
Directory doesn't existCreate with file: state=directory first
Content has wrong encodingSet content as UTF-8, check source encoding

Best Practices

  1. Use template for anything complex — Jinja2 logic belongs in .j2 files
  2. Always end content with \n — POSIX requires trailing newline
  3. Set explicit permissions — mode: '0644' prevents umask surprises
  4. Use validate for critical files — sudoers, nginx, sshd configs
  5. Use blockinfile for managed sections — markers prevent duplicates on re-run
  6. Prefer copy + content over shell echo — idempotent and auditable

Conclusion

For simple files, use copy with content. For files with variables and logic, use template. For editing existing files, use lineinfile (single line) or blockinfile (sections). Always set permissions, end content with a newline, and use validate for syntax-critical files.