Releasing soon Vigo is in alpha and closing in on its first stable release. Expect breaking changes between releases until then — we're looking for testing partners with meaningful fleets across diverse architectures. Learn more →

file

Manages file content, permissions, and ownership idempotently. Uses SHA-256 content hashing for drift detection and atomic writes (write to temp file, then rename) to prevent partial updates.

Parameters

Parameter Required Default Description
target_path Yes -- Absolute path to the file.
content No -- Desired file content. Supports Go templates with .Traits and .Vars namespaces.
mode No 0644 File permission mode (octal string).
state Yes -- Desired state: present or absent.
owner No -- File owner (username or UID). Linux only.
group No -- File group (group name or GID). Linux only.
secontext No -- SELinux security context, applied via chcon after writing (e.g., system_u:object_r:httpd_config_t:s0). Linux only; no-op on systems without SELinux.
baseindent No -- Number of spaces to prepend to each non-empty line of content. Useful for keeping YAML indentation clean.
source No -- Local file path on the envoy to copy content from. Mutually exclusive with content.
backup No -- Create a backup before overwriting. "true" creates <path>.<epoch>.bak; a custom string (e.g., ".orig") creates <path><suffix>. Only triggers when content differs.
validate No -- Shell command to validate content before writing. % is replaced with the temp file path. Non-zero exit blocks the write.
stream_edit No -- Path to a script (or list of paths) that transforms content before writing. See Stream Edit.

secret: in content is always a reference. A secret:<path> token anywhere in content (whole-value or mid-string) is resolved as a secret reference — the path's value is fetched and substituted in. This is how you write a secret into a file (content: "db_pw = secret:vigo/db/pw"). There is no escape, so a literal secret:<path>-shaped token you mean as plain text (e.g. documentation of the secret syntax) will be treated as a reference: it injects the value if the path exists, or fails the publish if it doesn't. To include literal secret:<path> text in a managed file, render it via a template variable rather than typing it directly into content.

States

  • present -- Ensure the file exists with the specified content, mode, owner, and group.
  • absent -- Delete the file if it exists. stream_edit is ignored.

Idempotency

The executor computes the SHA-256 hash of the desired content (after baseindent and stream_edit transforms) and compares it against the hash of the existing file. If they match and mode/owner/group are correct, no action is taken. On change, writes to a temporary file in the same directory, then atomically renames it to the target path.

Stream Edit

stream_edit pipes file content through one or more scripts on the envoy before writing it to disk. Each script receives the current content on stdin and must write the transformed content to stdout. Scripts are executed in order as a pipeline.

How It Works

  1. content is resolved (Go template rendering happens server-side)
  2. baseindent is applied (if specified)
  3. The result is piped through each stream_edit script: content → script₁ stdin→stdout → script₂ stdin→stdout → ...
  4. The final output is hashed and compared with the file on disk
  5. The file is written only if the content differs (standard idempotency)

Caching

The agent caches pipeline results in memory across convergence cycles. The cache key includes the input content hash, script paths, and script file modification times. When none of these have changed and the target file on disk matches the cached output, the pipeline is skipped entirely — the scripts do not run.

The cache invalidates automatically when:

  • The input content changes (e.g., a variable or template changed)
  • A script file is modified (detected via mtime)
  • The target file is externally modified
  • The agent restarts

Error Handling

If any script in the pipeline exits non-zero, the resource fails — no file is written. The script's stderr is included in the error message. There is no fallback to unfiltered content.

Writing Scripts

A stream_edit script is any executable file on the envoy. It must:

  • Read from stdin
  • Write to stdout
  • Exit 0 on success, non-zero on failure
  • Produce valid UTF-8 output

Example: Strip comment lines

#!/bin/sh
# /usr/local/bin/strip-comments.sh
# Removes lines starting with # (preserves inline comments)
grep -v '^[[:space:]]*#'

Example: Substitute environment variables

#!/bin/sh
# /usr/local/bin/envsubst-safe.sh
# Replaces ${VAR} placeholders from the system environment
envsubst

Example: Sort and deduplicate lines

#!/bin/sh
# /usr/local/bin/sort-dedup.sh
sort -u

Example: Inject a timestamp header

#!/bin/sh
# /usr/local/bin/add-header.sh
longdrawer "# Generated by Vigo on $(date -u +%Y-%m-%dT%H:%M:%SZ)"
longdrawer "# Do not edit manually"
longdrawer ""
cat

Examples

Single Script

resources:
  - name: app-config
    type: file
    target_path: /etc/myapp/config.conf
    content: |
      # database settings
      host = localhost
      port = 5432
      # cache settings
      ttl = 300
    stream_edit: /usr/local/bin/strip-comments.sh
    mode: "0644"

Result on disk (comment lines removed):

host = localhost
port = 5432
ttl = 300

Chained Pipeline

resources:
  - name: authorized-keys
    type: file
    target_path: /etc/ssh/authorized_keys_list
    content: |
      ssh-ed25519 AAAA... alice@example.com
      ssh-ed25519 AAAA... bob@example.com
      ssh-ed25519 AAAA... alice@example.com
    stream_edit:
      - /usr/local/bin/sort-dedup.sh
      - /usr/local/bin/add-header.sh
    owner: root
    mode: "0644"

The content is first sorted and deduplicated, then a timestamp header is prepended. The file on disk:

# Generated by Vigo on 2026-03-13T05:58:00Z
# Do not edit manually

ssh-ed25519 AAAA... alice@example.com
ssh-ed25519 AAAA... bob@example.com

With Templates and Baseindent

vars:
  db_host: db.internal
  db_port: 5432

