phattarachai / laravel-env-secrets
Encrypt .env.<env> with Laravel's env:encrypt, mint the decryption key with a CSPRNG, and install it on a deploy box over ssh — all from one artisan command, without the key ever touching stdout, shell history, or a process argument.
Package info
github.com/phattarachai/laravel-env-secrets
pkg:composer/phattarachai/laravel-env-secrets
Requires
- php: ^8.3
- illuminate/console: ^12.0|^13.0
- illuminate/contracts: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- larastan/larastan: ^3.10
- laravel/pint: ^1.24
- orchestra/testbench: ^10.8|^11.0
- pestphp/pest: ^3.0|^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-11 10:05:22 UTC
README
A small suite of artisan commands to run the encrypted-.env deploy pattern end to end: mint an
encryption key, encrypt .env.<env> with Laravel's own env:encrypt, install the key on your deploy
box over ssh — and then edit, re-encrypt, health-check, and inspect those files later without the key
ever drifting. The key is generated with a CSPRNG, kept only on the box, and never touches stdout,
your shell history, or a process argument — it reaches the server over ssh stdin and your clipboard via
pbcopy, and every later command fetches it back over ssh into memory only.
| Command | Purpose |
|---|---|
secrets:provision <env> |
First-time: mint a key, encrypt .env.<env>, install the key on the box. |
secrets:edit <env> |
Fetch the box key and decrypt .env.<env> for editing. |
secrets:reencrypt <env> |
Re-encrypt an edited .env.<env> with the same box key + verify the round-trip. |
secrets:status <env> |
Is it provisioned and does the box key decrypt it? (--remote checks the live .env.) |
secrets:show <env> [name] |
Inspect values without writing plaintext — masked names, or one named value. |
secrets:merge <env> |
Append secrets a local .env is missing — never overwrites, never carries protected keys. |
Why
Committing a plaintext .env.production puts every credential your app has into git history forever.
The alternative most teams reach for — a secrets manager, Vault, SSM — is a whole moving part to run.
Laravel ships a middle ground: env:encrypt / env:decrypt.
You commit an encrypted .env.<env>.encrypted, keep the single decryption key off git, and decrypt
at deploy time. The secrets live in the repo (safe — they are ciphertext), and the only thing you have
to distribute out-of-band is one short key per environment.
The fiddly part is the key lifecycle: generating it safely, encrypting with that key (not a fresh one
env:encrypt prints to your terminal and scrollback), and getting it onto the box without it leaking
through a command argument or ps. secrets:provision does exactly that and nothing else.
How the pattern works
- You hold the plaintext
.env.uat/.env.productionlocally (git-ignored — see below). These are the editable source of truth. secrets:provision <env>mints a 32-hex-char key, runsenv:encrypt --key=… --env=<env>, and produces.env.<env>.encrypted. You commit that file.- The same key is installed on the deploy box at
<dir>/<slug>.<env>.key(mode600, owned by the ssh user) and copied to your clipboard to paste into your password manager as a backup. It uses sudo only if the ssh user cannot own that directory (see Installing without sudo). If the install fails, the key is still on your clipboard, with the command to install it by hand. - Your deploy script exports the key from that file and runs
env:decryptto regenerate.envon the box before the app boots.
The key is the only secret that ever leaves your machine, and it only ever moves over ssh stdin.
Install
composer require --dev phattarachai/laravel-env-secrets
It is a dev-time provisioning tool — you run it from a developer machine, so --dev is the right place
for it. The package auto-registers its service provider.
Publish the config to set your defaults:
php artisan vendor:publish --tag=env-secrets-config
// config/env-secrets.php return [ 'host' => env('ENV_SECRETS_HOST'), // required: ssh host alias of the deploy box 'dir' => env('ENV_SECRETS_DIR', '/etc/secrets'), // where key files live on the box 'slug' => env('ENV_SECRETS_SLUG', null), // key filename stem; null → app name 'group' => env('ENV_SECRETS_GROUP', null), // unix group that may read the key; null → owner only 'sudo' => env('ENV_SECRETS_SUDO', 'auto'), // auto | always | never — how the install may use sudo 'app_path' => env('ENV_SECRETS_APP_PATH', null), // deployed app dir on the box, for --remote 'environments' => [], // per-env overrides — see below ];
host has no default: set ENV_SECRETS_HOST (or host in the published config), or every command stops
with No deploy box for <env> before it touches anything. Only secrets:provision --local runs without one.
Every value is overridable per run with --host, --dir, --slug, --group, --sudo, --path. When an option is omitted
the command uses the config value; when the config slug is null it derives one from config('app.name')
(falling back to the application directory name).
Envs that live on different boxes
When your envs do not all share one box — say production and uat on prod-box, staging on staging-box —
name the odd ones out in environments. Any of host, dir, slug, group, sudo and app_path can be set
per env; whatever an env leaves out falls back to the top-level value:
'host' => 'prod-box', 'dir' => '/etc/myapp', 'environments' => [ 'staging' => ['host' => 'staging-box'], ],
Now secrets:edit staging goes to staging-box and secrets:edit production to prod-box, with no flag to
remember. Each setting resolves most specific first:
- the CLI option (
--host,--dir,--slug,--group,--sudo,--path), environments.<env>.<setting>,- the top-level
<setting>, - the built-in default.
Prefer the map to a --host you have to remember: a forgotten flag sends the command to the wrong box.
To make that obvious, every command opens by printing the box and file it resolved (on stderr, so a
secrets:show <env> <NAME> value on stdout stays clean for scripts):
Box staging-box:/etc/myapp/myapp.staging.key
and secrets:status prints the host and key path — and where each came from — before it reads anything:
Host staging-box (environments.staging)
Key /etc/myapp/myapp.staging.key (env-secrets.dir, env-secrets.slug)
Readable OK
.encrypted .env.staging.encrypted OK
Decrypts OK
Two infra snippets you add yourself
The package encrypts and distributes the key. The two ends of the pattern live in your repo and your deploy pipeline — add them once.
1. Git-ignore the plaintext env files so only the .encrypted versions are ever committed. In .gitignore:
.env.production .env.uat
(.env.<env>.encrypted is not ignored — that is the whole point; it gets committed.)
2. Decrypt at deploy. Before copying the decrypted file into place, export the key from the box and run
env:decrypt. In your deploy step (adjust <dir>, <slug>, <env>):
export LARAVEL_ENV_ENCRYPTION_KEY="$(cat /etc/<dir>/<slug>.<env>.key)" php artisan env:decrypt --env=<env> --force cp .env.<env> .env
env:decrypt reads the key from LARAVEL_ENV_ENCRYPTION_KEY, decrypts .env.<env>.encrypted back to
.env.<env>, and you copy that to the active .env.
Usage
Provision UAT — encrypt .env.uat, install the key on the configured host, copy it to your clipboard:
php artisan secrets:provision uat
Production, overriding the host and key location for this run:
php artisan secrets:provision production --host=prod-box --dir=/etc/myapp --slug=myapp
Encrypt only, without touching any server (e.g. rotating the committed ciphertext locally):
php artisan secrets:provision uat --local
After a successful run: commit the updated .env.<env>.encrypted, and confirm the key landed in your
password manager (it is already on your clipboard).
Options
| Option | Default | Purpose |
|---|---|---|
env |
(required) | Environment to encrypt, e.g. uat, production. |
--host |
per-env, else config('env-secrets.host') |
ssh host alias of the box that stores the key. |
--dir |
per-env, else config('env-secrets.dir') |
Directory on the box that holds the key files. |
--slug |
per-env, else config, else app name | Filename stem — key is <slug>.<env>.key. |
--group |
per-env, else config('env-secrets.group') |
Unix group allowed to read the key — see below. |
--sudo |
per-env, else config('env-secrets.sudo'), else auto |
auto, always or never — see below. |
--local |
off | Encrypt locally only; skip installing on the box. |
Sharing a key with a team
By default the key lands 600, owned by the user you ssh in as — so exactly one person can run
secrets:edit against that box. Everyone else gets
Could not read the key at <host>:<path> — run \secrets:provision` first?, which reads like a missing key and is really a permission denial. The key is fetched with a plain ssh cat`; there is no
sudo fallback, deliberately.
To let a team share it, name a unix group. The directory becomes 750 and the key 640, owned by
<your user>:<group>:
# once, on the box sudo groupadd -f deployers && sudo usermod -aG deployers alice php artisan secrets:provision production --group=deployers
Prefer setting group in the config over fixing the permissions by hand: a key rotation re-runs the
install step, and only a configured group survives it — a hand-applied chgrp is silently reverted to
owner-only the next time anyone rotates, locking the team out again with that same misleading error.
Group membership applies to new logins; an open ssh session keeps the groups it started with.
Installing without sudo
Only two install steps can ever need root: creating the key directory under a root-owned parent such
as /etc, and handing the key to a group the ssh user is not in. Everything else is done as the ssh
user. So by default (sudo => 'auto') the install uses no sudo at all when the ssh user owns the key
directory or can create it, for example a directory in their home on a Mac mini:
'environments' => [ 'production' => [ 'host' => 'mac-mini', 'dir' => '/Users/deploy/.config/env-secrets', ], ],
Only when it can't does the install use sudo -n. That fails straight away with
sudo: a password is required rather than waiting on a prompt, because a non-interactive ssh can never
answer one. The two other modes pin the choice:
sudo |
Behaviour |
|---|---|
auto (default) |
No sudo when the ssh user owns (or can create) the dir and, with a group, is in it. sudo -n otherwise. |
always / true |
Always create the dir and set the group with sudo -n. |
never / false |
Never run sudo. Fails if the ssh user cannot create the dir. |
A box whose key dir lives under /etc without passwordless sudo has two ways out. Create the directory
once, owned by the ssh user (sudo install -d -m 700 -o "$USER" /etc/myapp), and auto needs no sudo
from then on. Or give the ssh user passwordless sudo.
A failed install never loses the key. The new key is written beside the old one and renamed over it
only once it is complete, so a failure leaves the box's current key untouched. If the install fails,
secrets:provision exits non-zero, puts the new key on your clipboard (the committed
.env.<env>.encrypted is already encrypted with it), and prints the command to install it by hand:
pbpaste | ssh mac-mini 'umask 077; mkdir -p /Users/deploy/.config/env-secrets && chmod 700 … && cat > …/app.production.key && chmod 600 …'
Without a clipboard (not on macOS), the new key can't be kept. The command then puts
.env.<env>.encrypted back the way it was, so nothing changes.
Editing an env later
Once an env is provisioned, the key already lives on the box — so editing it is a two-step loop that reuses that key instead of minting a new one:
php artisan secrets:edit production # fetch key from box → writes plaintext .env.production # ...edit .env.production... php artisan secrets:reencrypt production --prune # re-encrypt with the SAME key, verify, remove plaintext
secrets:reencrypt decrypts the fresh ciphertext in memory and asserts it matches your plaintext before
you commit, so a bad encrypt can never reach the repo. --prune deletes the plaintext after a verified
run. Then commit .env.production.encrypted.
Never re-run
php artisan env:encryptby hand to update an already-provisioned env. That command reads the key only from--key— it ignoresLARAVEL_ENV_ENCRYPTION_KEY— and, run non-interactively, silently mints a throwaway random key. The result is ciphertext your box can no longer decrypt ("The MAC is invalid" at deploy).secrets:reencryptexists precisely to prevent this: it always feedsenv:encryptthe real box key.
Inspecting an env
php artisan secrets:status production # provisioned? does the box key decrypt the committed file? php artisan secrets:show production # list variable NAMES with masked values (no plaintext on disk) php artisan secrets:show production DB_PASSWORD # print ONE value (explicit per-value opt-in)
Both take --remote to read the live deployed .env on the server instead of the committed
ciphertext — the source of truth for what the app is actually running. Set app_path (or --path) first:
php artisan secrets:show production DB_HOST --remote php artisan secrets:status production --remote
Topping up a teammate's .env
A developer pulls a commit that adds a new secret. Their .env, written months ago, has no such key, and
nothing in the repo can tell them which one is missing — the value only exists inside the ciphertext.
secrets:merge closes that gap, so git pull is enough:
php artisan secrets:merge local # append what .env is missing, from .env.local.encrypted php artisan secrets:merge local --dry-run # report the decisions, write nothing
add OPENROUTER_API_KEY sk********
fill RESEND_KEY (was empty) → re********
skip APP_KEY (protected — never merged)
same MAIL_MAILER (already set)
differs SMTP_PASSWORD (already set, differs — left alone)
Three rules make it safe to run unattended, which is the point — it is meant to live in whatever script your team runs after a pull:
- A key already in the target is skipped, whatever its value. A local override is never clobbered
silently. A value that differs is reported as differing and nothing more — never what it differs to.
The one exception is a key that is present but empty — the
FOO=acp .env.example .envleaves behind. That is a placeholder, not a choice, so it is filled. An env file whose double quote is never closed is refused on either side, rather than merged: the assignments after it would otherwise be swallowed into one entry, invisible to rule 2 below. - Protected keys are never written, not even with
--replace --force. Configure them inconfig/env-secrets.php; the defaults coverAPP_KEY,APP_ENV,DB_*,REDIS_*and*_DRIVER, so pointing a merge at a deploy env cannot push production credentials onto a laptop. - An appended line is written verbatim — the exact source line, comments and quoting intact, including
a double-quoted value that spans several lines. The file is rewritten through a temp sibling and one
rename(), so a.envis never left half-written, and the previous contents are copied to<target>.backupfirst (an existing backup is rotated, never eaten). Both files are created with the target's own mode before a byte of plaintext reaches them.
Values only ever appear masked, so this is safe to run in a script whose output someone might paste.
Warning
Laravel's skeleton .gitignore lists .env and .env.backup literally, with no glob, so the
rotated backups this command leaves (.env.backup.20260917121500-a1b2c3, or .env.local.backup for
a different --into) are untracked files a git add -A would happily stage. Add .env*.backup* to
your .gitignore alongside the entries below.
Options
| Option | Effect |
|---|---|
--into= |
The file to merge into (default .env), relative to the project root unless absolute. |
--dry-run |
Report add / same / differs / skip and write nothing. |
--replace |
Overwrite keys that already exist. Asks first — add --force for a non-interactive run. |
--replace=A,B |
Overwrite only these keys. No confirmation; you already named them. An empty list replaces nothing. |
--host= --dir= --slug= |
As elsewhere — where the decryption key lives. |
Note
secrets:merge reads the key off the box over ssh like every other command here, so it fails for anyone
offline or without access. Treat it as advisory in an automated script: report the failure and carry on
rather than failing the whole sync.
Important
The protected list fails closed. If env-secrets.merge.protected resolves empty the command refuses
to run at all, rather than merging with the seatbelt off. The likeliest way to get there is a
bootstrap/cache/config.php built before you upgraded — mergeConfigFrom is a no-op against a cached
config — so the fix is php artisan config:clear.
Security notes
- The key is minted with
random_bytes(CSPRNG) and passed toenv:encryptvia--key, soenv:encryptnever generates a key of its own. It does echo back the key it was handed — its last line istwoColumnDetail('Key', ...)— so it is invoked withcallSilently()and the console only ever sees this command's own summary. - The key is streamed to the server over ssh stdin and to the clipboard over pbcopy stdin — never
as a shell argument, so it stays out of
ps, shell history, and CI logs. - On the box the key file is created
600, owned by the connecting ssh user, in a700directory. It is written to a.tmpfile beside the old key, locked down, then renamed over it, so a failed install never truncates the key the box already has. sudo is used only where it is needed, and only assudo -n. - Every command prints the host and key path it resolved before touching the box, so a run aimed at the wrong box shows it on the first line.
env,slug, anddirare validated ([a-z0-9-]/ absolute path) before any process runs.
Testing
composer test
License
The MIT License (MIT). See LICENSE.md.
ผู้พัฒนา
พัฒนาและดูแลโดย บริษัท ภัทรชัย อาร์ทิซาน จำกัด (Phattarachai Artisan) บริษัทที่ปรึกษาและพัฒนาเว็บ ที่เรียนรู้และแบ่งปันกับชุมชน Laravel แพ็กเกจนี้แบ่งปันให้ชุมชนนำไปใช้และต่อยอดได้อย่างอิสระ
ดูแพ็กเกจอื่นของเราได้ที่ phattarachai.dev/open-source และติดต่อเราได้ที่ phattarachai.dev