Skip to content
Vast Cow's blog
Go back

Systemd and Certbot for Automated TLS Certificate Renewal (with DynamicUser for Privilege Separation)

Edit page

Introduction

This configuration automates the acquisition and renewal of Let’s Encrypt TLS certificates using Certbot and executing them periodically with systemd services and timers, while separating permissions using DynamicUser.

The main security focus is on “running with the least privileges necessary.”

Certificate acquisition and UPnP port operations are performed as a non-privileged user using systemd’s DynamicUser=yes, and operations requiring root privileges are limited to managing Python packages, deploying certificates, and restarting services.

A fixed dedicated user is not created, and explicit privilege switching using sudo -u is not used.

Furthermore, Python packages are checked for updates before each execution. If uv is installed, uv pip is prioritized, and if not, pip is used as a fallback.

Security Design (Permission Boundaries)

Non-Privileged Execution with DynamicUser

When DynamicUser=yes is specified, systemd assigns a temporary UID/GID at service startup and releases it at service termination.

This eliminates the need to manage a fixed dedicated user.

The following processes are executed with non-privileged access:

These processes are not granted root privileges.

Processes Using Root

Processes requiring root privileges are separated by attaching the + prefix to the systemd execution command.

ExecStartPre=+ updates Certbot’s Python virtual environment.

ExecStartPost=+ copies the obtained certificate to a protected location and restarts Nginx and StrongSwan.

The regular ExecStart= is executed as a non-privileged user using DynamicUser=yes.

The execution order is as follows:

certbot-renew.timer
        |
        v
certbot-renew.service
        |
        +-- ExecStartPre=+update-certbot.sh
        |       root
        |       Python package update
        |
        +-- ExecStart=certbot-renew.sh
        |       DynamicUser
        |       Local IP acquisition
        |       UPnP port opening
        |       Certbot execution
        |       UPnP port closing
        |
        +-- ExecStartPost=+certbot-deploy.sh
                root
                Update flag check
                Certificate verification and deployment
                Nginx / StrongSwan restart

If ExecStartPre= or ExecStart= fails, ExecStartPost= is not executed.

Note that the + prefix not only switches UID/GID but also releases some sandbox restrictions. Therefore, scripts executed as root should be owned by root and not modifiable by non-privileged users.

Directory Management with systemd

Persistent data and temporary data are managed using the following systemd directives:

Combining DynamicUser=yes and StateDirectory= protects the persistent directory with systemd.

Therefore, the webroot for HTTP-01 challenges, which Nginx references externally, is placed in the protected StateDirectory rather than in the RuntimeDirectory.

Deploy Only When an Update Occurs

A flag file is created with Certbot’s --deploy-hook.

Only if the certificate is newly issued or updated, the subsequent root process copies the certificate and restarts the service.

If there are no changes to the certificate, the deployment is skipped.

File Structure

The project has the following structure:

certbot-renewal/
├── certbot-renew.service
├── certbot-renew.timer
├── certbot.env.example
├── update-certbot.sh
├── certbot-renew.sh
├── certbot-deploy.sh
├── install.sh
└── uninstall.sh

The main locations after installation are as follows:

/etc/
├── certbot-renewal/
│   └── certbot.env
└── systemd/system/
    ├── certbot-renew.service
    └── certbot-renew.timer

/usr/libexec/
└── certbot-renewal/
    ├── update-certbot.sh
    ├── certbot-renew.sh
    ├── certbot-deploy.sh
    └── venv/

/var/lib/
└── certbot-renewal/
    └── config/

/var/log/
└── certbot-renewal/

/run/
└── certbot-renewal/
    ├── work/
    ├── webroot/
    └── renewed

/var/lib/certbot-renewal and /var/log/certbot-renewal may be managed via systemd’s private directories in practice.

certbot-renew.service

[Unit]
Description=Renew and deploy TLS certificates using Certbot
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot

EnvironmentFile=/etc/certbot-renewal/certbot.env

DynamicUser=yes

StateDirectory=certbot-renewal
StateDirectoryMode=0700

