It’s intended mainly to document setup of opencloud connected via authelia provider with lldap as ldap backend. So this is free list of steps to be done to have it working. As a domain here I use auth.tomho.eu for authelia authentication, cloud.tomho.eu for opencloud access and cal.tomho.eu for calendar. You should change it to your domain.

This comes with no guarantee - it may contain errors and is meant mainly as inspiration, not a fully tested guide.

All services will run under dedicated podman user using quadlets definition files. I use debian trixie actually so all commands will be tested against it.

Prepare user under which services will run and directory structure

Add dedicated user(-m for creating home dir):

useradd -m opencloud

Set some password:

passwd opencloud

And set enable-linger so services can run after logout:

loginctl enable-linger opencloud

Now login under the new user. 17170 is used by lldap and via mapping we can manage it from our computer. User needs to be in AllowUsers in /etc/ssh/sshd_config to be able to logon remotely.

ssh -L 17170:localhost:17170 -p <when using nonstandart port> 'opencloud@tomho.eu'

Create all needed directories:

mkdir -p .config/containers/systemd
mkdir -p containers/authelia/config
mkdir -p containers/authelia/data
mkdir -p containers/lldap/data
mkdir -p containers/opencloud/apps
mkdir -p containers/opencloud/config
mkdir -p containers/opencloud/data
mkdir -p containers/proxysthesis/config
mkdir -p containers/radicale/config
mkdir -p containers/radicale/data

Add podman network which will be shared among all containers

Add quadlet definiton of network open into .config/containers/systemd/opencloud.network:

[Network]
NetworkName=opencloud

Register and start the network now, so it’s ready for every container that will join it:

systemctl --user daemon-reload
systemctl --user start opencloud-network.service

Setup lldap service

Generate secrets

Generate secrets used by lldap container(- means read from input):

openssl rand -hex 64 | podman secret create lldap-key-seed -
openssl rand -hex 64 | podman secret create lldap-jwt-secret -
openssl rand -hex 64 | podman secret create lldap-ldap-user-pass -

Quadlet definition

Add quadlet definition of lldap into .config/containers/systemd/lldap.container:

[Unit]
Description=LLDAP

[Service]
Restart=on-failure

[Container]
AutoUpdate=registry
ContainerName=lldap
Image=ghcr.io/lldap/lldap:latest

Network=opencloud.network

# environment variables
Environment=UID=1000
Environment=GID=1000
Environment=LLDAP_LDAP_BASE_DN=dc=tomho,dc=eu
Environment=LLDAP_LDAP_USER_EMAIL=tomashorak@post.cz

# set secrets
Environment=LLDAP_KEY_SEED_FILE=/run/secrets/lldap-key-seed
Environment=LLDAP_JWT_SECRET_FILE=/run/secrets/lldap-jwt-secret
Environment=LLDAP_LDAP_USER_PASS_FILE=/run/secrets/lldap-ldap-user-pass

# health check
HealthCmd=/app/lldap healthcheck
HealthInterval=30s
HealthRetries=3
HealthStartPeriod=15s
HealthTimeout=30s

# secrets - file based
Secret=lldap-jwt-secret,type=mount,uid=1000
Secret=lldap-key-seed,type=mount,uid=1000
Secret=lldap-ldap-user-pass,type=mount,uid=1000

Volume=%h/containers/lldap/data:/data:U,Z
PublishPort=127.0.0.1:17170:17170
PublishPort=127.0.0.1:3890:3890

# Bootstrapping. Uncomment the remainder of this section. Once
# the bootstrap files are ready. re-comment once bootstrapping is done.
# Secret=lldap-ldap-user-pass,type=env,target=LLDAP_ADMIN_PASSWORD
# Environment=LLDAP_URL=http://localhost:17170
# Environment=LLDAP_ADMIN_USERNAME=admin
# Environment=LLDAP_ADMIN_PASSWORD_FILE=/run/secrets/lldap-ldap-user-pass
# Volume=%h/containers/lldap:/bootstrap:ro,Z

