Buzeli
buzeliSoluções Digitais
Incidents

`docker compose` with sed -i: why the config changed in the file but the container ignored it — and how the inode caused an OOM loop

Published on April 28, 2026

The server rebooting from OOM — but the config was correct

A WordPress/Drupal publisher running a Docker stack on AWS was in an OOM loop. The server was rebooting repeatedly. Connecting via SSH, the uptime showed '2 minutes' — it had just come back up from the latest crash. The site was returning 502.

The server was a t4g.medium with 3.4 GB of RAM. Memory check showed:

Copy
free -h
#               total        used        free      shared  buff/cache   available
# Mem:          3.4Gi       3.0Gi       96Mi       12Mi      370Mi       96Mi
# Swap:         1.2Gi       1.2Gi        0Ki

docker stats --no-stream --format "table {{.Name}}	{{.MemUsage}}	{{.MemPerc}}"
# CONTAINER    MEM USAGE / LIMIT    MEM %
# cache        1.796GiB / 3.4GiB   52.38%
# php-fpm      406MiB / 3.4GiB     11.56%
# app-firewall  77MiB / 3.4GiB       2.20%
# security-agent 56MiB / 3.4GiB       1.60%
# nginx        52MiB / 3.4GiB       1.49%
# metrics-exporter 13MiB / 3.4GiB      0.38%

Redis (called 'cache' in compose) consuming 1.796 GiB — 52% of total RAM. With PHP-FPM, nginx, and the other containers combined, the server was consistently over capacity. The kernel OOM killer terminated the greediest process, the container restarted, and the cycle began again.

The fix applied in the previous session — that didn't work

In the previous session, the fix had been applied via sed -i:

Copy
# Previous session: reduce maxmemory from 2048mb to 1024mb
sudo sed -i 's/--maxmemory 2048mb/--maxmemory 1024mb/' /opt/myapp/docker-compose.yml

# Immediate verification showed the file was correct:
grep maxmemory /opt/myapp/docker-compose.yml
# command: valkey-server --maxmemory 1024mb --maxmemory-policy allkeys-lru

The file was correct. The line with 1024mb was there. The fix looked applied. But the server kept rebooting from OOM.

When the file shows one thing and the container does another, the problem is usually which version of the file the container is reading — not the file's content.

The diagnosis: two inodes, two versions of the file

Checking via docker inspect revealed the problem:

Copy
# Check actual config of the cache container
docker inspect cache --format "{{.Config.Cmd}}"
# [valkey-server --maxmemory 2048mb --maxmemory-policy allkeys-lru]
#                            ^^^^^^ STILL 2048mb — ignoring the file!

# Check the file on the host
grep maxmemory /opt/myapp/docker-compose.yml
# command: valkey-server --maxmemory 1024mb --maxmemory-policy allkeys-lru
#                                   ^^^^^^ 1024mb — file is correct

The container was running with --maxmemory 2048mb. The file had 1024mb. How can they differ if compose is bind-mounted?

How sed -i behaves with inodes

sed -i doesn't edit the file in-place. What it actually does internally is:

1. Read the original file (inode A)

2. Create a temporary file with the substitutions (new inode B)

3. Rename the temporary file to the original name

4. Inode A (original) still exists if anyone has a file descriptor open

In practice, after sed -i, the name docker-compose.yml points to inode B (with 1024mb). But Docker, when creating the container, opened a file descriptor for inode A (with 2048mb). The container doesn't read the file by name — it read the content at creation time and holds onto those settings.

Copy
# Demonstrating inode behavior with sed -i
# Before:
stat /opt/myapp/docker-compose.yml | grep 'Inode'
# Inode: 1234567

sudo sed -i 's/--maxmemory 2048mb/--maxmemory 1024mb/' /opt/myapp/docker-compose.yml

# After:
stat /opt/myapp/docker-compose.yml | grep 'Inode'
# Inode: 1234999   ← NEW inode! sed -i created a new file

# A container created with the original inode doesn't see the change

This applies to any configuration passed as an argument in the compose command: — Docker uses the value at container creation time, it doesn't re-read the file on each start. nginx -s reload inside a container reads nginx.conf via bind mount (which is why it works with python3/tee in-place editing), but --maxmemory is a Redis startup argument, not a dynamically re-read config.

Why nginx -s reload wasn't enough

In another container in the same stack (the WAF running nginx), there was a similar problem: User-Agent configurations added via sed -i to the host's waf.conf file weren't appearing inside the container after nginx -s reload.

