Docker Compose: secrets per le password, fuori dalle variabili d’ambiente

Il docker-compose.yml tipico di un progetto WordPress o Laravel ha un blocco così:
environment:
MYSQL_ROOT_PASSWORD: una-password-vera
MYSQL_PASSWORD: un-altra-password-vera
Funziona, ed è proprio il problema: funziona così bene che nessuno lo cambia più. La documentazione di Docker lo dice senza giri di parole: «If you’re injecting passwords and API keys as environment variables, you risk unintentional information exposure». E subito dopo il motivo concreto: le variabili d’ambiente «can also be printed in logs when debugging errors without your knowledge». Basta uno stack trace che stampa l’ambiente, o un env lanciato per capire un errore, e la password del database è in un file di log che nessuno ha pensato di proteggere.
Compose ha un’alternativa pensata apposta: i secrets.
Come funzionano
Un secret, nella definizione di Docker, è «any piece of data, such as a password, certificate, or API key». Invece di arrivare al container come variabile, arriva come file: «Secrets are mounted as a file in /run/secrets/<secret_name> inside the container».
Si dichiarano in due punti. In alto, a livello di file, si dice da dove viene il valore. Nel servizio si dice chi lo può leggere:
services:
web:
image: example/webapp
secrets:
- db-password
secrets:
db-password:
file: ./db_password.txt
Il secondo punto non è una formalità. La reference di Compose è esplicita: dichiarare un secret in alto non dà accesso a nessuno, ogni servizio deve chiederlo nel proprio blocco secrets. È il vantaggio vero rispetto a environment, che di solito si copia uguale in tre servizi: qui il container del web server non vede la password di root del database, se non gliela date voi.
La convenzione _FILE
Resta un dettaglio: l’applicazione deve sapere che la password sta in un file. Per molte immagini ufficiali non bisogna scrivere niente, perché seguono una convenzione: aggiungendo _FILE al nome della variabile, il valore viene letto dal file indicato. Docker la descrive come «a convention used by some images», e l’esempio della documentazione mette insieme proprio MySQL e WordPress:
services:
db:
image: mysql:latest
volumes:
- db_data:/var/lib/mysql
environment:
MYSQL_ROOT_PASSWORD_FILE: /run/secrets/db_root_password
MYSQL_DATABASE: wordpress
MYSQL_USER: wordpress
MYSQL_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_root_password
- db_password
wordpress:
depends_on:
- db
image: wordpress:latest
ports:
- "8000:80"
environment:
WORDPRESS_DB_HOST: db:3306
WORDPRESS_DB_USER: wordpress
WORDPRESS_DB_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
secrets:
db_password:
file: db_password.txt
db_root_password:
file: db_root_password.txt
volumes:
db_data:
Notate la distribuzione: db riceve entrambe le password, wordpress solo quella che gli serve. Il nome del database e l’utente restano variabili normali, perché non sono segreti.
Una precisazione importante: non tutte le variabili supportano _FILE, nemmeno nelle immagini che la adottano. La pagina dell’immagine ufficiale MySQL su Docker Hub elenca quali: MYSQL_ROOT_PASSWORD, MYSQL_ROOT_HOST, MYSQL_DATABASE, MYSQL_USER e MYSQL_PASSWORD. Per qualunque altra immagine, il controllo da fare è lo stesso: leggere la sua pagina, prima di dare per scontato che QUALCOSA_FILE venga letto. Se non viene letto, il container parte lo stesso, con un valore vuoto o di default, e l’errore lo scoprite dopo.
Nel vostro codice
Per un’applicazione vostra, PHP o altro, _FILE non esiste finché non lo scrivete. La lettura è una riga:
$password = trim(file_get_contents('/run/secrets/db_password'));
Il trim() non è decorativo: un file scritto con un editor finisce quasi sempre con un a capo, e quell’a capo diventerebbe parte della password.
Permessi: la trappola dei campi ignorati
La sintassi lunga permette di scegliere proprietario e permessi del file:
secrets:
- source: api-token
target: token.txt
uid: "103"
gid: "103"
mode: 0440
Il default è «world-readable permissions (mode 0444)»: dentro il container chiunque può leggere il file. Ma prima di affidarvi a uid, gid e mode, leggete la nota della reference di Compose: sono supportati solo «when the source of the secret is environment». Con un secret da file, «Compose uses a bind-mount under the hood which doesn’t allow uid remapping, and these attributes are silently ignored».
Silently: nessun errore, nessun avviso. Scrivete mode: 0400, il container parte, e il file resta leggibile da tutti. Se i permessi contano, verificateli dentro il container con un ls -l /run/secrets/ invece di fidarvi del YAML.
Cosa evitare
- Committare i file dei secrets.
db_password.txtaccanto aldocker-compose.ymlè comodo, e finisce nel repository alla primagit add .. Va nel.gitignoreil giorno in cui lo create, non dopo. - Pensare che sia cifratura. Un secret di Compose è un file montato nel container. Toglie la password dall’ambiente e dai log, e permette di decidere quale servizio la vede. Non la protegge da chi ha accesso alla macchina, dove il file sorgente sta in chiaro.
- Usare
environment:come sorgente condocker stack deploy. La reference lo dice chiaramente: i secrets da variabile d’ambiente «are not supported when deploying withdocker stack deploy». Su Swarm servonofileoexternal. - Container Windows. «Secrets are supported on Linux containers only.»
- Lasciare la vecchia variabile accanto alla nuova. Se aggiungete
MYSQL_PASSWORD_FILEe dimenticateMYSQL_PASSWORD, la password in chiaro è ancora lì, e il lavoro è a metà.
In pratica
Su un progetto esistente il passaggio richiede poco: un file per ogni password, il blocco secrets in fondo al Compose, _FILE al posto della variabile dove l’immagine lo supporta, una riga di lettura nel vostro codice dove non lo supporta. Poi un controllo: docker compose config per vedere la configurazione risolta, e un env dentro il container per verificare che la password non compaia più.
Se avete uno stack Docker da mettere in ordine, o da portare in produzione senza password sparse nei file di configurazione, scriveteci.
Fonti: Docker Docs, Secrets in Compose; Compose file reference, secrets top-level e services; immagine ufficiale MySQL su Docker Hub.
