WebDAV with Rclone and mTLS

Updated October 27, 2025 8 minutes

WebDAV is an open extension to the HTTP protocol that lets you treat a web server like a remote filesystem. Rclone is an open source WebDAV client that lets you mount remote volumes for read/write access on your local computer.

Provision your WebDAV server

You can use any WebDAV server that you have access to. If you need to install one, I recommend copyparty or nextcloud (Use the d.rymcg.tech distribution to install these on Docker.)

Mutual TLS (optional)

For extra security, the server may require authentication with a client TLS certificate, which this script fully supports. You will just need to place the certificate file (e.g., ryan-files.pem) and the unencrypted key file (e.g., ryan-files.key) and put them someplace permanent (e.g., directly in the rclone config directory).

## Copy the cert and key file to someplace permanent:
mkdir -p ~/.config/rclone
cp ryan-files.pem ~/.config/rclone/
cp ryan-files.key ~/.config/rclone/

Note: the certificate should have been provided to you by your administrator. If you are deploying your own server, check out Step-CA (and the d.rymcg.tech distribution for installing it on Docker).

Example

In this example, let’s assume the following connection details when setting up the WebDAV service:

  • URL: https://files.example.com

    • Note: for copyparty, there is usually no path necessary. For Nextcloud, you need to specify the URL with a path, e.g.: https://files.example.com/nextcloud/remote.php/dav/files/USERNAME/)
  • Username: ryan

    • HTTP authentication username.
    • Note: copyparty ignores the username field.
  • Password: hunter2

    • HTTP authentication password.
    • Note: for copyparty, this is your only authentication (besides mTLS).
  • Volume name: ryan-files

    • This is the internal name that Rclone will reference this remote with.
    • This is also the default name of the mount point. (e.g., ~/ryan-files)
  • Certificate: ~/.config/rclone/ryan-files.pem

    • Optional client TLS certificate.
  • Key: ~/.config/rclone/ryan-files.key

    • Optional client TLS key.

Download the script

wget https://raw.githubusercontent.com/EnigmaCurry/blog.rymcg.tech/master/src/rclone/rclone_webdav.sh
chmod +x rclone_webdav.sh

Configuration

Create a new config (or update an existing one) named ryan-files:

./rclone_webdav.sh config ryan-files

Follow the prompts to configure the volume:

== Interactive rclone WebDAV setup (idempotent) ==
WebDAV URL (e.g., https://copyparty.example.com): https://files.example.com
Vendor (copyparty|owncloud|nextcloud|other) [copyparty]: copyparty
Username (HTTP Auth; username ignored copyparty): admin
Password (HTTP Auth): hunter2
Client certificate PEM path [/var/home/ryan/.config/rclone/client.crt]: /var/home/ryan/.config/rclone/ryan-files.pem
Client key PEM path [/var/home/ryan/.config/rclone/client.key]: /var/home/ryan/.config/rclone/ryan-files.key
Mount point (absolute) [/var/home/ryan/ryan-files]: /var/home/ryan/ryan-files

This will save the Rclone configuration to ~/.config/rclone/rclone.conf.

Install service

Install the systemd/User service to automatically mount the volume:

./rclone_webdav.sh enable ryan-files

Check service status

./rclone_webdav.sh status ryan-files

Now the remote volume should be mounted locally at ~/ryan-files and will automatically mount when you login.

If you want the volume mounted on system boot (prior to first login), you may want to turn on systemd “lingering”:

# Allow user services to automatically start on system boot:
sudo loginctl enable-linger ${USER}

Check logs for debugging purposes

./rclone_webdav.sh log ryan-files

Uninstall service

./rclone_webdav.sh uninstall ryan-files

The script

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

# === Settings ===
: "${XDG_CONFIG_HOME:="$HOME/.config"}"
RCLONE_CFG_DIR="$XDG_CONFIG_HOME/rclone"
MOUNT_POINTS_FILE="$RCLONE_CFG_DIR/mount_points.cfg"
SYSTEMD_USER_DIR="$XDG_CONFIG_HOME/systemd/user"

# Defaults for mount behavior (can be overridden via environment)
: "${RCLONE_VFS_CACHE_MODE:=writes}"
: "${RCLONE_DIR_CACHE_TIME:=5s}"

# === Helpers ===
_die() { echo "Error: $*" >&2; exit 1; }
_need() { command -v "$1" >/dev/null 2>&1 || _die "Required command '$1' not found"; }
_mkdirp() { mkdir -p "$1"; }

_yesno() { # _yesno "Prompt" default_yes|default_no
  local prompt="$1" def="$2" ans defc
  case "$def" in
    default_yes) defc="Y/n";;
    default_no)  defc="y/N";;
    *) _die "_yesno: second arg must be default_yes|default_no";;
  esac
  read -r -p "$prompt [$defc] " ans || true
  ans="${ans,,}"
  if [[ -z "$ans" ]]; then
    [[ "$def" == "default_yes" ]] && return 0 || return 1
  fi
  [[ "$ans" == "y" || "$ans" == "yes" ]]
}

