Your tunnel comes up and the client connects, but the fixed address and the pushed routes you defined for that one user are missing. Nothing in the log says "file not found", which is what sends people off to regenerate certificates. In most of these cases the daemon runs from a different directory than the shell you tested in, and client-config-dir was never resolved the way you assumed. The fix is a path correction, not a credential reset.

Read the unit before you touch the config

Start with the process that actually runs, not with the file you edited. Ask systemd for the executable line, the working directory and the account it drops to. Those three answers explain most of the failures in this class:

systemctl show openvpn-server@server -p ExecStart -p WorkingDirectory -p User -p RootDirectory

An empty WorkingDirectory means the unit starts in /, so a relative client-config-dir ccd is being looked for at /ccd. That directory almost never exists, and the daemon does not treat its absence as fatal.

Compare that with the interactive launch you used while testing, where your shell's current directory was probably the configuration directory. Same file, same keywords, two different results.

What a relative path is resolved against

The keyword is resolved against the process working directory at the time the option is used. It does not mean "beside the config file I passed with --config". OpenVPN will happily read a config from an absolute path and still resolve relative values elsewhere, which is exactly the trap here. The OpenVPN 2.6 manual states the rule under --client-config-dir and again under --cd.

The cd keyword changes that context before other files are read, so a unit that sets cd /etc/openvpn/server and then client-config-dir ccd resolves to /etc/openvpn/server/ccd. If you add cd, check the order of the directives and remember it affects every other relative reference in the same file.

Walk the path as the daemon's account

A path can be correct and still unreadable. After the privilege drop the daemon is no longer root, so every parent directory needs search permission for that account, and the client file itself needs read permission. One missing execute bit on a parent is enough to hide the file, and the error surfaces only as a client that quietly gets the default pool.

namei prints the permission of each component in order, which is faster than checking directories one at a time:

namei -l /etc/openvpn/server/ccd/client1

Then test the read as the service account rather than as yourself:

runuser -u openvpn -- test -r /etc/openvpn/server/ccd/client1 && echo readable

If the unit uses chroot, the directory has to exist inside the jail, and the path in the configuration is interpreted after the chroot is applied. A file that is present on the host outside the jail is invisible from inside it.

Confirm the lookup instead of guessing

Raise the log level for one restart and read what the server says about client-specific options. At verb 4 the daemon reports the per-client configuration it imported, so an absent line is direct evidence that no file was read for that connection.

openvpn --config /etc/openvpn/server.conf --verb 4 --client-config-dir /etc/openvpn/server/ccd

Run that in the foreground on a spare port and connect one client. It removes systemd from the picture entirely: if the overrides appear here and not under the service, the difference is the unit's context, not the file.

Decide what should happen when no file matches. Without ccd-exclusive the client is admitted with only the global pool settings, which is the silent failure described above. With it, a missing file becomes a refused connection, and that refusal is easier to notice but harder to debug under load.

Fix, restart, verify

Make the directory absolute in the configuration and keep it that way. Relative paths are only safe when the unit pins the working directory, and pinning it is an extra moving part that someone will remove later.

client-config-dir /etc/openvpn/server/ccd
ccd-exclusive

Restart the instance, reconnect one known client, and read the address it received along with the routes pushed to it. If the address matches the ifconfig-push line in that client's file, the lookup worked. If the client still lands in the general pool, go back to the log line count: zero imports means the path or the identity is wrong, one import with the wrong contents means you are editing a different file than the daemon reads.

Roll back by restoring the previous unit and configuration together, then restart. Certificates do not need to be reissued for any of this, and treating a directory problem as a credential problem is how a five-minute fix turns into a maintenance window.

Barruulee wal qabatan