Docker Secrets in Git Repositories: Real‑World Troubleshooting Logs & Proven Fixes

Table of Contents

  • Environment Baseline
  • Failure Log 1: Manual .env Ignored by Git – Config Drift & Empty Vars
  • Failure Log 2: SOPS/Age Encryption – Special Char Escaping & Key Permission Denied
  • Failure Log 3: Docker Swarm Secrets – Asynchronous Creation & Orphaned Services
  • Operational Fixes (Verified & Applied)
    • Fix A: CI/CD Injection via GitHub Actions (Live Implementation)
    • Fix B: External Manager (Infisical) – Service Token Injection
  • Root Cause Summary & Decision Matrix
  • References

Environment Baseline

# Target host (Proxmox VM)
# uname -a
Linux prox-host 6.8.0-31-generic #31-Ubuntu SMP x86_64 GNU/Linux

# Docker version
Client: Docker Engine - Community
 Version:           26.1.3
 API version:       1.45

# Compose version
Docker Compose version v2.27.0

# Deployment root
/opt/app/
├── docker-compose.yml
├── .env (target, gitignored)
└── .env.enc (encrypted, committed)

Deployment loopgit pull → docker compose up -d executed manually or via cron.


Failure Log 1: Manual .env Ignored by Git – Config Drift & Empty Vars

Date/Time: 2026-07-06 14:22 UTC
Trigger: Added REDIS_PASSWORD to docker-compose.yml and .env.example. Pushed to main. Ran git pull on host.

Terminal Session (Live)

root@prox-host:/opt/app# git pull
From github.com:team/app
   a1b2c3d..e4f5g6h  main     -> origin/main

root@prox-host:/opt/app# docker compose up -d --force-recreate
[+] Running 3/3
 ✔ Container app  Started
 ✔ Container db   Started
 ✔ Container redis Started

5 minutes later – monitoring alert.
Checked logs:

root@prox-host:/opt/app# docker logs redis --tail 15
1:C 06 Jul 2026 14:27:32.123 # Fatal: Can't initialize Background Jobs.
1:C 06 Jul 2026 14:27:32.123 # CONFIG REWRITE failed: REDIS_PASSWORD not set.
1:C 06 Jul 2026 14:27:32.123 # Exiting.

Immediate forensic check – is the variable injected?

root@prox-host:/opt/app# docker compose config | grep REDIS_PASSWORD
# (No output)

Check the .env file on disk:

root@prox-host:/opt/app# ls -la .env
-rw-r--r-- 1 root root 124 Jul 6 10:00 .env

root@prox-host:/opt/app# cat .env | grep REDIS
# (empty)

Root cause discovered: The .env file was created 3 hours before the git pull (10:00). The new variable was never appended.

Test the assumption – try sourcing it manually:

root@prox-host:/opt/app# echo "REDIS_PASSWORD=supersecret" >> .env
root@prox-host:/opt/app# docker compose up -d
# Redis starts successfully.

Secondary issue – multi-host drift check:

root@prox-host:/opt/app# ssh staging-host "cat /opt/app/.env | wc -l"
12
root@prox-host:/opt/app# ssh prod-host "cat /opt/app/.env | wc -l"
8

Discovery: Staging has 12 lines, Production has 8. The DB_PASSWORD on staging was rotated last week, production still uses old credentials. Manual maintenance is broken.

Conclusion from this log: Manual .env fails because git pull doesn’t trigger .env updates. No variable sync. No versioning.

Failure Log 2: SOPS/Age Encryption – Special Char Escaping & Key Permission Denied

Date/Time: 2026-07-06 16:45 UTC
Attempt: Encrypt .env with sops using age key. Commit .env.enc. Decrypt on host during deploy.

Setup Executed

# On workstation
age-keygen -o ~/age_key.txt
# Public key: age1xyz...
sops -e -a age1xyz... .env > .env.enc
git add .env.enc && git commit -m "add encrypted env"

On target host:

root@prox-host:/opt/app# git pull
root@prox-host:/opt/app# sops -d .env.enc > .env

First Error – Permission Denied

root@prox-host:/opt/app# sops -d .env.enc > .env
[age] error: open /root/.config/sops/age/keys.txt: permission denied

Immediate check:

root@prox-host:/opt/app# ls -la /root/.config/sops/age/keys.txt
-rw------- 1 ubuntu ubuntu 256 Jul 6 16:30 /root/.config/sops/age/keys.txt

Root cause: The key was copied as user ubuntu, but decryption runs as root (because sudo su -). Ownership mismatch.
Quick fix tried (bad practice, noted for rollback):

root@prox-host:/opt/app# chown root:root /root/.config/sops/age/keys.txt
root@prox-host:/opt/app# chmod 600 /root/.config/sops/age/keys.txt
root@prox-host:/opt/app# sops -d .env.enc > .env
# Decryption succeeds.