[Install]
WantedBy=default.target

Start service

Uncomment bootstrapping section and run it first time:

systemctl --user daemon-reload
systemctl --user start lldap.service

Then stop the service, comment it out again, and repeat the steps above. Or you can just leave it commented, connect to admin console and create admin account directly.

Retrieve the generated password

You can use your own password for lldap-ldap-user-pass or any other instead of running rand. In case you didn’t use it and need to know generated one, attach to running container and cat it:

podman exec -it lldap /bin/sh
cat /run/secrets/lldap-ldap-user-pass

You can access lldap admin console now via http://localhost:17170(mapped via ssh -L param).

Setup authelia service

Quadlet definition

Add quadlet definition of authelia into .config/containers/systemd/authelia.container:

[Unit]
Description=Authelia

[Service]
Restart=on-failure

[Container]
AutoUpdate=registry
ContainerName=authelia
Image=docker.io/authelia/authelia:latest

Network=opencloud.network

# Match the user timezone so logs match your local system
Environment=TZ=Europe/Prague

Volume=%h/containers/authelia/config:/config:U,Z
Volume=%h/containers/authelia/data:/var/lib/authelia:U,Z
PublishPort=9091:9091

[Install]
WantedBy=default.target

Configuration file

Add configuration into containers/authelia/config/configuration.yml:

###############################################################
#                 Authelia Core Configuration                 #
###############################################################

server:
  address: 'tcp://0.0.0.0:9091'

  endpoints:
    authz:
      forward-auth:
        implementation: 'ForwardAuth'

#log:  
#  level: 'trace'

storage:
  local:
    path: '/var/lib/authelia/db.sqlite3'
  encryption_key: 'replace f.e. with openssl rand -hex 64'

authentication_backend:
  ldap:
    implementation: 'lldap'
    address: 'ldap://lldap:3890'
    base_dn: 'DC=tomho,DC=eu'
    user: 'UID=authelia,OU=people,DC=tomho,DC=eu'
    password: 'replace with real user password'  

identity_validation:
  reset_password:
    jwt_secret: 'replace f.e. with openssl rand -hex 64'
    

# Intercepts reset requests and puts them in a text file instead of sending mail
notifier:
  filesystem:
    filename: '/config/notification.txt'

# Access parameters
access_control:
  default_policy: 'deny'
  rules:
    - domain: 'auth.tomho.eu'
      policy: 'bypass'
    - domain: 'cloud.tomho.eu'
      policy: 'one_factor'
    - domain: 'cal.tomho.eu'
      policy: 'one_factor'

# Domain Session configuration
session:
  name: 'authelia_session'
  secret: 'replace f.e. with openssl rand -hex 64'
  same_site: 'lax'
  expiration: '1h'
  inactivity: '5m'
  remember_me: '1M'
  cookies:
    - domain: 'tomho.eu'
      authelia_url: 'https://auth.tomho.eu'