nginx re-reads its configuration files via bind mount when it receives SIGHUP (nginx -s reload). But it only works if the bind mount still points to the correct inode. When sed -i creates a new inode for waf.conf, Docker's bind mount keeps pointing to the old inode — and nginx re-reads the old file.

Copy
# WRONG pattern: sed -i + nginx -s reload (doesn't work with bind mounts)
sudo sed -i 's/old-value/new-value/' /home/developer/webserver/waf.conf
docker exec app-firewall nginx -s reload  # reads old inode — change ignored

# CORRECT pattern option 1: python3 preserves the inode (writes to same file)
python3 -c "
with open('/home/developer/webserver/waf.conf', 'r') as f:
    content = f.read()
content = content.replace('old-value', 'new-value')
with open('/home/developer/webserver/waf.conf', 'w') as f:
    f.write(content)
"
docker exec app-firewall nginx -s reload  # now works — same inode, new content

# CORRECT pattern option 2: recreate the container
docker compose up -d --force-recreate app-firewall

The definitive fix: recreate the container

The solution for Redis was simple — recreate the container so it would read the updated docker-compose.yml:

Copy
cd /opt/myapp && sudo docker compose up -d cache

# Post-recreate verification:
docker inspect cache --format "{{.Config.Cmd}}"
# [valkey-server --maxmemory 1024mb --maxmemory-policy allkeys-lru]
#                            ^^^^^^ now 1024mb

docker exec cache valkey-cli config get maxmemory
# 1) "maxmemory"
# 2) "1073741824"   ← exactly 1 GB

With the container recreated, Redis started using 1 GB maxmemory. The memory result was immediate:

Copy
# Before/after comparison after recreating the cache container
# Metric               Before       After
# Redis maxmemory      2 GB         1 GB
# Redis actual usage   468 MB       458 MB
# Free RAM             370 MB       648 MB
# Available            ~400 MB      985 MB
# Swap in use          1.2 GB       88 MB

648 MB free against 370 MB before — a difference that means the gap between OOM crash and stable server. The reboot loop stopped immediately.

The general rule: sed -i + bind mount = change that doesn't persist

This is a principle that applies to the entire Docker stack with bind mounts:

Any tool that creates a new file when editing (sed -i, awk, cp, mv over the original) will create a new inode and Docker's bind mount will keep pointing to the original inode. The change exists on the host but is invisible to the container.

Tools that preserve the inode (edit in-place):

python3 open(path, 'w') — truncates and rewrites the same inode

tee — writes to the existing file descriptor

perl -pi -e — edits in-place preserving inode (unlike sed -i on some systems)

vim / nano — depending on config, may or may not preserve the inode

The safest and most explicit way to ensure a change reaches the container:

Copy
# Verify the change is in the host file
grep 'new-value' /path/to/config.conf

# Verify the container sees the change
docker exec <container> cat /path/to/config.conf | grep 'new-value'

# If the outputs differ: recreate the container
docker compose up -d --force-recreate <service>

The full context: t4g.medium with 2 GB Redis

The original configuration of --maxmemory 2048mb on a 3.4 GB total server was unsustainable from the start. With Redis using 2 GB, only 1.4 GB remained for all the other containers (PHP-FPM, nginx, WAF, CrowdSec) plus the OS. Under normal traffic, the system was on the edge of OOM; under scraper traffic, it crossed the line.

The operational rule for sizing Redis on shared servers: Redis should not use more than 30-40% of total RAM when there are multiple containers. For a t4g.medium with 3.4 GB, the healthy limit is approximately 1 GB — which is what the fix established.

The incident had two layers: the immediate cause (Redis with 2 GB maxmemory on a 3.4 GB server) and the hidden cause (sed -i creating a new inode, fix apparently applied but never effective). The server was rebooting because the fix never reached the container. OOM diagnoses that don't check docker inspect don't reach the real cause.

Lesson: always verify docker inspect after changing container config

The correct workflow for any configuration change in Docker containers:

1. Edit the configuration file on the host (using python3/tee to preserve inode, or accepting that you'll need to recreate)

2. Verify the host file has the correct value (grep/cat)

3. Verify the container sees the correct value (docker exec + cat, or docker inspect for startup args)

4. If the values differ: recreate the container (docker compose up -d <service>)

Step 3 is what most people skip — and it's exactly what would have prevented the OOM loop in this case. The file was correct. The container was not.