Tutorial

Docker Secrets in production: protecting secrets on a VPS

Security & Monitoring7 min read5 steps

A docker-compose.yml with API keys in plaintext is a leak waiting for a scanner. This article shows how to eliminate that risk on a root VPS: native Docker Secrets (without Swarm since Compose v2.24), .env.vault and SOPS — each method with exact commands and real limitations.

Contents· The real risk: your secrets travel inside your images1/9
  1. 01The real risk: your secrets travel inside your images
  2. 025 configuration mistakes that expose your secrets
  3. 03Prerequisites
  4. 04Method 1 — Docker Secrets in non-Swarm Compose (step by step)
  5. 05Docker Secrets vs .env.vault vs SOPS — which method for which context
  6. 06Supplementary configuration: rotation and encryption at rest
  7. 07What Docker Secrets does not protect
  8. 08Troubleshooting — common errors
  9. 09Your secrets under control — and what's next

The real risk: your secrets travel inside your images

In 2024, GitGuardian detected over 12.8 million exposed secrets in public GitHub repositories — a 28% increase year-over-year according to the State of Secrets Sprawl 2025. Docker Hub images are one of the most underestimated vectors.

When you write ARG API_KEY in a Dockerfile or pass -e DB_PASSWORD=hunter2 at container startup, that secret doesn't stay confined to runtime. It can end up:

- in the image layers (inspectable with docker history --no-trunc);
- in the image metadata exported to Docker Hub (docker inspect);
- in your .env file accidentally committed during a hurried git add ..

Automated scanners (Trivy, Grype, GitGuardian) continuously crawl Docker Hub. A public repository with a plaintext secret is indexed within minutes. The exposure window is nearly zero.

5 configuration mistakes that expose your secrets

  • Plaintext env vars in docker-compose.ymlenvironment: DB_PASSWORD: hunter2 is readable by anyone who accesses the file or the image.
  • Committed .env file — miss .gitignore once, and the secret is in git history forever (even after git rm, accessible via git log).
  • ARG passed at build then copied into the imageARGs are baked into layer metadata and readable via docker history.
  • Secrets in logs — an application that logs its environment variables at startup (Spring Boot, Rails in debug mode, some Node servers) prints credentials in docker logs.
  • Bind-mount volumes on /root or the project directory — a .env file mounted from the host remains accessible to any process in the container with root privileges.

Prerequisites

To follow this article, you need:

- A Linux VPS (Debian 12 or Ubuntu 22.04+) with root access.
- Docker Engine ≥ 24 and Docker Compose ≥ 2.24 (check with docker compose version).
- For SOPS: age installed (age package on Debian/Ubuntu, or binary from github.com/FiloSottile/age).
- For .env.vault: Node.js ≥ 18 and the dotenvx CLI (npm install -g @dotenvx/dotenvx).

No Swarm cluster is required for any of the methods presented here.

