Docker Secrets Done Right: From Portainer Copy-Paste to Fully Automated Deployments
If you manage your Docker stacks through Portainer, you know the drill: the Compose files live neatly in Git, but the passwords? You type those into the UI by hand after every deploy. It works, until you need to redeploy a stack for the third time and find yourself digging through old notes for that SMTP password.
Here’s how I went from that semi-automated state to a setup where Portainer pulls a stack from Git and everything just runs, no manual credential wrangling required.
How It Started: Passwords by Hand
My portainer_iac repo has one YAML file per service. In Portainer, I deploy every stack via “Create from Repository”, meaning Portainer pulls the YAML straight from Git and spins up the stack. Sounds like Infrastructure as Code. It was only half of it, though.
The problem: the YAML files were in the repo, but the passwords obviously weren’t. So after every deploy, I’d manually add the environment variables in the Portainer UI. Deploy the stack, click into the Environment section, paste ADMIN_TOKEN, paste SMTP_PASSWORD, save, restart the stack. For every service. Every single time.
It was semi-automated at best. The infrastructure came from Git, the secrets came from my head (or, if I’m being honest, from a text file on my desktop). Whenever I had to redeploy a stack, I’d first go hunting for passwords. “Rebuild at the push of a button” this was not.
The Breakthrough: Docker Secrets Instead of Environment Variables
Then I discovered Docker Secrets. Docker Compose can mount files as read-only under /run/secrets/ inside containers. Instead of typing passwords as environment variables through the Portainer UI, the YAML file simply references a file on the host. Portainer pulls the YAML from Git, the secrets are already sitting on the server, the stack starts without any manual intervention. Fully automated.
That was the moment “Infrastructure as Code with manual password patching” became actual IaC. But it raised the next question: how do the secret files get onto the server without me uploading them one by one over SSH?
The Problem: Secrets and Git Don’t Mix
Every stack needs some kind of credentials: Vaultwarden wants an admin token and SMTP credentials, Grafana needs login details, the Cloudflare Tunnel needs its token, CrowdSec a bouncer key. All in all, we’re talking about 20+ individual secrets spread across six stacks.
What I wanted:
- A single file with all credentials
- Encrypted in Git so I don’t lose them
- Decrypted on the server as individual files that Docker Secrets can read
- No HashiCorp Vault or AWS Secrets Manager needed when you’re only running a handful of containers
The Solution: SOPS + age in a Docker Container
SOPS (Secrets OPerationS) is a Mozilla tool for encrypting files. age is a modern encryption tool that serves as the backend for SOPS. Together they replace the older PGP setup that nobody misses.
My approach: a small Alpine container that ships SOPS and age and can do exactly three things: encrypt, decrypt, and unpack secrets.
The Architecture
One File to Rule Them All
All credentials live in a single credentials.yml. The structure is straightforward: one top-level key per service, individual secrets nested below.
# credentials.yml (excerpt)
vaultwarden:
admin_token: "$argon2id$v=19$m=65540..."
smtp:
host: mail-eu.smtp2go.com
from: info@example.com
username: myuser
password: supersecret123
tunnel:
token: eyJhIjoiNGNjZDJm...
grafana:
admin:
user: admin
email: admin@example.com
password: evenmoresecret456
crowdsec:
bouncer_key: 6ae1c245e3420ffd...
This file gets encrypted and committed as credentials.enc.yml. The plaintext version stays out of Git via .gitignore.
The Container
The tooling runs in a minimal Alpine container. No Python ecosystem needed on the host, no SOPS installation, no age binaries. Everything self-contained.
FROM alpine:3.23
RUN apk add --no-cache python3 sops age shadow py3-yaml
ARG USER_ID=1000
ARG GROUP_ID=1000
# ... User mapping for host permissions
The container receives the host user’s UID/GID at build time so generated files have the correct ownership. No chmod 777 workarounds.
The Wrapper
A bash script sops-run.sh builds the container and mounts two things: the current directory and the age key from the host.
docker run --rm -it \
-v "$(pwd)":/app \
-v "$LOCAL_KEY_DIR":"/home/sopsuser/.config/sops/age" \
$IMAGE_NAME "$@"
The age key lives at ~/.config/sops/age/keys.txt and never leaves the machine. It’s not committed, not copied, not uploaded anywhere.
The Workflow
One-Time: Generate a Key
./sops-run.sh generate-key
This creates an age keypair. SOPS needs the public key for encryption, the private key for decryption. Back this key up. If it’s gone, your encrypted credentials are gone with it.
Encrypt
./sops-run.sh encrypt credentials.yml
SOPS encrypts the entire file as binary. That means credentials.enc.yml is completely unreadable. No YAML keys visible, no structure recognizable. Open the file and all you see is gibberish.
Why binary instead of the usual SOPS approach where only values get encrypted? Because I don’t want anyone looking at the Git repo to see which services I run or what my secret structure looks like. Key names are information too.
Decrypt and Unpack
./sops-run.sh unpack credentials.enc.yml
This is the interesting part. unpack decrypts the file and generates individual files from the YAML structure:
/opt/portainer_iac/secrets/secrets/
├── vaultwarden/
│ ├── admin_token
│ ├── smtp_host
│ ├── smtp_from
│ ├── smtp_username
│ └── smtp_password
├── tunnel/
│ └── token
├── grafana/
│ ├── admin_user
│ ├── admin_email
│ └── admin_password
├── crowdsec/
│ └── bouncer_key
├── hooks/
│ ├── cloudflare_auth_token
│ ├── cloudflare_account_id
│ └── cloudflare_policy_id
└── monitoring/
├── pve_user
├── pve_token_name
└── pve_token_value
Each file contains exactly one value. No line breaks, no YAML, just the raw secret string. Exactly what Docker Secrets expects.
The naming convention: the top-level key becomes the directory, nested keys are joined with underscores. vaultwarden.smtp.host becomes vaultwarden/smtp_host.
How the Stacks Consume Secrets
Docker Compose has a secrets section. It mounts files as read-only under /run/secrets/ inside the container. This is more secure than environment variables because secrets don’t show up in docker inspect, process listings, or core dumps.
Pattern 1: Token File Directly
The simplest case. The Cloudflare Tunnel accepts a --token-file argument:
# tunnel.yml
services:
tunnel:
image: cloudflare/cloudflared:latest
command: tunnel --no-autoupdate run --token-file /run/secrets/cf_tunnel_token
secrets:
- cf_tunnel_token
secrets:
cf_tunnel_token:
file: /opt/portainer_iac/secrets/secrets/tunnel/token
Clean. The container reads the token from the file, done.
Pattern 2: Entrypoint with cat
Many containers expect secrets as environment variables. Vaultwarden, for example, wants ADMIN_TOKEN as an ENV. The solution: a custom entrypoint that reads secrets from /run/secrets/ and exports them as ENV before starting the actual process.
# vaultwarden.yml (excerpt)
services:
vaultwarden:
command:
- /bin/sh
- -ec
- |
set -eu
export ADMIN_TOKEN="$(cat /run/secrets/admin_token)"
export SMTP_HOST="$(cat /run/secrets/smtp_host)"
export SMTP_PASSWORD="$(cat /run/secrets/smtp_password)"
exec /start.sh
secrets:
- source: admin_token
- source: smtp_host
- source: smtp_password
The exec at the end matters. It replaces the shell process with the actual application process. Without exec, the shell would run as PID 1 and wouldn’t forward signals like SIGTERM correctly.
Pattern 3: Permissions for Non-Root Containers
Some containers don’t run as root. The Gitea Runner, for instance, needs the secret as user 1000. Docker Compose can set permissions directly:
# gitea.yml (excerpt)
secrets:
- source: gitea_runner_token
uid: "1000"
gid: "1000"
mode: 0400
0400 means: only the owner can read. No writing, no executing, no access for others. Exactly right for a token.
What the .gitignore Controls
The .gitignore in the secrets/ directory is deliberately restrictive. Everything is ignored by default. Only explicitly allowed files end up in Git:
# Ignore everything
*
# Track the tooling
!.gitignore
!README.md
!Dockerfile
!sops-run.sh
!sops_manager.py
# Allow encrypted files
!*.enc.yml
!*.enc.yaml
The plaintext credentials, the unpacked secret files, all of it stays local. Only the encrypted credentials.enc.yml and the tooling around it make it into Git.
Why Not Just Use .env Files?
Fair question. .env files work, but they have a few downsides.
Docker Secrets are mounted as files under /run/secrets/, not as environment variables. That means they don’t show up in docker inspect, not in /proc/<pid>/environ, not in crash dumps. If a container gets compromised and the attacker reads the environment variables, secrets in .env are immediately visible. With Docker Secrets, they’d have to actively read the file.
Also: .env files per service means many individual files you need to keep in sync. A single credentials.yml that automatically unpacks into the right structure is less error-prone.
The Full Flow
When I need a new secret or want to change an existing one:
- Edit
credentials.ymlon my machine - Run
./sops-run.sh encrypt credentials.yml - Commit and push
credentials.enc.yml - On the server:
git pull && ./sops-run.sh unpack credentials.enc.yml - Redeploy the affected stack in Portainer
Five steps, no manual file edits on the server, no copy-pasting passwords over SSH. And if the server burns down tomorrow, everything is in Git. Set up a new server, grab the age key from backup, run unpack, deploy the stacks. Done.
What I’d Do Differently
The age key is the single point of failure. If it’s gone, all encrypted credentials are lost. Currently there’s a copy on an encrypted USB stick. Not elegant, but it works. A better solution would be age with multiple recipients, so a second key can serve as backup.
There’s also no rotation yet. The secrets rarely change, but an automatic reminder would make sense. Maybe a cron job that checks whether the last_updated field in credentials.yml is older than 90 days.
But for a self-hosted setup with a manageable number of containers? This does exactly what it needs to. Secrets are encrypted in Git, decrypted on the server, and no password sits in plaintext in a YAML file on GitHub.