DEV Community

Cover image for Running SnapEnv on a Bare Linux Server with systemd
Mohammed Tayeh
Mohammed Tayeh

Posted on AI-assisted

Running SnapEnv on a Bare Linux Server with systemd

Part 2 of 3 — if you haven't already, part 1 covers creating a project and adding your first variables from the dashboard. This guide picks up from there and assumes a project already has variables in it.

Not every service runs in Kubernetes. Plenty of production workloads are still one Go binary (or Rails app, or whatever) running under systemd on a plain VPS — and that's exactly the case SnapEnv's CLI was built for first: no plaintext .env sitting on disk forever, no manually SSH-ing in to update a secret, and a full audit trail of every pull.

This guide sets up a systemd service that:

  • Pulls its secrets from SnapEnv into memory-backed storage (/run, tmpfs) — never written to persistent disk
  • Authenticates with a token scoped to exactly one project, one environment, read-only
  • Picks up changed secrets automatically on a timer, and only restarts when something actually changed

Why not just scp a .env file?

Because that .env file then sits on the server indefinitely, readable by anyone with access to the filesystem or a backup of it, with no record of who put which value there or when. SnapEnv's answer is the same one the CLI already gives you locally — snapenv pull — just wired into systemd's own primitives instead of a login shell.

1. Install the CLI on the server

The snapenv binary is a single static executable — no runtime dependencies, so it drops straight into a minimal server image:

# Auto-detects OS/arch, installs to ~/.local/bin/snapenv
curl -fsSL https://get.snapenv.io/install.sh | sh

# Or pin it to a system path directly, per architecture
curl -fsSL https://get.snapenv.io/cli/latest/snapenv-linux-amd64 \
  -o /usr/local/bin/snapenv && chmod +x /usr/local/bin/snapenv

# arm64 (Graviton, Ampere, etc.)
curl -fsSL https://get.snapenv.io/cli/latest/snapenv-linux-arm64 \
  -o /usr/local/bin/snapenv && chmod +x /usr/local/bin/snapenv
Enter fullscreen mode Exit fullscreen mode

For a systemd-managed service, install to /usr/local/bin~/.local/bin only works if the service runs as a user with that directory on $PATH, which systemd units usually don't have.

2. Create a scoped, read-only token

This is the one place least-privilege actually matters most: a token living on a server is a token that could leak in a backup, a core dump, or a misconfigured log line. Scope it as tightly as the service needs and nothing more.

From Access Tokens → New token: read-only scope, pinned to this one project, and — critically — only the prod environment checked. This service has no business being able to read dev or staging:

Scoped read-only token for prod

I picked No expiry here deliberately — an expiring token on an unattended server is a token that silently breaks your deploy six months from now. If your org has a rotation policy, rotate on a schedule you control instead of letting an expiry date pick the moment for you.

prod is a protected environment by default, which is why it shows a 🔒 in the dashboard — this token still works because tokens carry their own scope independent of the creator's role, but it's worth understanding why prod is locked down for human accounts even while a scoped machine token can read it:

prod marked as a protected environment

Copy the token — it's shown exactly once.

3. Store the token outside the unit file

Don't put Environment=SNAPENV_TOKEN=snp_live_... directly in the .service file — anyone who can run systemctl cat or systemctl show on the unit can read it back out. Put it in its own file instead, readable only by the user the service runs as:

sudo mkdir -p /etc/snapenv
sudo tee /etc/snapenv/token.env > /dev/null <<'EOF'
SNAPENV_TOKEN=snp_live_xxxxxxxxxxxxxxxxxxxx
SNAPENV_PROJECT=ad12d532-0779-49fa-8045-eed069db8597
SNAPENV_ENV=prod
EOF

sudo chown myapp:myapp /etc/snapenv/token.env
sudo chmod 600 /etc/snapenv/token.env
Enter fullscreen mode Exit fullscreen mode