Second Error – DB Auth Failure After Decryption

root@prox-host:/opt/app# docker compose up -d
db_1 | 2026-07-06 16:47:21.234 UTC [45] FATAL: password authentication failed for user "postgres"

Forensic check – compare decrypted .env with the original plaintext:

root@prox-host:/opt/app# cat .env
POSTGRES_PASSWORD=my$up3r$ecure

Original was my$up3r$ecure. But $up3r is interpreted as a shell variable if unescaped during cat or echo. However, SOPS writes raw text. Check hexdump:

root@prox-host:/opt/app# xxd .env | grep -A2 POSTGRES
00000010: 504f 5354 4752 4553 5f50 4153 5357 4f52  POSTGRES_PASSWOR
00000020: 443d 6d79 2475 7033 7224 6563 7572 650a  D=my$up3r$ecure.

The $ signs are literal (24 hex). So the .env file is correct. Why did the DB reject it?

Check running container environment:

root@prox-host:/opt/app# docker exec -it db env | grep POSTGRES
POSTGRES_PASSWORD=my

It got truncated at the first $!
Root cause: The Docker Compose environment: directive in the compose file uses shell parsing. When it reads the .env file, unescaped $ variables are expanded (or truncated).
Fix required: Escape $ as $$ in the .env file.
Discovery while testing: The original .env on the workstation had $ unescaped because developers copy-paste from password managers directly.

Conclusion from this log: Encryption works, but character escaping ($\!) introduces silent runtime failures that don’t appear in the encrypted file. Key permissions add an extra layer of human error.


Failure Log 3: Docker Swarm Secrets – Asynchronous Creation & Orphaned Services

Date/Time: 2026-07-07 09:15 UTC
Attempt: Use built-in Docker Swarm secrets with docker stack deploy to remove local .env entirely.

Initialization & Secret Creation

root@prox-host:/opt/app# docker swarm init
Swarm initialized: current node (abc) is now a manager.

root@prox-host:/opt/app# echo "db-prod-pass" | docker secret create db_pass -
root@prox-host:/opt/app# echo "api-key-xyz" | docker secret create api_key -

Modified docker-compose.yml to:

services:
  app:
    image: myapp:1.2
    secrets:
      - db_pass
      - api_key
    environment:
      - DB_PASSWORD_FILE=/run/secrets/db_pass
secrets:
  db_pass:
    external: true
  api_key:
    external: true

Deployed:

root@prox-host:/opt/app# docker stack deploy -c docker-compose.yml myapp
Creating service myapp_app

First friction: Git workflow breaks.
Developer pushed a new compose file that adds cache_token secret.
Server runs git pull and tries to deploy:

root@prox-host:/opt/app# git pull
root@prox-host:/opt/app# docker stack deploy -c docker-compose.yml myapp
failed to create service myapp_app: secret "cache_token" not found

Immediate workaround attempted:

root@prox-host:/opt/app# echo "cache-token-value" | docker secret create cache_token -
# Wait, the container expects the secret file to exist at runtime. 
# But the service creation is blocked until the secret exists.
# We can create it before the stack deploy.
root@prox-host:/opt/app# docker secret create cache_token -
root@prox-host:/opt/app# docker stack deploy -c docker-compose.yml myapp
# Works now.

Secondary issue – orphaned secrets:
A week later, api_key is rotated. Operator creates api_key_v2, updates compose, deploys. The old api_key secret is still in Swarm’s store (not referenced by any service).

root@prox-host:/opt/app# docker secret ls
ID                          NAME
abc123                      db_pass
def456                      api_key
ghi789                      api_key_v2
jkl012                      cache_token

Attempt to remove old one:

root@prox-host:/opt/app# docker secret rm api_key
Error response from daemon: rpc error: code = Unknown desc = secret 'api_key' is in use by the following services: myapp_app

It’s still in use until you force a service update with the new secret and remove the old reference. The compose update references api_key_v2, but the service definition in Swarm still has api_key attached from the previous deployment.
Fix sequence (verified):

root@prox-host:/opt/app# docker service update --secret-rm api_key myapp_app
root@prox-host:/opt/app# docker service update --secret-add api_key_v2 myapp_app
# After the service updates, wait for rollout.
root@prox-host:/opt/app# docker secret rm api_key
# Works now.

Conclusion from this log: Swarm secrets introduce a chicken-and-egg deployment problem (secret must exist before stack deploy) and require manual cleanup steps that are not captured in Git. The git pull + docker stack deploy workflow is not atomic.


Operational Fixes (Verified & Applied)

The following sections document the exact steps that resolved the failures above, with edge cases handled.


Fix A: CI/CD Injection via GitHub Actions (Live Implementation)

Target: Single Proxmox host. No extra secret store.

Implementation file.github/workflows/deploy.yml

