Files
hermes-skills/devops/docker-host-administration/references/docker-service-reconstruction-2026-07.md
T

7.6 KiB

Docker Service Reconstruction After Config Loss — 2026-07-05

Scenario

CT 111 (Proxmox LXC container, 10.0.30.99) had its root filesystem partially cleared — all Docker compose files, .env files, and container state were deleted. However, data on bind-mount volumes survived because they were on separate RBD-backed mount points. This reference covers rebuilding Docker services from surviving data without losing anything.

Key Principles

  1. NEVER delete or overwrite existing data directories — only create new compose/config files
  2. Inspect surviving data before writing anything — read existing config files (seahub_settings.py, postgresql.conf, etc.) to extract credentials and settings
  3. Bind mounts survive root FS clearing — data on separate partitions/RBDs is safe
  4. Docker images often survive — check docker images before pulling
  5. Postgres data is self-containedPOSTGRES_PASSWORD env var is only used for initialization; existing DBs keep their own auth. Reset password with ALTER USER after starting.

File Transfer to Proxmox CTs

Problem: scp to PVE nodes can hang or get blocked by security scanners

Solution: base64-over-SSH (reliable, no scp needed)

# Write file locally, then transfer via base64 encoding
B64=$(base64 -w0 /tmp/compose-file.yml)
ssh root@<pve-node> "pct exec <CT_ID> -- bash -c 'echo $B64 | base64 -d > /path/to/file'"

Solution: pct push (writes file into CT from PVE host)

# Copy file to PVE host first (via base64 if scp fails)
B64=$(base64 -w0 /tmp/file)
ssh root@<pve-node> "echo $B64 | base64 -d > /tmp/file"
# Then push into CT
ssh root@<pve-node> "pct push <CT_ID> /tmp/file /path/inside/ct/file"

Pitfall: Shell variable expansion eats ${VAR} in heredocs

When writing Docker compose files via bash -c 'cat > file << EOF', shell expands ${VARIABLE} references. The resulting file has empty values where compose variables should be.

Fix: Write files locally with write_file, then transfer via base64 or pct push. Never use heredocs for compose files with ${VAR} interpolation.

Multi-line RSA Keys in Docker .env Files

Problem

Docker Compose .env files parse each line as KEY=value. Multi-line RSA private keys (PEM format with -----BEGIN PRIVATE KEY----- headers) break the parser because:

  • Newlines split the value across multiple lines
  • + characters in base64 trigger "unexpected character in variable name" errors
  • Quoting doesn't help — the parser still chokes on +

Solution: Escape newlines as literal \n (two chars: backslash + n)

import pathlib

pem = pathlib.Path('/tmp/jwt_private_key.pem').read_text()
single_line = pem.strip().replace('\n', '\\n')

# Write to .env with the escaped key
env_content = f"""JWT_PRIVATE_KEY={single_line}
OTHER_VAR=value
"""
pathlib.Path('/opt/app/.env').write_text(env_content)

Docker Compose will interpolate \n back to actual newlines when passing the env var to the container. The container receives a proper multi-line PEM key.

Pitfall: sed fails on keys containing /

Using sed "s|^JWT_PRIVATE_KEY=.*|$KEY|" fails because RSA keys contain / characters which conflict with sed's delimiter. Use Python for safe string manipulation.

Postgres Recovery with Existing Data

Starting Postgres with existing data directory

When the Postgres data directory survives (on a bind mount), the POSTGRES_PASSWORD env var is ignored — it's only used for initial database creation on empty directories. Postgres will start with whatever passwords are already in the cluster.

# Remove stale postmaster.pid (leftover from unclean shutdown)
rm -f /mnt/data/postgres/postmaster.pid

# Start postgres container — it will detect existing data and skip init
docker compose up -d database
# Logs show: "PostgreSQL Database directory appears to contain a database; Skipping initialization"
# Then: "database system was not properly shut down; automatic recovery in progress"
# Then: "database system is ready to accept connections"