# Read mountpoint for a remote from ~/.config/rclone/mount_points.cfg
_get_mountpoint() {
  local name="$1"
  [[ -f "$MOUNT_POINTS_FILE" ]] || return 1
  # lines like: name=/path/to/mount
  awk -F= -v k="$name" '$1==k {print $2}' "$MOUNT_POINTS_FILE" | tail -n1
}

# Set/replace mountpoint entry
_set_mountpoint() {
  local name="$1" mp="$2"
  _mkdirp "$(dirname "$MOUNT_POINTS_FILE")"
  touch "$MOUNT_POINTS_FILE"
  # remove old line(s) for name
  grep -v -e "^${name}=" "$MOUNT_POINTS_FILE" > "${MOUNT_POINTS_FILE}.tmp" || true
  echo "${name}=${mp}" >> "${MOUNT_POINTS_FILE}.tmp"
  mv "${MOUNT_POINTS_FILE}.tmp" "$MOUNT_POINTS_FILE"
}

# Systemd unit filename for a remote
_unit_name() {
  local name="$1"
  # keep it simple; allow letters, digits, dash/underscore
  echo "rclone-${name}.service"
}

# Create (or overwrite) systemd --user unit for a remote
_write_unit() {
  local name="$1"
  local mp="$2"
  local unit="$(_unit_name "$name")"
  _mkdirp "$SYSTEMD_USER_DIR"
  cat > "${SYSTEMD_USER_DIR}/${unit}" <<EOF
[Unit]
Description=Mount ${name} via rclone
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
# You can tweak these defaults via environment variables.
Environment=RCLONE_VFS_CACHE_MODE=${RCLONE_VFS_CACHE_MODE}
Environment=RCLONE_DIR_CACHE_TIME=${RCLONE_DIR_CACHE_TIME}
ExecStart=/usr/bin/rclone mount ${name}: ${mp}
ExecStop=/bin/fusermount -u ${mp}
Restart=on-failure

[Install]
WantedBy=default.target
EOF
}

_print_help() {
  cat <<'EOF'
Usage: rclone-webdav.sh <command> [args]

Commands:
  help                    Show this help text.
  config                  Interactive setup: creates/updates an rclone WebDAV remote
                          and saves its mount point in ~/.config/rclone/mount_points.cfg.
  mount <name>            Mount the remote now (foreground). Uses saved mount point.
  enable <name>           Install and enable a systemd --user service for the remote.
  disable <name>          Disable and stop the systemd --user service for the remote.
  uninstall <name>        Disable, stop, and remove the systemd --user service file.
  status <name>           Check status of the systemd unit.
  log <name>              Show log from the systemd unit.

Notes:
- mTLS (client cert/key) is optional. If enabled in the wizard, the paths are stored as
  global.client_cert/global.client_key in rclone.conf for the remote.
- Mount options default to RCLONE_VFS_CACHE_MODE="writes" and RCLONE_DIR_CACHE_TIME="5s".
  Override by exporting env vars before running, or edit the generated unit.
- Mount point paths are stored as entries in ~/.config/rclone/mount_points.cfg (format: NAME=/path).
EOF
}