name: Deploy to Prod
on:
  push:
    branches: [ main ]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Render .env with escaping
        run: |
          # Escape $ to $$ for Docker Compose compatibility
          echo "POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}" | sed 's/\$/$$/g' > .env
          echo "API_KEY=${{ secrets.API_KEY }}" | sed 's/\$/$$/g' >> .env
          echo "REDIS_PASSWORD=${{ secrets.REDIS_PASSWORD }}" | sed 's/\$/$$/g' >> .env

      - name: Deploy over SSH
        uses: appleboy/[email protected]
        with:
          host: ${{ secrets.HOST }}
          username: ${{ secrets.USER }}
          key: ${{ secrets.SSH_KEY }}
          source: ".env,docker-compose.yml"
          target: "/opt/app/"

      - name: Restart containers
        uses: appleboy/[email protected]
        with:
          host: ${{ secrets.HOST }}
          username: ${{ secrets.USER }}
          key: ${{ secrets.SSH_KEY }}
          script: |
            cd /opt/app
            docker compose pull
            docker compose up -d --force-recreate

Verification after push:

root@prox-host:/opt/app# docker compose exec app env | grep POSTGRES
POSTGRES_PASSWORD=my$$up3r$$ecure  # Escaped correctly, container reads literal.

Why this works: The sed 's/\$/$$/g' handles the escaping that broke Failure Log 2. Secrets never touch Git. The git pull step is replaced by scp from CI, which guarantees the compose file and .env are always in sync.


Fix B: External Manager (Infisical) – Service Token Injection

Target: Multi-environment (dev/staging/prod) with audit requirements.

Setup commands executed (copy-paste ready):

# Install Infisical CLI on host
curl -1sLf 'https://dl.cloudsmith.io/public/infisical/infisical-cli/setup.deb.sh' | sudo -E bash
sudo apt-get install infisical -y

# Login via service token (non-interactive)
export INFISICAL_TOKEN="st.xxxxx"
infisical login --service-token $INFISICAL_TOKEN

# Pull secrets to .env during container start
infisical export --env=prod --format=dotenv > .env

Docker Compose integration (docker-compose.yml):

services:
  app:
    image: myapp:latest
    environment:
      - INFISICAL_TOKEN=${INFISICAL_TOKEN}  # passed from host systemd service
    command: >
      sh -c "infisical export --env=prod --format=dotenv > .env && 
             exec node index.js"

Host systemd override (/etc/systemd/system/docker.service.d/override.conf):

[Service]
Environment="INFISICAL_TOKEN=st.xxxxx"

Validation:

root@prox-host:/opt/app# docker compose exec app cat /app/.env | head -1
POSTGRES_PASSWORD=prod_secure_hash

Edge case handled: The $ escaping is now handled by Infisical’s export formatter, which wraps values in single quotes automatically.


Root Cause Summary & Decision Matrix

FailureRoot CauseWhy Fix Works
Manual .envNo sync between Git changes and host files.CI always pushes a fresh .env with every commit.
SOPS key permissionsUser mismatch (ubuntu vs root) and unescaped $.CI runs escaping (sed), and no key is stored on host.
Swarm secretsSecret must pre-exist stack deploy; manual cleanup for rotation.Secret manager or CI injects values directly, bypassing Swarm’s persistent store.

Decision Matrix:

  • Single host, 1 developer: Use Fix A (CI Injection). Total setup time: 15 min.
  • Multiple hosts, team > 3: Use Fix B (Infisical/Doppler). Total setup time: 45 min.
  • Compliance (SOC2): Fix B with audit logs enabled.

Mandatory pre-commit hook installed (prevents recurrence of Failure Log 2’s plaintext commit):

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.18.0
    hooks:
      - id: gitleaks

Test run:

root@workstation:/app# pre-commit run --all-files
gitleaks................................................................Failed
- hook id: gitleaks
- exit code: 1
  Finding:     POSTGRES_PASSWORD=my$up3r$ecure
  Secret:      my$up3r$ecure

The commit is blocked.


References

  1. Docker Official — Compose secrets reference
    https://docs.docker.com/compose/how-tos/use-secrets/
  2. SOPS — Age encryption usage
    https://github.com/getsops/sops
  3. Infisical — Docker Compose integration
    https://infisical.com/docs/integrations/docker-compose
  4. GitHub Actions — Environment variables escaping for Docker
    https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions
  5. GitLeaks — Pre-commit hook setup
    https://github.com/gitleaks/gitleaks
  6. Stackademic — Secrets and escaping pitfalls
    https://blog.stackademic.com/secrets-management-in-docker-compose-env-sops-bitwarden-and-the-good-enough-threat-model-2bbc6d8e1064
  7. Semaphore — Secret rotation best practices
    https://semaphore.io/blog/docker-secrets-management