identity_providers:
  oidc:
    lifespans:
      refresh_token: '24 hours'
      access_token: '1 hour'
      id_token: '1 hour'
    cors:
      endpoints:
        - authorization
        - token
        - revocation
        - introspection
        - userinfo
      allowed_origins:
        - https://auth.tomho.eu
        - https://cloud.tomho.eu
        - https://cal.tomho.eu
      allowed_origins_from_client_redirect_uris: true
    hmac_secret: 'replace f.e. with openssl rand -hex 64'
    jwks:
      - key_id: "authelia"
        algorithm: "RS256"
        use: "sig"
        key: |
          # replace with openssl genrsa 2048

    claims_policies:
         
      opencloud_policy:
        id_token:
          - preferred_username
          - name
          - email
          - email_verified
          - groups
        access_token:
          - preferred_username
          - name
          - email
          - email_verified
          - groups
        userinfo:
          - preferred_username
          - name
          - email
          - email_verified
          - groups
    
    clients:
      # ── OpenCloud web app ─────────────────────────────────────────────────
      - client_id: opencloud
        client_name: OpenCloud
        public: true
        claims_policy: default_policy
        authorization_policy: one_factor
        access_token_signed_response_alg: RS256
        grant_types:
          - authorization_code
        response_types:
          - code
        redirect_uris:
          - https://cloud.tomho.eu/oidc-callback.html
          - https://cloud.tomho.eu/oidc-silent-redirect.html
        scopes:
          - openid
          - profile
          - email
          - groups
        claims_policy: opencloud_policy
      # ────────────────────────────────────────────────────────────────────────────

      # ── OpenCloud Android app ─────────────────────────────────────────────────
      - client_id: OpenCloudAndroid
        client_name: OpenCloud Android
        public: true
        authorization_policy: one_factor
        access_token_signed_response_alg: RS256
        id_token_signed_response_alg: RS256
        audience:
          - OpenCloudAndroid
        grant_types:
          - authorization_code
          - refresh_token
        response_types:
          - code
        redirect_uris:
          - oc://android.opencloud.eu
        scopes:
          - openid
          - profile
          - email
          - offline_access
          - groups
        claims_policy: opencloud_policy
      # ────────────────────────────────────────────────────────────────────────────

      # ── OpenCloud Desktop client ──────────────────────────────────────────────
      - client_id: OpenCloudDesktop
        client_name: OpenCloud Desktop
        public: true
        claims_policy: default_policy
        authorization_policy: one_factor
        access_token_signed_response_alg: RS256
        grant_types:
          - authorization_code
          - refresh_token
        response_types:
          - code
        redirect_uris:
          - http://127.0.0.1
          - http://localhost
        scopes:
          - openid
          - profile
          - email
          - offline_access
          - groups
        claims_policy: opencloud_policy

      # ── OpenCloud iOS app ─────────────────────────────────────────────────────
      - client_id: OpenCloudIOS
        client_name: OpenCloud iOS
        public: true
        claims_policy: default_policy
        authorization_policy: one_factor
        access_token_signed_response_alg: RS256
        grant_types:
          - authorization_code
          - refresh_token
        response_types:
          - code
        redirect_uris:
          - oc://ios.opencloud.eu
        scopes:
          - openid
          - profile
          - email
          - offline_access
          - groups
        claims_policy: opencloud_policy


# Disable the external network time synchronization checks
ntp:
  disable_startup_check: true
  disable_failure: true          

Start service

Start and stop authelia service:

systemctl --user daemon-reload
systemctl --user start authelia.service
systemctl --user stop authelia.service

Setup proxysthesis service

You can see sources on https://github.com/tomas303/proxysthesis and I have created image which just wraps this one standalone binary. Just adds groups into scope parameter if is missing. Mobile apps just don’t put it there and without it is impossible to use oidc login.

Quadlet definition

Add quadlet definition of proxysthesis into .config/containers/systemd/proxysthesis.container:

[Unit]
Description=Proxysthesis (groups scope manage)

[Service]
Restart=on-failure

[Container]
AutoUpdate=registry
ContainerName=proxysthesis
Image=ghcr.io/tomas303/proxysthesis:latest

Network=opencloud.network

Environment=TZ=Europe/Prague

Volume=%h/containers/proxysthesis/config:/config:U,Z
PublishPort=8831:8831

AddHost=auth.tomho.eu:host-gateway

[Install]
WantedBy=default.target

Configuration file

Add configuration into ./containers/proxysthesis/config/config.yaml:

listen: ":8831"
upstream: "http://auth.tomho.eu:9091"
debug_traffic: false

Start service

Start proxysthesis service:

systemctl --user daemon-reload
systemctl --user start proxysthesis.service

Setup opencloud service

Generate secrets

First prepare two secrets - oidc is brand new so generate as usual, second one is password to lldap, so this can be done by echo, but don’t forget -n switch, which suppresses common linebreak generation:

openssl rand -hex 64 | podman secret create opencloud-proxy-oidc-client-secret -
echo -n 'your lldap password'  | podman secret create opencloud-oc-ldap-bind-password-secret -

Initialize configuration

Next we have to pull opencloud’s image and initialize configuration:

podman pull docker.io/opencloudeu/opencloud-rolling:latest
podman run --rm -it \
     -v /home/opencloud/containers/opencloud/config:/etc/opencloud \
     -v /home/opencloud/containers/opencloud/data:/var/lib/opencloud \     
     -e IDM_ADMIN_PASSWORD=<replace f.e. with openssl rand -hex 64> \
     opencloudeu/opencloud-rolling:latest init

Quadlet definition

Add quadlet definition of opencloud into .config/containers/systemd/opencloud.container:

[Unit]
Description=open cloud 

[Service]
Restart=on-failure

[Container]
AutoUpdate=registry
ContainerName=opencloud
Image=docker.io/opencloudeu/opencloud-rolling:latest

Network=opencloud.network
AddHost=cloud.tomho.eu:host-gateway
AddHost=auth.tomho.eu:host-gateway

Environment=OC_URL=https://cloud.tomho.eu
Environment=OC_INSECURE=false
Environment=OC_OIDC_ISSUER=https://auth.tomho.eu


# authelia
Environment=PROXY_TLS_EXTERNAL=https://cloud.tomho.eu
Environment=PROXY_OIDC_ISSUER=https://auth.tomho.eu
Environment=PROXY_OIDC_CLIENT_ID=opencloud
Secret=opencloud-proxy-oidc-client-secret,type=env,target=PROXY_OIDC_CLIENT_SECRET
Environment=PROXY_AUTOPROVISION_ACCOUNTS=false
Environment=PROXY_ROLE_ASSIGNMENT_DRIVER=oidc

Environment=PROXY_OIDC_REWRITE_WELLKNOWN=true
Environment=WEB_OIDC_AUTHORITY=https://auth.tomho.eu
Environment=WEB_OIDC_CLIENT_ID=opencloud
Environment="WEB_OIDC_SCOPE=openid profile email groups"
Environment=PROXY_CSP_CONFIG_FILE_LOCATION=/etc/opencloud/csp.yaml

Environment=SETTINGS_SETUP_DEFAULT_ASSIGNMENTS="false"
Environment=GRAPH_ASSIGN_DEFAULT_USER_ROLE=false
Environment=GRAPH_USERNAME_MATCH=none
Environment=OC_EXCLUDE_RUN_SERVICES=idp

# OpenCloud LDAP configuration
Environment=OC_LDAP_URI=ldap://lldap:3890
Environment=OC_LDAP_SERVER_WRITE_ENABLED=false
Environment=FRONTEND_READONLY_USER_ATTRIBUTES="user.onPremisesSamAccountName,user.displayName,user.mail,user.passwordProfile,user.accountEnabled,user.appRoleAssignments"
Environment=OC_LDAP_INSECURE=true
Environment=OC_LDAP_BIND_DN=uid=authelia,ou=people,dc=tomho,dc=eu
Secret=opencloud-oc-ldap-bind-password-secret,type=env,target=OC_LDAP_BIND_PASSWORD
Environment=OC_LDAP_USER_BASE_DN=ou=people,dc=tomho,dc=eu
Environment=OC_LDAP_DISABLE_USER_MECHANISM=none
Environment=OC_LDAP_GROUP_BASE_DN=ou=groups,dc=tomho,dc=eu
Environment=GRAPH_LDAP_SERVER_UUID=true

# --- PROXY AUTHENTICATION ROUTING ---
Environment=PROXY_USER_OIDC_CLAIM=preferred_username
Environment=PROXY_USER_CS3_CLAIM=username

# --- GLOBAL LDAP SCHEMA CONFIGURATION ---
# Inform OpenCloud that users are tracked uniquely by their server UUID
Environment=OC_LDAP_USER_SCHEMA_ID=entryuuid
Environment=OC_LDAP_GROUP_SCHEMA_ID=cn