_prompt_default() {
  local prompt="$1" default="${2:-}"
  local ans
  if [[ -n "$default" ]]; then
    read -r -p "$prompt [$default]: " ans || true
    echo "${ans:-$default}"
  else
    read -r -p "$prompt: " ans || true
    echo "$ans"
  fi
}

_prompt_secret() {
  local prompt="$1"
  local ans
  read -r -s -p "$prompt: " ans || true
  echo
  echo "$ans"
}

_cmd_config() {
  _need rclone
  echo "== Interactive rclone WebDAV setup (idempotent) =="

  local name="${1:-}"; [[ -n "$name" ]] || _die "Usage: $0 config <name>"
  local url vendor user pass enable_mtls client_cert client_key mount_point

  # --- prompts ---
  url="$(_prompt_default 'WebDAV URL (e.g., https://copyparty.example.com)')"
  vendor="$(_prompt_default 'Vendor (copyparty|owncloud|nextcloud|other)' 'copyparty')"
  user="$(_prompt_default 'Username (HTTP Auth; username ignored by copyparty)')"
  pass="$(_prompt_secret 'Password (HTTP Auth)')"
  echo

  if _yesno "Use mutual TLS (client certificate)?" default_no; then
    enable_mtls=1
    client_cert="$(_prompt_default 'Client certificate PEM path' "$HOME/.config/rclone/client.crt")"
    client_key="$(_prompt_default 'Client key PEM path' "$HOME/.config/rclone/client.key")"
    echo
    # best-effort warnings if files missing; rclone will error if truly required
    [[ -f "$client_cert" ]] || echo "Warning: client cert not found at '$client_cert' (continuing)">&2
    [[ -f "$client_key" ]] || echo "Warning: client key not found at '$client_key' (continuing)">&2
  else
    enable_mtls=0
  fi

  local mp_default
  mp_default="$(realpath "$HOME/${name}" 2>/dev/null || echo "$HOME/${name}")"
  mount_point="$(_prompt_default 'Mount point (absolute)' "$mp_default")"

  # --- sanitize values to avoid CR/LF sneaking in ---
  sanitize() { printf %s "$1" | tr -d '\r\n'; }
  name=$(sanitize "$name"); url=$(sanitize "$url"); vendor=$(sanitize "$vendor")
  user=$(sanitize "$user"); pass=$(sanitize "$pass")
  client_cert=$(sanitize "${client_cert:-}"); client_key=$(sanitize "${client_key:-}")

  # vendor alias
  [[ "$vendor" == "copyparty" ]] && vendor="owncloud"

  echo
  echo "Creating/updating rclone remote '$name'..."

  # if exists, replace to keep idempotent
  if rclone config show 2>/dev/null | grep -qxF "[$name]"; then
    rclone config delete "$name" >/dev/null || true
  fi

  # build args array
  # include mTLS settings only if enabled
  args=( "$name" webdav "url=$url" "vendor=$vendor" "pacer_min_sleep=0.01ms" )
  if [[ "$enable_mtls" == "1" ]]; then
    args+=( "global.client_cert=$client_cert" "global.client_key=$client_key" )
  fi
  if [[ -n "$user" ]]; then
    args+=( "user=$user" )
  fi

  if [[ -n "$pass" ]]; then
    # use --obscure so rclone stores it safely
    rclone config create "${args[@]}" "pass=$pass" --obscure
  else
    rclone config create "${args[@]}"
  fi

  echo "Saving mount point mapping: ${name} -> ${mount_point}"
  _set_mountpoint "$name" "$mount_point"

  echo "Done. Try:"
  echo "  $(basename "$0") mount $name"
  echo "  $(basename "$0") enable $name"
}