Resetting the postgres password after start

Since we don't know the original password, reset it to match the .env:

# Use psql inside the container — quoting hell through nested SSH
# This pattern works:
ssh root@<node> 'pct exec <CT> -- bash -c "docker exec <pg_container> psql -U postgres -c \"ALTER USER postgres WITH PASSWORD '"'"'NewPassword!'"'"';\""'
# Output: ALTER ROLE

Immich Recovery Pattern

Surviving Data

  • /mnt/data/immich-library/ — photo library (261 GB, years 2004-2026)
  • /mnt/data/immich-postgres/ — Postgres 14 data directory
  • immich_model-cache Docker volume — ML model cache

Steps

  1. Download official Immich docker-compose.yml from GitHub releases
  2. Create .env with: UPLOAD_LOCATION=/mnt/data/immich-library, DB_DATA_LOCATION=/mnt/data/immich-postgres, DB_PASSWORD=<new_password>
  3. Set model-cache volume as external: true (existing volume)
  4. Start database first: docker compose up -d database
  5. Remove stale postmaster.pid if present
  6. Reset postgres password: ALTER USER postgres WITH PASSWORD '<new_password>'
  7. Start full stack: docker compose up -d
  8. Verify: curl -s -o /dev/null -w "%{http_code}" http://localhost:2283 → 200

Immich Compose Template (v2.6.3, OpenVINO ML)

Key compose elements for recovery:

  • Server image: ghcr.io/immich-app/immich-server:release
  • ML image: ghcr.io/immich-app/immich-machine-learning:release-openvino (Intel Arc/OpenVINO)
  • Postgres image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0 (pinned by digest)
  • Redis: docker.io/valkey/valkey:9 (pinned by digest)
  • Model cache volume: external: true, name: immich_model-cache

Seafile Recovery Pattern

Surviving Data

  • /opt/seafile-data/ — file storage (blocks, commits, fs directories) — 249 GB
  • /opt/seafile-data/seafile/conf/ — seahub_settings.py, seafile.conf, gunicorn.conf.py
  • Docker images: seafileltd/seafile-mc:12.0-latest, mariadb:10.11, memcached:1.6.29

Lost Data

  • /opt/seafile-mysql/db — MariaDB database (was on root FS)
  • /opt/seadoc-data/ — SeaDoc data
  • All compose files and .env

Steps

  1. Download official Seafile CE 12.0 compose templates (seafile-server.yml, caddy.yml, seadoc.yml)
  2. Extract credentials from surviving config: cat /opt/seafile-data/seafile/conf/seafile.conf → DB password
  3. Extract domain from seahub_settings.pySERVICE_URL = "https://seafile.familie-schoen.com"
  4. Generate new JWT_PRIVATE_KEY (RSA 2048): openssl genpkey -algorithm RSA -out jwt.pem -pkeyopt rsa_keygen_bits:2048
  5. Convert to single-line \n-escaped format for .env (see above)
  6. Create missing directories: mkdir -p /opt/seafile-mysql/db /opt/seafile-caddy /opt/seadoc-data
  7. Start services: docker compose up -d
  8. MariaDB initializes fresh — Seafile will re-init DB but file storage is preserved

Post-Recovery Issue

The MariaDB database is freshly initialized. File storage (blocks/commits/fs) survives but the DB doesn't know about the libraries. SeaDoc data is lost. Libraries need to be reconstructed using seaf-fsck or manually re-created and linked to existing storage.

Root FS Space Management

When pulling many Docker images on a CT with limited root FS:

# Check space BEFORE pulling
df -h /

# If root FS is >90% full, prune before pulling
docker image prune -f
docker builder prune -f

# Monitor during pulls
watch -n 10 'df -h / | tail -1'

CT 111 had 49 GB root FS at 93% after pulling all images for Immich + Seafile. Consider docker image prune after services are running.