RuntimeDirectory=certbot-renewal
RuntimeDirectoryMode=0755

LogsDirectory=certbot-renewal
LogsDirectoryMode=0700

PrivateTmp=yes
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes

# HTTP-01 challenge files must be readable by Nginx.
UMask=0022

ExecStartPre=+/usr/libexec/certbot-renewal/update-certbot.sh
ExecStart=/usr/libexec/certbot-renewal/certbot-renew.sh
ExecStartPost=+/usr/libexec/certbot-renewal/certbot-deploy.sh

Type=oneshot causes each phase to be executed in order.

UMask=0022 is a setting to ensure that the HTTP-01 challenge file created by Certbot, which is executed as a non-privileged user, can be read by Nginx.

On the other hand, persistent data such as private keys is protected with StateDirectoryMode=0700. Certbot itself also sets restricted access permissions for private keys.

NoNewPrivileges=yes and ProtectSystem=strict restrict the normal non-privileged process.

certbot-renew.timer

[Unit]
Description=Periodically renew TLS certificates using Certbot

[Timer]
Unit=certbot-renew.service

OnCalendar=*-*-* 00:00:00
OnCalendar=*-*-* 12:00:00

RandomizedDelaySec=1h
Persistent=true

[Install]
WantedBy=timers.target

Certbot is executed twice a day.

RandomizedDelaySec=1h distributes the execution time, and Persistent=true compensates for missed executions during shutdown.

If there is sufficient margin in the certificate validity period, Certbot will exit without updating.

update-certbot.sh

This script is executed as root and creates or updates Certbot’s dedicated Python virtual environment.

It checks for package updates before each execution, so it is executed every time the timer is started.

#!/usr/bin/env bash
set -Eeuo pipefail

if (( EUID != 0 )); then
    echo "[error] must run as root" >&2
    exit 1
fi

VENV="$CERTBOT_VENV"

echo "[info] preparing Certbot virtual environment" >&2

if [[ ! -x "$VENV/bin/python" ]]; then
    echo "[info] creating virtual environment" >&2

    # The virtual environment must remain root-owned.
    umask 022
    python3 -m venv "$VENV"
fi

echo "[info] updating Python packages" >&2

if command -v uv >/dev/null 2>&1; then
    echo "[info] using uv pip" >&2

    uv pip install \
        --python "$VENV/bin/python" \
        --upgrade \
        certbot
else
    echo "[info] using pip" >&2

    if ! "$VENV/bin/python" -m pip --version \
        >/dev/null 2>&1; then
        "$VENV/bin/python" -m ensurepip --upgrade
    fi

    "$VENV/bin/python" -m pip install \
        --upgrade pip

    "$VENV/bin/python" -m pip install \
        --upgrade \
        --upgrade-strategy eager \
        certbot
fi

echo "[info] checking installed dependencies" >&2

if command -v uv >/dev/null 2>&1; then
    uv pip check --python "$VENV/bin/python"
else
    "$VENV/bin/python" -m pip check
fi

echo "[info] installed Certbot version:" >&2
"$VENV/bin/certbot" --version

echo "[info] Python package update completed" >&2

Using uv and pip

By using uv pip install --python, the target Python virtual environment can be explicitly specified.

--upgrade resolves the new compatible version of Certbot and the necessary dependent packages.

With regular pip, --upgrade-strategy eager is also specified to actively update dependent packages.

In either case, only Certbot and its dependencies are updated, and arbitrary Python packages not included in Certbot’s dependencies are not updated.

Since this configuration attempts to update packages every time, the certificate renewal process will fail if the Python package repository is inaccessible or if dependency resolution fails. In addition, the latest release of the package may have bugs, which may affect certificate renewal.

In a production environment, consider the risks and implement error monitoring, pre-update backups, and version locking as needed.

certbot-renew.sh

This script executes Certbot and UPnP as a non-privileged user.

No root checks or privilege switching using sudo -u is required.

#!/usr/bin/env bash
set -Eeuo pipefail

if (( EUID == 0 )); then
    echo "[error] must run as non-root" >&2
    exit 1
fi