_cmd_mount() {
  _need rclone
  local name="${1:-}"; [[ -n "$name" ]] || _die "Usage: $0 mount <name>"
  local mp="$(_get_mountpoint "$name")" || _die "No mount point stored for '$name'. Run '$0 config' first."

  # Ensure directory exists
  _mkdirp "$mp"
  echo "Temporarily mounting ${name}: at $mp"
  echo "This process will now block as it services the mount."
  echo "If you want to run in the background, you should enable the systemd unit instead."
  echo "e.g., \`${BASH_SOURCE} enable ${name}\`"
  exec rclone mount "${name}:" "$mp" \
    --vfs-cache-mode "$RCLONE_VFS_CACHE_MODE" \
    --dir-cache-time "$RCLONE_DIR_CACHE_TIME"
}

_cmd_enable() {
  _need systemctl
  _need rclone
  local name="${1:-}"; [[ -n "$name" ]] || _die "Usage: $0 enable <name>"
  local mp="$(_get_mountpoint "$name")" || _die "No mount point stored for '$name'. Run '$0 config' first."

  _write_unit "$name" "$mp"
  systemctl --user daemon-reload
  systemctl --user enable "$(_unit_name "$name")"
  echo "Enabled systemd/User service: $(_unit_name "$name")"
  systemctl --user start "$(_unit_name "$name")"
  echo "Started systemd/User service: $(_unit_name "$name")"
  echo "Waiting 5 seconds before checking status ..."
  sleep 5
  systemctl --user status "$(_unit_name "$name")"
}

_cmd_disable() {
  _need systemctl
  local name="${1:-}"; [[ -n "$name" ]] || _die "Usage: $0 disable <name>"
  local unit="$(_unit_name "$name")"
  systemctl --user disable "$unit" || true
  systemctl --user stop "$unit" || true
  systemctl --user daemon-reload
  systemctl --user status "$unit" || true
  echo
  echo "Disabled service: $unit."
  echo "Stopped service: $unit"
}

_cmd_uninstall() {
  _need systemctl
  local name="${1:-}"; [[ -n "$name" ]] || _die "Usage: $0 uninstall <name>"
  local unit="$(_unit_name "$name")"
  systemctl --user disable "$unit" || true
  systemctl --user stop "$unit" || true
  rm -f "${SYSTEMD_USER_DIR}/${unit}"
  systemctl --user daemon-reload
  systemctl --user status "$unit" || true
  echo
  echo "Removed ${unit}."
}

_cmd_status() {
  _need systemctl
  local name="${1:-}"; [[ -n "$name" ]] || _die "Usage: $0 status <name>"
  systemctl --user status "$(_unit_name "$name")"
}

_cmd_log() {
  _need journalctl
  local name="${1:-}"; [[ -n "$name" ]] || _die "Usage: $0 log <name>"
  journalctl --user -u "$(_unit_name "$name")"
}

# === Main ===
cmd="${1:-help}"
case "$cmd" in
  help|-h|--help) _print_help ;;
  config) shift; _cmd_config "$@" ;;
  mount) shift; _cmd_mount "$@" ;;
  enable) shift; _cmd_enable "$@" ;;
  disable) shift; _cmd_disable "$@" ;;
  uninstall) shift; _cmd_uninstall "$@" ;;
  status) shift; _cmd_status "$@" ;;
  log|logs) shift; _cmd_log "$@" ;;
  *) _die "Unknown command: $cmd. Try '$0 help'." ;;
esac


You can discuss this blog on Matrix (Element): #blog-rymcg-tech:enigmacurry.com

This blog is copyright EnigmaCurry and dual-licensed CC-BY-SA and MIT. The source is on github: enigmacurry/blog.rymcg.tech and PRs are welcome. ❤️