Copy a working command line into ExecStart, reload, and the unit fails or exits silently with the arguments treated as filenames. Nothing is broken in systemd. ExecStart is an argument list, not a shell, so pipes, redirection and conditionals lose their meaning the moment they leave your terminal. Decide where the shell belongs, then write a unit that states exactly what should execute.

Why the pipeline in ExecStart does nothing

When a command is typed in a terminal, the shell parses it first. It builds a pipeline, opens files for redirection and expands variables before any program starts. systemd performs none of that: it splits ExecStart according to its own rules and executes the resulting program with those arguments. A > therefore arrives as a literal argument, and a | is handed to the program instead of connecting two processes. The unit is not misbehaving; the syntax meant something else.

That is deliberate rather than careless, because predictable argument handling is what makes units auditable. It only surprises people who assume a shell is present.

Write a unit that says exactly what runs

Begin with a small unit whose purpose is visible in the journal, so parsing problems stay separate from tunnel problems. This one only reports a binary version.

[Unit]
Description=Report the installed OpenVPN version

[Service]
Type=oneshot
ExecStart=/usr/sbin/openvpn --version
StandardOutput=journal
StandardError=journal

The path is a placeholder, so substitute the binary that is actually installed. Type=oneshot suits a command that runs to completion, and the journal settings put its output where you can read it without a terminal. There is deliberately no install section, so the unit is invoked by hand rather than enabled at boot. Validate the file before starting it, because a typo in a directive name fails differently from a typo in the path.

systemd-analyze verify /etc/systemd/system/openvpn-version.service
systemctl start openvpn-version.service

Keep shell logic in a script

When a helper genuinely needs conditional logic, put that logic in a script and call it explicitly. The script needs an interpreter line, executable permission and exit codes the service can report. Launching /bin/sh -c with a quoted command does work, but it hides quoting rules inside a unit and makes review harder. A named script can be tested by hand and replaced later without editing the unit. Do not rely on the interactive user's shell profile, because a service starts with its own environment.

Set WorkingDirectory when relative paths matter, and pass environment through the unit's supported directives instead of exporting it in a terminal. Secrets belong in protected files or credentials, not in argument lists that the journal may record.

Check the unit that actually exists

Drop-ins change behaviour without touching the file you remember editing. Read what the manager loaded and what the last run reported.

systemctl cat vpn-helper.service
journalctl -u vpn-helper.service -n 20 --no-pager

The unit dump includes drop-ins in load order, which is where an unexpected ExecStart usually comes from. After any change, reload the manager and confirm the new definition is what systemctl cat prints. The journal shows the exact command line and exit status, separating an argument problem from a missing binary or a permission error. Then decide whether the helper belongs to the existing VPN lifecycle or should remain a manual diagnostic, because a second background tunnel process creates a competing owner.

A unit that prints the right output on demand is a good first milestone. It is not yet a tunnel, and it should not pretend to be one.

Artaigilean co-cheangailte