# Paths default to directories managed by systemd.
CONFIG_PATH="${CONFIG_PATH:-${STATE_DIRECTORY:?STATE_DIRECTORY is required}/config}"
WORK_DIR="${WORK_DIR:-${RUNTIME_DIRECTORY:?RUNTIME_DIRECTORY is required}/work}"
LOGS_DIR="${LOGS_DIR:-${LOGS_DIRECTORY:?LOGS_DIRECTORY is required}}"
WEB_ROOT="${WEB_ROOT:-${RUNTIME_DIRECTORY:?RUNTIME_DIRECTORY is required}/webroot}"
FLAGFILE="${RUNTIME_DIRECTORY:?RUNTIME_DIRECTORY is required}/renewed"

# Prepare writable runtime directories.
install -d -m 0700 "$WORK_DIR"
install -d -m 0755 "$WEB_ROOT"

echo "[info] detecting local IP address" >&2

local_ip="$(
    ip -j route get 1.1.1.1 |
        jq -r '.[0].prefsrc // empty'
)"

if [[ -z "$local_ip" ]]; then
    echo "[error] failed to detect local IP" >&2
    exit 1
fi

echo "[info] local IP: $local_ip" >&2

mapping_attempted=0

cleanup() {
    if (( mapping_attempted )); then
        echo "[info] closing UPnP port" >&2
        upnpc -d 80 tcp || true
    fi
}

trap cleanup EXIT
trap 'exit 143' TERM
trap 'exit 130' INT

echo "[info] opening UPnP port" >&2

# WAN:80 -> LAN:80, lease: 600 seconds
mapping_attempted=1
upnpc -a "$local_ip" 80 80 tcp 600

echo "[info] running Certbot" >&2

"$CERTBOT_VENV/bin/certbot" \
    certonly \
    --non-interactive \
    --quiet \
    --webroot \
    -w "$WEB_ROOT" \
    --cert-name "$CERT_NAME" \
    --key-type rsa \
    --rsa-key-size 4096 \
    -d "$DOMAINLIST" \
    --agree-tos \
    --no-eff-email \
    --email "$EMAIL" \
    --deploy-hook "/usr/bin/touch $FLAGFILE" \
    --config-dir "$CONFIG_PATH" \
    --work-dir "$WORK_DIR" \
    --logs-dir "$LOGS_DIR"

echo "[info] Certbot completed successfully" >&2

Handling UPnP Port Mapping

A port mapping from WAN:80 to LAN:80 is temporarily added for the HTTP-01 verification.

The mapping is deleted by trap upon Certbot’s termination.

However, if there is a SIGKILL, power outage, or abnormal process termination, the cleanup may not be executed.

Therefore, if the router supports it, the lease time is set to 600 seconds to prevent the mapping from remaining indefinitely.

Also, upnpc -d 80 tcp is an operation to delete the port mapping for the specified port. This is not suitable for configurations that share the same port mapping for other purposes.

Update Flag

A flag file is created with Certbot’s --deploy-hook when the certificate is issued or updated.

The subsequent root process only copies the certificate and restarts the service if the certificate has been newly issued or updated.

If there are no changes to the certificate, the deployment is skipped.

certbot-deploy.sh

This script is for root only and deploys the updated certificate to Nginx and StrongSwan.

If the certificate has not changed, it does nothing and exits.

#!/usr/bin/env bash
set -Eeuo pipefail

if (( EUID != 0 )); then
    echo "[error] must run as root" >&2
    exit 1
fi

CONFIG_PATH="${CONFIG_PATH:-${STATE_DIRECTORY:?STATE_DIRECTORY is required}/config}"
FLAGFILE="${RUNTIME_DIRECTORY:?RUNTIME_DIRECTORY is required}/renewed"

if [[ ! -f "$FLAGFILE" ]]; then
    echo "[info] no certificate renewal; skip deploy" >&2
    exit 0
fi

echo "[info] deploying renewed certificates" >&2

CERTPATH="$CONFIG_PATH/live/$CERT_NAME"

