There’s a simple test that separates amateur automation from production-ready infrastructure: can you run it twice?

If your deployment script works perfectly the first time but explodes on the second run, you don’t have automation — you have a time bomb with a friendly interface.

What Idempotency Actually Means

An operation is idempotent if performing it multiple times produces the same result as performing it once. In practical terms:

1
2
3
4
5
# Idempotent: always results in nginx being installed
apt install nginx

# NOT idempotent: appends every time
echo "export PATH=/opt/bin:$PATH" >> ~/.bashrc

The first command checks state before acting. The second blindly mutates.

Why It Matters More Than You Think

Recovery scenarios: When a deploy fails halfway through, you need to rerun it. Non-idempotent scripts force you to manually undo partial changes before retrying.

Drift detection: Running your automation as a scheduled job can catch configuration drift — but only if it’s safe to run repeatedly.

Parallel execution: In CI/CD with multiple runners, idempotency prevents race conditions from becoming catastrophic.

Sleep quality: Knowing you can rerun any automation at 3 AM without fear is worth more than any monitoring dashboard.

Common Anti-Patterns

The Append Trap

1
2
3
4
5
# Bad: grows infinitely
echo "alias ll='ls -la'" >> ~/.bashrc

# Good: check first
grep -q "alias ll=" ~/.bashrc || echo "alias ll='ls -la'" >> ~/.bashrc

The Create-or-Fail Pattern

1
2
3
4
5
# Bad: crashes if exists
os.mkdir("/var/app/data")

# Good: idempotent
os.makedirs("/var/app/data", exist_ok=True)

The Database Migration Landmine

1
2
3
4
5
-- Bad: fails on rerun
CREATE TABLE users (id INT PRIMARY KEY);

-- Good: idempotent
CREATE TABLE IF NOT EXISTS users (id INT PRIMARY KEY);

Building Idempotent Automation

Use Tools That Think in State

Terraform and Ansible aren’t just convenient — they’re philosophically different. They declare desired state, then converge reality to match it.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Ansible: idempotent by design
- name: Ensure nginx is installed
  apt:
    name: nginx
    state: present

- name: Ensure config line exists
  lineinfile:
    path: /etc/nginx/nginx.conf
    line: "worker_connections 1024;"
    regexp: "^worker_connections"

Ansible’s lineinfile doesn’t append — it ensures a line matching a pattern exists, replacing if needed.

Guard Your Mutations

When you must use shell commands, add guards:

1
2
3
4
5
6
7
8
# Check before creating
if [ ! -d "/opt/app" ]; then
    mkdir -p /opt/app
    chown app:app /opt/app
fi

# Use atomic operations
install -D -m 644 config.yaml /etc/app/config.yaml

Make Rollbacks Idempotent Too

If your deploy is idempotent but your rollback isn’t, you’ve solved half the problem:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# Bad: assumes specific previous state
rollback() {
    git checkout HEAD~1
    restart_app
}

# Good: rollback to known version
rollback() {
    local target_version=$1
    git checkout "v${target_version}"
    restart_app
}

Testing for Idempotence

The test is simple: run your automation twice in a row.

1
2
3
4
5
# First run: should succeed
./deploy.sh && echo "First run: OK"

# Second run: should also succeed with no changes
./deploy.sh && echo "Second run: OK"

Better yet, add this to CI:

1
2
3
4
5
test-idempotence:
  script:
    - terraform apply -auto-approve
    - terraform apply -auto-approve  # Should show "No changes"
    - test "$(terraform plan -detailed-exitcode 2>/dev/null; echo $?)" = "0"

The Convergence Mindset

Stop thinking about automation as “do these steps.” Start thinking about it as “ensure this state exists.”

The question isn’t “what commands do I run?” It’s “what should be true when this finishes?”

  • ❌ “Install nginx, then edit the config, then restart”
  • ✅ “Nginx should be running with this configuration”

This shift in thinking naturally leads to idempotent code, because you’re describing the destination, not the journey.

Real-World Application

Here’s a complete idempotent deployment script:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#!/bin/bash
set -euo pipefail

APP_DIR="/opt/myapp"
CONFIG_DIR="/etc/myapp"
SERVICE_USER="myapp"

# Ensure user exists (idempotent)
id "$SERVICE_USER" &>/dev/null || useradd -r -s /sbin/nologin "$SERVICE_USER"

# Ensure directories exist (idempotent)
install -d -o "$SERVICE_USER" -g "$SERVICE_USER" -m 755 "$APP_DIR"
install -d -m 755 "$CONFIG_DIR"

# Deploy config (idempotent: overwrites with same content)
install -m 644 config.yaml "$CONFIG_DIR/config.yaml"

# Deploy binary (idempotent: same file = no change)
install -m 755 myapp "$APP_DIR/myapp"

# Ensure service is enabled (idempotent)
systemctl enable myapp

# Restart only if binary changed
if systemctl is-active myapp &>/dev/null; then
    CURRENT_HASH=$(md5sum "$APP_DIR/myapp" | cut -d' ' -f1)
    # Store hash somewhere persistent to compare
fi

systemctl start myapp

Every command can run a thousand times and produce the same result.

The Bottom Line

Idempotency isn’t a nice-to-have — it’s the foundation of trustworthy automation. Until your scripts are safe to run twice, they’re not safe to run once.

Build for convergence. Test with repetition. Sleep soundly.