Method 1 — Docker Secrets in non-Swarm Compose (step by step)

  1. Check the Compose version

    Docker Secrets work without Swarm since Docker Compose v2.24.0 (released January 11, 2024). Verify:

    docker compose version
    # Docker Compose version v2.27.1

    If you're below v2.24, update Compose before continuing (apt-get install docker-compose-plugin on Debian/Ubuntu).

  2. Create the secret files

    Docker Compose secrets in non-Swarm mode are files on the host, mounted as tmpfs inside the container. Create them outside the project directory:

    mkdir -p /etc/myapp/secrets
    echo -n 'strong-db-password' > /etc/myapp/secrets/db_password
    echo -n 'stripe-api-key-xxxxx' > /etc/myapp/secrets/stripe_key
    chmod 600 /etc/myapp/secrets/*
    chown root:root /etc/myapp/secrets/*

    The -n flag of echo avoids a trailing newline — some applications read the entire file including the newline, which invalidates the key.

  3. Declare secrets in docker-compose.yml

    services:
      app:
        image: myapp:latest
        secrets:
          - db_password
          - stripe_key
        environment:
          # indicate the PATH, not the value
          DB_PASSWORD_FILE: /run/secrets/db_password
          STRIPE_KEY_FILE: /run/secrets/stripe_key
    
    secrets:
      db_password:
        file: /etc/myapp/secrets/db_password
      stripe_key:
        file: /etc/myapp/secrets/stripe_key

    Note the use of the *_FILE convention: your application must read the DB_PASSWORD_FILE variable, open the indicated file and read its content. Official PostgreSQL, MySQL, Redis images and most Bitnami images natively support this convention — check your image's documentation.

  4. Verify the mount inside the container

    After docker compose up -d, inspect the mount:

    docker compose exec app ls -la /run/secrets/
    # -r-------- 1 root root 20 Sep 20 08:12 db_password
    # -r-------- 1 root root 28 Sep 20 08:12 stripe_key
    
    docker inspect myapp_app_1 | grep -A5 Mounts
    # "Type": "tmpfs",
    # "Destination": "/run/secrets",

    The mount type is tmpfs: the content lives in RAM, never written to the container's disk. It disappears when the container stops.

  5. What Docker Secrets does NOT do — understand before continuing

    Docker Secrets is not a hermetic vault. What it does not protect against:

    - The source file (/etc/myapp/secrets/db_password) remains on the host disk in plaintext — root on the VM always has access.
    - Any process in the container (PID 1 or a subprocess launched by the app) can read /run/secrets/*.
    - An env variable derived from the secret (DB_PASSWORD=$(cat /run/secrets/db_password) in an entrypoint) puts the secret back into the env, visible via docker inspect.

    Docker Secrets protects against leaks in image layers and in docker-compose.yml. It does not protect against a compromised process inside the container.

Docker Secrets vs .env.vault vs SOPS — which method for which context

Scroll the table

CriterionDocker Secrets (Compose).env.vault (dotenvx)SOPS + age
Swarm requiredNo (since Compose v2.24)NoNo
Secret stored in plaintext on hostYes (source file)No (encrypted in repo)No (encrypted in repo)
Remote KMS requiredNoNo (local symmetric key possible)No (age works offline)
Rotation without redeploymentNo (restart needed)No (rebuild .env)No (rebuild .env)
CI/CD: injection into the pipelineComplex (files to provision)Simple (`DOTENV_PRIVATE_KEY` variable)Medium (age key as CI secret)
Learning curveLow (native Compose)Low (dotenvx CLI)Medium (YAML syntax + age/GPG keys)

Supplementary configuration: rotation and encryption at rest

Docker Compose secret rotation. Non-Swarm Docker Compose does not support hot rotation (unlike Swarm which can update a secret without stopping the service). To change a secret:

# 1. Write the new value
echo -n 'new-password' > /etc/myapp/secrets/db_password
# 2. Restart the affected service
docker compose restart app

Encrypting source files with age. If you want to encrypt files on the host (against snapshot theft or a compromised backup), SOPS + age lets you store encrypted files and decrypt them at startup:

# Generate an age key
age-keygen -o /root/.config/sops/age/keys.txt
# Encrypt the secret file
sops --encrypt --age $(age-keygen -y /root/.config/sops/age/keys.txt) \
  /etc/myapp/secrets/db_password > /etc/myapp/secrets/db_password.enc
# In your startup script, decrypt before docker compose up
sops --decrypt /etc/myapp/secrets/db_password.enc > /etc/myapp/secrets/db_password

Access auditing. Enable Docker logs with journald (--log-driver=journald in /etc/docker/daemon.json) to keep a record of who launched which containers and when.

What Docker Secrets does not protect

Docker Secrets mounts the secret as tmpfs in /run/secrets/: it's a safety net against leaks in images and Compose files, not against a compromised process inside the container. Any process running in the container — including a shell obtained via RCE — can read /run/secrets/*. And root on the host always has access to the source file.

If your threat model includes a compromised container, the right answer is an external secret manager (HashiCorp Vault, AWS Secrets Manager, self-hosted Infisical) that delivers secrets via API with authentication, without ever writing them to the container disk.

Troubleshooting — common errors

unknown shorthand flag: 's' in -s during docker compose up
You are using the old docker-compose command (v1, Python). Switch to docker compose (v2, Go plugin) with apt-get install docker-compose-plugin.

secrets are only supported when deploying to a swarm
Your Docker Compose version is below v2.24. Check with docker compose version and update.

Container starts but /run/secrets/db_password is empty
Verify that the source file exists and is not empty: cat /etc/myapp/secrets/db_password | wc -c. An empty file creates an empty tmpfs mount, without error.

permission denied when reading /run/secrets/
Files are mounted with the source file's permissions. If your process runs as a non-root user in the container, adjust the host file permissions: chmod 640 /etc/myapp/secrets/db_password and check the process GID.

docker inspect still shows the env var in plaintext
You declared the secret but also passed the value in environment:. Remove the direct-value entry and only use the *_FILE convention in environment:.

Your secrets under control — and what's next

Docker Secrets in non-Swarm Compose mode eliminates the main cause of leaks: plaintext credentials in configuration files and image layers. The method fits in five steps, works without external infrastructure and integrates into any existing workflow.

To go further:

- Production checklist: the 10 essential points for a production-ready docker-compose.yml.
- First steps with Docker on VPS: getting started with Docker on VPS if you are building your first environment.
- OS hardening: Linux hardening checklist to secure the host layer on which Docker runs.

Control your stack, control your secrets

A root VPS with full Docker access: you decide on secrets management, attack surface, and every layer of your infrastructure.

Need help?

Browse our help center and FAQ, or reach our team — callback, WhatsApp or email. Support in French, English and Arabic.

Message us on WhatsAppopens in a new tab