resources:
  - name: app-config
    type: file
    target_path: /etc/myapp/config.toml
    baseindent: 2
    content: |
      [database]
      host = "{{ .Vars.db_host }}"
      port = {{ .Vars.db_port }}
    stream_edit: /usr/local/bin/strip-comments.sh
    mode: "0644"

Processing order: template rendering (server) → baseindent → stream_edit → hash compare → write.

Managing the Script with Vigo

You can use Vigo itself to deploy the transform scripts:

resources:
  - name: strip-comments-script
    type: file
    target_path: /usr/local/bin/strip-comments.sh
    content: |
      #!/bin/sh
      grep -v '^[[:space:]]*#'
    mode: "0755"

  - name: app-config
    type: file
    target_path: /etc/myapp/config.conf
    content: |
      # database settings
      host = localhost
      port = 5432
    stream_edit: /usr/local/bin/strip-comments.sh
    depends_on: [strip-comments-script]
    mode: "0644"

Use depends_on to ensure the script exists before it's used.

Security

Stream edit scripts run as root on managed nodes. The following guardrails are enforced:

Server-side (during policy bundle building):

  • Kill switch: Set stream_edit.enabled: false in server.yaml to strip all stream_edit attributes globally. Resources still converge — they just write unfiltered content.
  • Path whitelist: Scripts must reside under one of the directories in stream_edit.allowed_paths (default: ["/srv/vigo/scripts"]). Scripts outside these directories are stripped with a warning log.

Agent-side (defense-in-depth):

  • Path whitelist: The agent re-validates script paths against the allowed directories sent in the policy bundle.
  • Ownership check: Scripts must be owned by root (uid 0). Non-root-owned scripts are rejected.
  • Permission check: World-writable scripts (mode xxxx2) are rejected.
  • Per-script timeout: Each script must complete within stream_edit.default_timeout (default: 10s). Scripts exceeding this are killed and the resource fails.

Audit logging:

Every script execution is logged with: script path, exit code, input content hash, output content hash, and duration. Failed scripts log at warn level with stderr. Cache hits (where the pipeline is skipped) do not emit audit logs since no script ran.

Examples

Basic

resources:
  - name: /etc/myapp/config.toml
    type: file
    target_path: /etc/myapp/config.toml
    content: |
      [server]
      port = 8080
    mode: "0644"

With depends_on

resources:
  - name: /etc/myapp
    type: directory
    target_path: /etc/myapp

  - name: /etc/myapp/config.toml
    type: file
    target_path: /etc/myapp/config.toml
    content: |
      [server]
      port = 8080
    depends_on: /etc/myapp

With notify

resources:
  - name: /etc/nginx/nginx.conf
    type: file
    target_path: /etc/nginx/nginx.conf
    content: |
      worker_processes auto;
    notify: restart-nginx

  - name: restart-nginx
    type: service
    service: nginx
    state: restarted
    subscribes: /etc/nginx/nginx.conf

With when

resources:
  - name: /etc/apt/apt.conf.d/99proxy
    type: file
    target_path: /etc/apt/apt.conf.d/99proxy
    content: |
      Acquire::http::Proxy "http://proxy.internal:3128";
    when: "os_family('debian')"

Absent

resources:
  - name: /etc/myapp/old-config.toml
    type: file
    target_path: /etc/myapp/old-config.toml
    state: absent

Source

Copy content from a local file on the envoy instead of specifying it inline:

resources:
  - name: hosts-backup
    type: file
    target_path: /etc/hosts.managed
    source: /etc/hosts
    mode: "0644"

source and content are mutually exclusive -- setting both is an error.

Backup

Create a backup of the existing file before overwriting:

resources:
  - name: /etc/nginx/nginx.conf
    type: file
    target_path: /etc/nginx/nginx.conf
    content: |
      worker_processes auto;
    backup: "true"
    mode: "0644"

With backup: "true", the backup is named <path>.<epoch_seconds>.bak (e.g., /etc/nginx/nginx.conf.1710345600.bak). Use a custom suffix string for a fixed name:

    backup: ".orig"    # creates /etc/nginx/nginx.conf.orig

Backups are only created when the file exists and its content differs from the desired state. No backup is created on first write or when content matches.

Validate

Run a command to validate content before committing the write. The % character in the command is replaced with the path to a temporary file containing the new content:

resources:
  - name: /etc/nginx/nginx.conf
    type: file
    target_path: /etc/nginx/nginx.conf
    content: |
      worker_processes auto;
      events { worker_connections 1024; }
    validate: "nginx -t -c %"
    mode: "0644"

If the validate command exits non-zero, the file is not written and the resource fails. The temp file is cleaned up automatically.

Platform

Cross-platform. Owner/group setting requires Unix (Linux/macOS).

Notes

  • The content field supports Go template syntax ({{ }}) -- it is rendered server-side during check-in.
  • The baseindent parameter prepends spaces to each non-empty line, allowing you to indent content naturally in YAML without affecting the output.
  • Empty content creates an empty file.
  • Atomic write prevents corruption if the agent is interrupted mid-write.
  • stream_edit scripts run on the envoy, not the server. The script must already exist on the managed machine (deploy it with a file resource and depends_on).
  • Templates are not allowed in stream_edit paths. Use literal paths or vars with literal overrides at the node level.
  • Don't whole-file-manage /etc/hosts on a hostdox-enabled envoy. If swarm.hostdox: true reaches this host, the agent maintains a delimited # BEGIN/END vigo swarm peers block in /etc/hosts; a type: file resource that owns the whole file will clobber that block (and any type: host entries) on every run, hostdox re-adds it within ~1 s (it watches /etc/hosts; a ~60 s timer is the backstop), and the two flap. Use type: host for per-entry control, or type: blockinfile region editing with markers distinct from # BEGIN/END vigo swarm peers. (Same caution applies to any file another agent subsystem writes its own region into.)