CONFIG_REAL="$(realpath -e -- "$CONFIG_PATH")"
ARCHIVE_PREFIX="$CONFIG_REAL/archive/$CERT_NAME/"

check_cert_file() {
    local name="$1"
    local source

    source="$(realpath -e -- "$CERTPATH/$name")" \
        || return 1

    [[ "$source" == "$ARCHIVE_PREFIX"* ]] \
        || return 1

    [[ -f "$source" && -s "$source" ]] \
        || return 1

    printf '%s\n' "$source"
}

FULLCHAIN="$(check_cert_file fullchain.pem)" || exit 2
PRIVKEY="$(check_cert_file privkey.pem)" || exit 2
CHAIN="$(check_cert_file chain.pem)" || exit 2

echo "[info] preparing destination directories" >&2

install -d -m 0755 "$NGINX_CERTS"
install -d -m 0755 "$IPSEC_ETC/private"
install -d -m 0755 "$IPSEC_ETC/cacerts"
install -d -m 0755 "$IPSEC_ETC/certs"

echo "[info] installing Nginx certificates" >&2

install -m 0644 \
    "$FULLCHAIN" \
    "$NGINX_CERTS/fullchain.pem"

install -m 0600 \
    "$PRIVKEY" \
    "$NGINX_CERTS/privkey.pem"

echo "[info] installing StrongSwan certificates" >&2

install -m 0600 \
    "$PRIVKEY" \
    "$IPSEC_ETC/private/privkey.pem"

install -m 0644 \
    "$CHAIN" \
    "$IPSEC_ETC/cacerts/chain.pem"

install -m 0644 \
    "$FULLCHAIN" \
    "$IPSEC_ETC/certs/fullchain.pem"

echo "[info] validating Nginx configuration" >&2

nginx -t

echo "[info] restarting Nginx" >&2
systemctl restart nginx

echo "[info] restarting StrongSwan" >&2
systemctl restart strongswan-starter

echo "[info] certificate deployment completed" >&2

Before deploying the certificate, it checks that the necessary files exist and are not empty.

It also checks that the symbolic link from live/ to archive/ points to the expected location.

However, this file check is a limited security measure. Since the certificate storage area managed by a non-privileged user is read by root, it does not completely protect against malicious simultaneous changes or link substitutions.

In a strict threat model, additional measures are needed, such as handling the certificate storage area, secure reading using file descriptors, certificate integrity verification, and atomic deployment after service configuration verification.

Also, this example does not have a complete rollback function for failed copies. If availability is important in a production environment, consider using a staging directory and atomic replacement.

certbot.env.example

CERT_NAME=hoge.ddns.net
DOMAINLIST=hoge.ddns.net
EMAIL=hoge@mail.com

CERTBOT_VENV=/usr/libexec/certbot-renewal/venv

# Optional overrides (uncomment only when the defaults are unsuitable):
# CONFIG_PATH=/custom/certbot/config
# WORK_DIR=/custom/certbot/work
# LOGS_DIR=/custom/certbot/logs
# WEB_ROOT=/custom/certbot/webroot

NGINX_CERTS=/etc/nginx/pki/certs
IPSEC_ETC=/etc/ipsec.d

For directories managed by systemd, you do not need to manually set the owner during installation.

In certbot.env, if there is no explicit specification, the path is derived from the systemd environment variable.

SettingDefault ValueReference
CONFIG_PATH$STATE_DIRECTORY/configStateDirectory=
WORK_DIR$RUNTIME_DIRECTORY/workRuntimeDirectory=
LOGS_DIR$LOGS_DIRECTORYLogsDirectory=
WEB_ROOT$RUNTIME_DIRECTORY/webrootRuntimeDirectory=
FLAGFILE (internal variable)$RUNTIME_DIRECTORY/renewedRuntimeDirectory=

FLAGFILE is not specified in the configuration file and is always assembled from RUNTIME_DIRECTORY. CERTBOT_VENV, NGINX_CERTS, and IPSEC_ETC cannot be derived from the systemd-provided environment variables, so explicit settings are maintained.