(Swap myapp for whichever system user your service runs as, and the project UUID for the one shown at the top of your own project's Variables page.)

Setting all three of SNAPENV_TOKEN, SNAPENV_PROJECT, and SNAPENV_ENV puts the CLI into non-interactive auto mode — no snapenv login, no config file, nothing written to ~/.config. Every snapenv command in the rest of this guide just picks these up from the environment.

4. Wire it into the systemd unit

The pattern: ExecStartPre pulls fresh secrets into a tmpfs-backed runtime directory, and a second EnvironmentFile= loads that pulled file straight into the service's own environment — no dotenv-parsing code needed in the app itself.

# /etc/systemd/system/myapp.service
[Unit]
Description=My App
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=myapp
Group=myapp

# tmpfs-backed /run/myapp, owned by myapp:myapp, auto-cleaned on stop —
# the pulled secrets never touch persistent disk
RuntimeDirectory=myapp
RuntimeDirectoryMode=0700

# Token + project + env for the CLI itself
EnvironmentFile=/etc/snapenv/token.env

# Pull fresh secrets before every start/restart
ExecStartPre=/usr/local/bin/snapenv pull --file /run/myapp/env

# Load the pulled secrets into the service's own environment
EnvironmentFile=/run/myapp/env

ExecStart=/opt/myapp/bin/server

Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target
Enter fullscreen mode Exit fullscreen mode
sudo systemctl daemon-reload
sudo systemctl enable --now myapp.service
sudo systemctl status myapp.service
Enter fullscreen mode Exit fullscreen mode

Two EnvironmentFile= lines is intentional and valid — systemd merges them in order, and each is re-read fresh at start time, which is exactly what makes ExecStartPre writing the second file before ExecStart reads it work.

5. Refresh secrets on a timer, restart only when something changed

A dumb "re-pull and restart every 5 minutes" timer works, but it also bounces your service every 5 minutes even when nothing changed — noisy, and needlessly disruptive if the process is mid-request. Use snapenv diff to gate the restart on there actually being a difference:

# /usr/local/bin/snapenv-refresh-myapp.sh
#!/bin/sh
set -e

ENV_FILE=/run/myapp/env

if snapenv diff --file "$ENV_FILE" --no-metadata; then
  # exit 0 = no differences — nothing to do
  exit 0
fi

snapenv pull --file "$ENV_FILE"
systemctl restart myapp.service
Enter fullscreen mode Exit fullscreen mode
sudo chmod +x /usr/local/bin/snapenv-refresh-myapp.sh
Enter fullscreen mode Exit fullscreen mode
# /etc/systemd/system/myapp-secrets-refresh.service
[Unit]
Description=Check SnapEnv for changed secrets, restart myapp if needed

[Service]
Type=oneshot
EnvironmentFile=/etc/snapenv/token.env
ExecStart=/usr/local/bin/snapenv-refresh-myapp.sh
Enter fullscreen mode Exit fullscreen mode
# /etc/systemd/system/myapp-secrets-refresh.timer
[Unit]
Description=Run myapp-secrets-refresh every 5 minutes

[Timer]
OnUnitActiveSec=5min
OnBootSec=2min

[Install]
WantedBy=timers.target
Enter fullscreen mode Exit fullscreen mode
sudo systemctl daemon-reload
sudo systemctl enable --now myapp-secrets-refresh.timer
Enter fullscreen mode Exit fullscreen mode

Check it's actually running, and only touching myapp.service when it needs to:

$ systemctl list-timers myapp-secrets-refresh.timer
NEXT                        LEFT      LAST                         PASSED  UNIT
Thu 2026-09-17 14:05:00 UTC  4min left  Thu 2026-09-17 14:00:02 UTC  55s ago myapp-secrets-refresh.timer

$ journalctl -u myapp-secrets-refresh.service --since "1 hour ago"
-- no differences found, restart skipped --
Enter fullscreen mode Exit fullscreen mode

6. Rotating the token

Rotate from Access Tokens → the rotate icon next to prod · systemd (VPS). Rotation issues a new token with the same name, scope, and permissions, and revokes the old one immediately — so update /etc/snapenv/token.env first, then rotate, or the old token stops working before the new one is in place:

sudo -u myapp tee /etc/snapenv/token.env > /dev/null <<'EOF'
SNAPENV_TOKEN=snp_live_<new-token>
SNAPENV_PROJECT=ad12d532-0779-49fa-8045-eed069db8597
SNAPENV_ENV=prod
EOF
sudo systemctl restart myapp-secrets-refresh.service
Enter fullscreen mode Exit fullscreen mode

Then check the audit log — every pull shows up there, so a rotation followed by a burst of vars.pull events from the right token name is your confirmation it worked.

Troubleshooting

snapenv pull exits 1 with an auth error. The token was revoked (check if someone rotated it), expired (shouldn't happen here since we picked No expiry), or /etc/snapenv/token.env has the wrong project/env. Run the pull manually as the service user to see the real error: sudo -u myapp snapenv pull --file /tmp/test.env.

EnvironmentFile=/run/myapp/env — service fails with "No such file or directory". RuntimeDirectory= didn't get created, or ExecStartPre failed silently before writing the file. Check journalctl -u myapp.service -n 50 for the ExecStartPre failure — it blocks ExecStart from running at all, which is the correct behavior (better a failed start than a service running with stale or missing secrets).

Values look wrong / stale. EnvironmentFile= lines don't support export, quotes around values, or inline comments — if the app was previously loading a hand-written .env with any of that, the raw output from snapenv pull (plain KEY=value, no quoting) may parse differently. Run snapenv pull --stdout --env prod locally to inspect exactly what gets written.

The refresh timer runs but never restarts the service. That's snapenv diff correctly reporting no differences — check with snapenv diff --env prod --file /run/myapp/env by hand. If you know something changed and diff disagrees, confirm /run/myapp/env wasn't wiped by a reboot between pulls (it's tmpfs, so a reboot clears it — myapp.service's own ExecStartPre will repopulate it on next start, but the refresh timer diffs against whatever's currently there).

What's next

This is part 2 of 3:

  1. Dashboard + CLI for developers
  2. Linux server with systemd (this guide)
  3. Kubernetes: the operator, auto-reload, and Helm — coming up next: syncing variables straight into native Secret objects, auto-restarting Deployments when they change (the same "diff before restart" idea from this guide, but as a Kubernetes controller), and a drop-in pattern for adding SnapEnv to your own Helm chart.

🎉 Code HELLOSNAP gets you the Pro plan free for 3 months, first 100 redemptions — redeem it from Workspace → Plan & Billing at snapenv.io.

Top comments (0)