# Inform OpenCloud that the readable username lookup corresponds to 'uid'
Environment=OC_LDAP_USER_SCHEMA_USERNAME=uid

# --- GRAPH SERVICE SPECIFIC ALIGNMENT ---
# These parameters directly enforce the Shared User Directory specifications
Environment=GRAPH_LDAP_SERVER_UUID=true
Environment=GRAPH_LDAP_USER_SCHEMA_ID=entryuuid
Environment=GRAPH_LDAP_USER_SCHEMA_USERNAME=uid
Environment=GRAPH_LDAP_GROUP_SCHEMA_ID=cn
Environment=GRAPH_LDAP_GROUP_SCHEMA_NAME=cn

# Logs
# Environment=OC_LOG_LEVEL=debug
# Environment=PROXY_LOG_LEVEL=debug

# Persistent Volume Storage Mount on the Host
Volume=%h/containers/opencloud/config:/etc/opencloud:U,Z
Volume=%h/containers/opencloud/data:/var/lib/opencloud:U,Z
Volume=%h/containers/opencloud/apps:/var/lib/apps:U,Z

PublishPort=127.0.0.1:9200:9200

[Install]
WantedBy=default.target

Start service

Start opencloud service:

systemctl --user daemon-reload
systemctl --user start opencloud.service

Setup radicale service

This one will use the forward-auth example from https://www.authelia.com/integration/proxies/caddy/?utm_source=chatgpt.com. Authelia’s authz endpoint was already configured in the authelia section above.

Quadlet definition

Add quadlet definition of radicale into .config/containers/systemd/radicale.container:

###
### radicale - podman quadlet file
### https://docs.podman.io/en/latest/markdown/podman-systemd.unit.5.html
###

[Unit]
Description=Radicale CalDAV/CardDAV server

[Service]
Restart=on-failure

[Container]
AutoUpdate=registry
ContainerName=radicale
Image=docker.io/tomsquest/docker-radicale:latest

Network=opencloud.network

Volume=%h/containers/radicale/config:/config:ro,U,Z
Volume=%h/containers/radicale/data:/data:U,Z

PublishPort=127.0.0.1:5232:5232


[Install]
WantedBy=default.target

Configuration file

Add radicale config containers/config/config:

[server]
hosts = 0.0.0.0:5232

[auth]
# Trust the "X-Remote-User" header set by the reverse proxy (Authelia
# forward-auth) instead of doing its own authentication.
# IMPORTANT: radicale must only be reachable through the proxy - the proxy
# has to strip any client-supplied X-Remote-User header and set it itself
# after Authelia validates the session, otherwise auth can be spoofed.
type = http_x_remote_user

[rights]
type = owner_only

[storage]
filesystem_folder = /data/collections

[logging]
level = info
mask_passwords = True

[web]
type = internal
prefer_browser_login = True

Start service

And start radicale service:

systemctl --user daemon-reload
systemctl --user start radicale.service

Configure Caddy reverse proxy

This Caddy configuration ties all the services together behind a single reverse proxy.

# The Caddyfile is an easy way to configure your Caddy web server.
#
# Unless the file starts with a global options block, the first
# uncommented line is always the address of your site.
#
# To use your own domain name (with automatic HTTPS), first make
# sure your domain's A/AAAA DNS records are properly pointed to
# this machine's public IP, then replace ":80" below with your
# domain name.

# Refer to the Caddy docs for more information:
# https://caddyserver.com/docs/caddyfile

(auth_headers_up) {
	header_up X-Real-IP {remote_host}
	header_up X-Forwarded-Scheme https
	header_up X-Forwarded-Port 443
}

(auth_inject_groups) {
	@missing_groups expression `{query}.contains("scope") && !{query.scope}.contains("groups")`
	uri @missing_groups query scope "{query.scope} groups"
	#respond "Test check uri: {http.request.uri}" 200 {
	#	close
	#}
}