Systemd provides the environment variables RUNTIME_DIRECTORY, STATE_DIRECTORY, CACHE_DIRECTORY, LOGS_DIRECTORY, and CONFIGURATION_DIRECTORY. This configuration does not use CacheDirectory= and ConfigurationDirectory=, so the corresponding environment variables are not referenced. If more paths are added to the managed directories in the future, they will be derived in the same way from each management directory. The environment variables are colon-separated when multiple directories are specified in a single directive, so this example assumes that each management directory is specified individually.

If you run the script directly without systemd, the systemd environment variables will not be automatically provided. It is usually executed via a unit.

DOMAINLIST is assumed to be a single domain in this case.

If you want to specify multiple domains, extend the script to pass multiple -d options to Certbot.

Installation Method

First, create the necessary files and edit the configuration.

cp certbot.env.example certbot.env

Edit the contents of certbot.env according to your environment, and then install.

sudo ./install.sh

Check the installed configuration file.

sudoedit /etc/certbot-renewal/certbot.env

Run the initial execution manually.

sudo systemctl start certbot-renew.service

Check the execution log.

sudo journalctl \
    -u certbot-renew.service \
    -n 100 \
    --no-pager

Check the status of the timer.

systemctl list-timers certbot-renew.timer

If you want to check the systemd unit settings, use the following:

sudo systemd-analyze verify \
    /etc/systemd/system/certbot-renew.service

The following can be used to evaluate the permission settings:

systemd-analyze security certbot-renew.service

However, systemd-analyze security is a static configuration evaluation and does not guarantee actual attack resistance.

Migration from Existing Environment

If migrating from an old configuration that used a fixed user, you need to move the existing Certbot persistent data to the new StateDirectory.

First, stop the old timer and service, and back up the certificate, private key, ACME account information, and renewal settings.

Simply copying the contents of the old directory may cause the user assigned by DynamicUser=yes to not be able to access it.

Migrate, considering systemd’s private directory management and ownership processing, and after verifying that it is working, delete the old configuration.

In particular, the symbolic links from live/ to archive/ may be broken by the copy method.

Operational Notes

Package Updates Every Time

In this configuration, the Certbot Python package update is also checked each time the certificate renewal is attempted.

The introduction of new packages may cause compatibility issues, which may cause certificate renewal to fail.

In continuous operation, it is recommended to monitor systemd’s failure logs and be able to quickly detect problems.

Also, the Python package update, which is performed with root privileges, is separate from the ACME communication performed by the non-privileged user.

Certificate Validity Monitoring

Even if the systemd timer is working correctly, certificate renewal may fail due to DNS, router, UPnP, or Let’s Encrypt issues.

It is recommended to monitor the certificate validity in addition to checking that the timer is working.

Management of Deployment Destination

The certificate directories for Nginx and StrongSwan are managed by root, and write permissions are not granted to non-privileged users.

If more certificate deployment destinations are added, the configuration should be maintained so that the non-privileged Certbot process is not changed, and the root deployment script is added.

Dependence on External Services

Certificate acquisition depends on Let’s Encrypt’s ACME service, Python package updates depend on the package index, and UPnP operations depend on the router’s implementation.

These failures cannot be completely eliminated, so appropriate logging and failure monitoring is required.

Summary

In this configuration, systemd’s DynamicUser=yes is used to execute the certificate renewal process as a non-privileged user, and operations requiring root privileges are separated by attaching the + prefix to the ExecStartPre= and ExecStartPost= commands.

This eliminates the need to create a fixed user and allows systemd to manage ownership and runtime directory management.

Furthermore, Certbot’s Python virtual environment is managed by root, and the dependent packages are checked for updates each time using uv pip or pip.

Installation and removal are managed by install.sh and uninstall.sh, and the certificate data is retained in a normal uninstall.

The key point of this configuration is to clearly separate the different responsibilities of certificate acquisition, Python package management, and deployment to the production environment, and to grant only the privileges necessary for each process.


Edit page
Share this post:

Comments


Previous Post
Git Pull and Divergent Branches: Choosing Merge, Rebase, or Fast-Forward
Next Post
Automating TLS Certificate Renewal with systemd and `acme.sh`