The same command connects from a shell and dies under cron, so the natural conclusion is that the tunnel or the certificate is at fault. Look at what the two runs have in common before believing that: the binary, the configuration and the credentials are identical, and only the process environment changed. When the program is not even found, no amount of TLS debugging helps. The fix usually lives in a path, a working directory and a handful of environment variables, not in the VPN.

command -v openvpn in the shell that works prints the answer, and it is very often /usr/sbin/openvpn, a directory that cron does not put on its search path.

The Tunnel Never Started, So the Tunnel Is Not the Problem

Jobs started by cron inherit a minimal environment, conventionally PATH=/usr/bin:/bin, and no controlling terminal. Anything installed in /usr/sbin or /usr/local/sbin is simply absent from that list, and the log line reads openvpn: not found instead of a TLS error. Python's subprocess fails differently: it inherits the environment of the interpreter process, which may be richer than cron's but still has no shell to expand a tilde. Both failures happen before a single packet leaves the host.

A quick way to tell them apart is to read the first error instead of the last. A missing binary produces a shell or Python exception, a bad configuration produces an OpenVPN usage message, and a real handshake failure produces timestamps and peer addresses. Only the third one is a tunnel problem.

Absolute Paths and Argument Lists Beat Shell Strings

Pass the resolved path explicitly and skip the shell entirely. subprocess.run(["/usr/sbin/openvpn", "--config", config, "--verb", "4"], capture_output=True, text=True, timeout=90) gives you a return code, stdout and stderr without asking a shell to interpret a filename. Build the configuration path from os.environ["HOME"] or pathlib.Path.home(), so a tilde never has to be expanded. When you genuinely need shell syntax, use shlex.split, because splitting a string with .split() breaks on any path containing a space. Read stderr as well, since OpenVPN writes most of its early failures there.

The same rule covers cron: the job line is not a login shell, so nothing sources your profile and nothing defines your aliases.

What a crontab Actually Inherits

A crontab entry gets whatever environment the crontab declares plus a few inherited defaults, so spell the path out or wrap the call in a script that does. A line such as PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin at the top of the file covers the usual install locations, and redirecting with >> cron.log 2>&1 is what makes the next failure readable. A passphrase-protected profile has no way to ask for its secret under cron, since no terminal exists to prompt on. Such a connection belongs in a systemd unit that can read a credential file, not in a job that pretends to be interactive.

Relative paths are the second trap. A test run often happens from the folder that holds the profile, and a cron job starts somewhere else entirely, so --config office.ovpn resolves to a file that is not there and the message reads as a missing file rather than a missing tunnel.

Prove It by Stripping the Environment

Reproduce the minimal environment directly: env -i PATH=/usr/bin:/bin /usr/sbin/openvpn --config office.ovpn --verb 4. A path or terminal complaint from that run means the problem sits above the VPN. If it connects, the remaining suspects are the account it runs as, the directory it starts in, and the credential it presents. Either result beats another round of certificate checking.

Then make the wrapper self-reporting. Exit with a distinct status for each stage it reached, log both streams with a timestamp, and the next failure tells you whether it died at path resolution, at authentication, or after the tunnel was already up.

A unit also needs correct ordering: Wants=network-online.target plus After=network-online.target keeps the client from starting before an address or default route exists, which otherwise surfaces as certificate or TLS errors that vanish on a manual restart.

Makomi ya ndenge moko