# static web pages
tomho.eu, www.tomho.eu {
	root * /var/www/html
	file_server
}

# backup via restic
backup.tomho.eu {
	reverse_proxy http://localhost:8000 {
		header_up Authorization {http.request.header.Authorization}
	}
}

# opencloud
cloud.tomho.eu {
	reverse_proxy https://127.0.0.1:9200 {
		header_up Host {host}
		header_up X-Forwarded-Proto https

		transport http {
			tls_insecure_skip_verify
		}
	}
}

# auth provider authelia
auth.tomho.eu {
	# 8831 will add groups into scopes if missing - parameter aswell as body in case of token renewal
	handle /api/oidc/authorization {
		reverse_proxy http://127.0.0.1:8831 {
			import auth_headers_up
		}
	}
	handle /api/oidc/token {
		reverse_proxy http://127.0.0.1:8831 {
			import auth_headers_up
		}
	}
	handle {
		reverse_proxy http://127.0.0.1:9091 {
			import auth_headers_up
		}
	}
}

# radicale
cal.tomho.eu {
	forward_auth 127.0.0.1:9091 {
		uri /api/authz/forward-auth
		copy_headers Remote-User Remote-Groups Remote-Email Remote-Name
	}

	reverse_proxy http://127.0.0.1:5232 {
		header_up X-Remote-User {http.request.header.Remote-User}
	}
}

Tips

Generating passwords

rsa key:

openssl genrsa 2048

random password as hex:

openssl rand -hex 64

Restarting a failed service

If a quadlet-based service fails to start a few times in a row, systemd applies a rate limit and refuses further attempts, logging something like:

lldap.service: Start request repeated too quickly.
lldap.service: Failed with result 'exit-code'.

This message alone doesn’t tell you what actually went wrong, it just means the retry limit was hit. To find the real cause, check the service and container logs:

journalctl --user -u lldap.service -e --no-pager | tail -n 100
podman logs lldap 2>&1 | tail -n 100

Once you’ve fixed the underlying issue, clear the failed/rate-limited state and start the service again:

systemctl --user daemon-reload
systemctl --user reset-failed lldap.service
systemctl --user start lldap.service

lldap fixed schema people

Lldap has a fixed schema - all user accounts always live under ou=people, no matter what “group” you create through the lldap admin console (those are LDAP groups under ou=groups, not organizational units for accounts).

Mapping subdomains to the host gateway

Containers on the shared opencloud network resolve each other by container name (e.g. lldap, authelia), but sometimes a container needs to reach a service via its public subdomain instead - for example proxysthesis talking to Authelia through auth.tomho.eu rather than the internal authelia name. Since that subdomain only resolves on the host (or publicly), the container needs an extra host entry pointing it back to the host itself:

AddHost=auth.tomho.eu:host-gateway

host-gateway is a special value podman resolves to the IP of the host from inside the container, so the subdomain now reaches whatever is listening on the host (e.g. your reverse proxy) instead of failing to resolve or hitting the wrong endpoint. Just add one AddHost= line per subdomain you need mapped this way.

Setting up a GitHub image repo (GHCR) for your own image

GitHub Container Registry (ghcr.io) can host your own small images (like proxysthesis), so quadlets can pull them via Image=ghcr.io/<user>/<image>:latest just like the lldap image above.

Create a Personal Access Token (classic) with write:packages scope, then login:

echo $GH_TOKEN | podman login ghcr.io -u <your-github-username> --password-stdin

A locally built image (e.g. localhost/proxysthesis:latest) can’t be pushed directly, it first needs to be tagged with the full ghcr.io reference so podman knows under which name to publish it:

podman tag localhost/proxysthesis:latest ghcr.io/<your-github-username>/proxysthesis:latest
podman push ghcr.io/<your-github-username>/proxysthesis:latest

By default the pushed package is private - make it public in the package settings on GitHub if you want anonymous pulls, otherwise use a read-only token on the podman host when pulling.