Proxmox Unprivelliged LXC with shared Intel ARC GPU for Plex or Jellyfin transcoding

Status: CURRENT
Last reviewed: 31 August 2026
Applies to: Proxmox VE 8.2+ / 9.x, unprivileged LXC, Intel Arc GPU
Example LXC: Debian Trixie

This guide originally used manual cgroup, UID/GID, subuid and subgid mappings. Modern Proxmox has native LXC Device Passthrough, so the current method is much simpler.

This guide is for running Plex and/or Jellyfin in Docker inside an unprivileged Proxmox LXC, while sharing an Intel Arc GPU from the Proxmox host for hardware transcoding.

I’m using a Debian Trixie LXC for this example. This assumes you have:

  • Created an unprivileged LXC (Proxmox instructions)
  • Installed Docker inside the LXC (Docker instructions)
  • An Intel Arc GPU detected and working on the Proxmox host
  • An active Plex Pass subscription if you want to use Plex hardware transcoding

Find the Intel Arc render device

On the Proxmox host, check the available render devices:

ls -l /dev/dri/render*


Example output from my host:

crw-rw---- 1 root render 226, 128 Nov 16 21:02 /dev/dri/renderD128
crw-rw---- 1 root render 226, 129 Nov 16 21:02 /dev/dri/renderD129


In my system, renderD128 is the Intel iGPU and renderD129 is the Intel Arc A310. Your device numbers may be different, so don’t blindly copy mine.

If you’re not sure which render device belongs to which GPU, check the DRI device links:

ls -l /dev/dri/by-path/


You can compare the PCI addresses shown there with:

lspci | grep -Ei 'VGA|Display'


Pass the Arc GPU into the LXC

This is the bit that became much easier in newer Proxmox versions.

In the Proxmox web interface, select the LXC and go to:

Resources → Add → Device Passthrough

  • Device Path: select your Intel Arc render device. Mine is /dev/dri/renderD129
  • Mode: 0666
  • UID: leave blank
  • GID: leave blank

For my system, Proxmox creates an LXC config entry equivalent to:

dev0: /dev/dri/renderD129,mode=0666


That’s it. No manual lxc.idmap entries, no editing /etc/subuid or /etc/subgid, and no manually-created cgroup or bind-mount rules.

Why 0666?
This gives processes inside the LXC read/write access to the passed-through render device, which avoids the UID/GID mapping mess used by older methods. For a dedicated unprivileged Plex/Jellyfin LXC this is the simple option. If you want tighter permissions, Proxmox also supports assigning a specific UID/GID to the device instead.

Restart the LXC after adding the device, then check from inside the LXC:

ls -l /dev/dri/


You should see the render device you passed through. In my case that is renderD129.

Docker Compose

The Arc render device also needs to be passed from the LXC into the Plex or Jellyfin Docker container.

Add the following to the Plex or Jellyfin service in your Compose file, changing the render device if yours is different:

devices:
  - /dev/dri/renderD129:/dev/dri/renderD129


If your Arc GPU is renderD128, use /dev/dri/renderD128:/dev/dri/renderD128 instead.

After recreating the Docker container, you can confirm the device is visible inside it:

docker exec -it <container-name> ls -l /dev/dri/


Plex

In Plex, go to Settings → Server → Transcoder and enable Show Advanced if required.

  • Enable Use hardware acceleration when available
  • Enable Use hardware-accelerated video encoding
  • Under Hardware transcoding device, select the Intel Arc GPU

Mine appears as Intel DG2 [Arc A310].

To test it, play a video and force a transcode by changing the quality to something lower. Then go to Activity → Dashboard. The video transcode should show the hardware (hw) tag.

If the Arc GPU doesn’t appear in Plex, check the device in this order:

  1. Proxmox host: ls -l /dev/dri/
  2. Inside the LXC: ls -l /dev/dri/
  3. Inside the Docker container: docker exec -it <container-name> ls -l /dev/dri/

Note: Plex hardware-accelerated transcoding requires an active Plex Pass subscription.

Plex hardware transcoding using Intel Arc

Jellyfin

For Jellyfin, open the Administration Dashboard, then go to Playback → Transcoding.

  • Under Hardware Acceleration, select Intel Quick Sync (QSV)
  • Set the QSV device to the Intel Arc render device. Mine is /dev/dri/renderD129

If the Arc GPU is the only Intel GPU in your system it will often be /dev/dri/renderD128. Again, use the device you identified earlier rather than assuming the number.

To test Jellyfin, play a video, lower the playback quality so it has to transcode, then open Playback Info. Look for Play method: Transcoding.

If transcoding fails completely, first confirm that the render device exists inside both the LXC and the Jellyfin Docker container. Jellyfin’s Intel hardware acceleration documentation also includes additional QSV/VA-API and driver checks if required:

Jellyfin Intel GPU hardware acceleration documentation

Jellyfin Intel Quick Sync transcoding settings

Verify the GPU is actually being used

If you want to confirm the GPU itself is doing the work, install intel-gpu-tools on the Proxmox host and run intel_gpu_top while a transcode is active. You should see activity on the Intel GPU’s video engines.

Archived method: manual UID/GID mapping

ARCHIVED – November 2025 method

The original version of this guide manually added lxc.cgroup2.devices.allow, bind-mounted the DRI devices, created custom lxc.idmap rules and edited /etc/subuid and /etc/subgid.

That method worked, but it is no longer the recommended path for a current Proxmox install. Native LXC Device Passthrough creates the device node for the container and can assign its mode, UID and GID directly, so the old mapping instructions have been removed from the active guide to avoid unnecessary complexity.

If you’re deliberately running an older Proxmox release, use documentation appropriate for that version rather than copying the old mappings from this article.

Credit: Thanks to gnd-7000 in the comments for pointing out that Proxmox 9 no longer needs the old GID mapping method and that the GPU can be added directly through Resources → Device Passthrough. Much cleaner 🙂

References: Proxmox pct documentation · Jellyfin Intel GPU documentation · Plex Transcoder documentation

Portainer and Authentik / Traefik failling to login due to UFW

Status: CURRENT — ENVIRONMENT-SPECIFIC FIX
Last reviewed: 31 August 2026
Applies to: Portainer + Authentik OAuth/OIDC behind Traefik with UFW enabled

This was the fix for my setup. If disabling UFW immediately makes the Authentik login work, the firewall path is involved. Docker and UFW interact in slightly weird ways though, so don’t assume every Docker host will behave exactly the same.

I use Authentik with Traefik for SSO, and Portainer authenticates against Authentik using OAuth/OIDC.

Authentik still has a Portainer integration guide, although that page still notes it was originally based on Authentik 2021.7.3 and Portainer 2.6.x. Portainer’s current documentation still supports a Custom OAuth provider.

This post isn’t another full Portainer/Authenik setup guide. It’s for the stupid problem I hit after the OAuth configuration looked correct.

The problem

When I tried logging into Portainer through Authentik, the login failed. Inspecting the browser console showed a request returning 404, which eventually resulted in a 401 Unauthorized.

Portainer Authentik login failure
Browser console showing the authentication request error

Portainer, Authentik and Traefik were already on the expected Docker networks and changing the Traefik configuration wasn’t getting me anywhere.

The thing that finally exposed the problem was testing with UFW disabled.

Check UFW first

Before changing anything, check the current firewall state:

sudo ufw status verbose


If Traefik is supposed to be reachable over HTTPS but there is no rule allowing the traffic you expect, that is worth investigating before ripping apart your Authentik configuration.

Temporarily disable UFW as a test

Diagnostic test only: disabling the firewall removes protection from the host while it is off. Don’t leave UFW disabled as the fix.

Temporarily disable UFW:

sudo ufw disable


Now try the Portainer → Authentik login again.

In my case it immediately worked. That told me the OAuth configuration itself wasn’t the problem — traffic involved in the HTTPS authentication flow was being blocked by my firewall setup.

Why being on the same Docker network didn’t save it

This caught me out because Portainer, Authentik and Traefik were on Docker networks together.

OAuth/OIDC isn’t necessarily just container-to-container traffic. Your browser is redirected between the Portainer and Authentik HTTPS URLs, and the callback returns through those hostnames. If the HTTPS path to Traefik is broken, the authentication flow can fail even though the containers themselves can communicate internally.

Docker + UFW warning: Docker’s normal published ports are implemented with Docker-managed firewall/NAT rules, and Docker documents that published container ports can bypass the normal UFW INPUT/OUTPUT rules.

Because of that, ufw allow 443 is not universally required for every Docker-published Traefik instance. If toggling UFW changes the result on your host, inspect how Traefik exposes 80/443 and any custom forwarding/firewall rules rather than assuming this exact fix applies everywhere.

Check how Traefik is listening

A quick check on the Docker host:

sudo ss -lntp | grep -E ':(80|443)\b'


You can also check Docker’s published ports:

docker ps --format 'table {{.Names}}\t{{.Ports}}'


This helps work out whether Traefik is listening directly on the host, using Docker port publishing, host networking, or something more customised.

The fix on my host

Once I confirmed UFW was involved, I re-enabled it and allowed HTTP/HTTPS for Traefik.

If you’re connected to the server remotely over SSH, make sure SSH is already allowed before enabling UFW or you can lock yourself out.

sudo ufw allow OpenSSH
sudo ufw allow 80/tcp comment 'Allow HTTP for Traefik'
sudo ufw allow 443/tcp comment 'Allow HTTPS for Traefik'
sudo ufw enable
sudo ufw reload


Then confirm the rules:

sudo ufw status numbered


After that, the Portainer login through Authentik worked normally again.

Do you actually need port 80?

Not necessarily. If your Traefik setup only needs HTTPS and you aren’t using port 80 for an HTTP → HTTPS redirect or an ACME HTTP challenge, then you may only need:

sudo ufw allow 443/tcp comment 'Allow HTTPS for Traefik'


Open only what your setup actually needs.

If disabling UFW does NOT fix it

Then stop blaming UFW 😅 and go back through the OAuth flow.

  • Confirm the Portainer redirect URL exactly matches the redirect URI configured in Authentik.
  • Confirm Portainer’s Authorization, Access Token and Resource/UserInfo URLs point at the correct Authentik instance.
  • Check the Client ID and Client Secret.
  • Portainer’s OAuth scopes should be space-separated, not comma-separated.
  • Confirm both the Portainer and Authentik hostnames are reachable from the browser performing the login.
  • Check Traefik and Authentik logs while reproducing the login.

Portainer’s current documentation still supports Settings → Authentication → OAuth → Custom, so the general Authentik-as-OIDC-provider design remains valid.

Summary

For me, the giveaway was simple:

UFW enabled  → Authentik login fails
UFW disabled → Authentik login works


That narrowed the problem from “something is broken in Authentik/Portainer/Traefik” to “the firewall path is involved”, which was a hell of a lot easier to troubleshoot.

Just remember that Docker’s own firewall rules can bypass UFW for normally published container ports, so treat this as a troubleshooting method and my specific fix rather than a universal Docker rule.

References

Proxmox cluster with Traefik

Status: CURRENT
Last reviewed: 31 August 2026
Applies to: Proxmox VE 8/9 cluster + Traefik v3.x

Traefik can sit in front of multiple Proxmox nodes so a single hostname such as proxmox.richay.au reaches whichever cluster node is available. Modern Traefik supports Proxmox shell/noVNC WebSockets without a special WebSocket middleware.

Why put a Proxmox cluster behind Traefik?

Each node in a Proxmox cluster provides the web interface, and Proxmox cluster management can be performed by connecting to any cluster node.

Instead of remembering:

https://10.10.10.1:8006
https://10.10.10.2:8006
https://10.10.10.3:8006


I can use one address:

https://proxmox.richay.au


Traefik then load balances the browser traffic between the available nodes.

Security: Proxmox is a management interface with control over your VMs, containers, storage and cluster. I recommend keeping this hostname private rather than publishing it to the open internet.

I use Tailscale Split DNS for this type of service so proxmox.richay.au resolves only while I’m at home or connected to Tailscale. My guide is here: Tailscale Split DNS by Domain for Secure Home Server Access.

The old WebSocket workaround is no longer needed

The original version of this article added these request headers manually:

Upgrade: websocket
Connection: Upgrade


That is no longer required with current Traefik. Traefik v3 supports WebSocket and WebSocket Secure connections out of the box and automatically handles the protocol upgrade using normal HTTP routing.

So there is no need for a custom websocket-upgrade middleware just to make the Proxmox shell or noVNC console work.

Current Traefik dynamic configuration

This is the modern equivalent of my original config.yaml.

This example assumes your Proxmox nodes are using their normal cluster-generated HTTPS certificates and Traefik is connecting to them by IP address. Because those certificates are normally signed by the private Proxmox cluster CA, the example uses a dedicated ServersTransport with certificate verification disabled.

http:
  routers:
    proxmox:
      entryPoints:
        - "https"
      rule: "Host(`proxmox.richay.au`)"
      service: proxmox
      tls: {}

  services:
    proxmox:
      loadBalancer:
        serversTransport: proxmox-transport

        sticky:
          cookie:
            name: proxmox_lb
            secure: true
            httpOnly: true
            sameSite: lax

        servers:
          - url: "https://10.10.10.1:8006" # Proxmox node 1
          - url: "https://10.10.10.2:8006" # Proxmox node 2
          - url: "https://10.10.10.3:8006" # Proxmox node 3

  serversTransports:
    proxmox-transport:
      insecureSkipVerify: true


That’s it. No manual WebSocket headers.

Why use a sticky cookie?

Traefik’s load balancer normally distributes requests between the backend servers. Enabling a sticky cookie keeps subsequent requests from the same browser session on the same Proxmox node where possible.

For a normal website that may not matter much, but for a management UI with long-running shell/noVNC connections I prefer keeping the browser pinned to one node during the session.

The example also marks Traefik’s affinity cookie as Secure and HTTPOnly.

About insecureSkipVerify

insecureSkipVerify: true does not disable HTTPS. Traffic between Traefik and Proxmox is still encrypted, but Traefik does not verify that the backend certificate is trusted and belongs to the expected server.

That’s convenient for a trusted home management network, but it is weaker than actually trusting the Proxmox cluster CA.

Proxmox creates its own cluster CA by default and generates a node certificate for each node. The public cluster CA is stored at:

/etc/pve/pve-root-ca.pem


If you want proper backend certificate verification, copy only the public CA certificate to the Traefik host/container and use node hostnames that match the certificates.

For example:

http:
  services:
    proxmox:
      loadBalancer:
        serversTransport: proxmox-trusted
        sticky:
          cookie:
            name: proxmox_lb
            secure: true
            httpOnly: true

        # Use the actual DNS names present in your node certificates.
        servers:
          - url: "https://pve1.home.arpa:8006"
          - url: "https://pve2.home.arpa:8006"
          - url: "https://pve3.home.arpa:8006"

  serversTransports:
    proxmox-trusted:
      rootCAs:
        - /etc/traefik/certs/pve-root-ca.pem


This is the better option if you want end-to-end TLS verification rather than simply trusting the management LAN.

Do not copy the Proxmox cluster CA private key to Traefik. The public pve-root-ca.pem certificate is all Traefik needs to trust the cluster certificates.

HTTPS redirect

My old dynamic configuration attached an HTTPS redirect middleware to the router even though that router was already listening on the HTTPS entrypoint. That’s redundant.

If you want every request hitting port 80 to redirect to HTTPS, I prefer doing that once on the HTTP entrypoint in Traefik’s static/install configuration:

entryPoints:
  http:
    address: ":80"
    http:
      redirections:
        entryPoint:
          to: https
          scheme: https

  https:
    address: ":443" 


Then every service can use the HTTPS entrypoint without carrying around its own redirect middleware.

Testing it

Open the single cluster hostname:

https://proxmox.richay.au


Log into Proxmox and test:

  • Normal cluster navigation.
  • A node shell.
  • An LXC console.
  • A VM noVNC console.

They should work through the same Traefik route without any WebSocket-specific middleware.

If the normal web UI loads but consoles fail, check the browser developer tools for the WebSocket request and check the Traefik logs. Don’t immediately add fake Upgrade headers back in — modern Traefik already handles that part.

What happens when a node goes down?

Traefik can distribute requests across multiple Proxmox nodes, but this alone is not a full HA design. If the node your sticky cookie points to disappears, Traefik can select another backend when the failed backend is detected, but an existing shell/noVNC connection to the dead node will obviously be lost.

The reverse proxy gives you one convenient cluster URL. Proxmox clustering/HA is still what handles the actual guests and cluster state.

Archived note from the original article

ARCHIVED — old Traefik WebSocket middleware

The original article used a websocket-upgrade Headers middleware with Upgrade: websocket and Connection: Upgrade. That workaround has been removed from the active configuration because current Traefik handles WebSocket upgrades automatically.

References

Much cleaner now — one cluster URL, sticky sessions, working shells, and no janky WebSocket header hack required 🙂

Authentik and Home Assistant with working Companion App solution using Traefik proxy

Status: CURRENT — MAJOR 2026 UPDATE
Last reviewed: 31 August 2026
Applies to: Home Assistant, Authentik and Traefik with the Home Assistant Companion App

The original two-hostname workaround in this article still explains a real problem, but the component it relied on has now been archived. In 2026 I recommend authenticating Home Assistant directly with Authentik over OIDC instead of putting Authentik Forward Auth in front of Home Assistant.

I absolutely love logging into Authentik once and getting SSO across the homelab. Home Assistant used to be one of the awkward exceptions.

The original version of this article solved that by creating two Traefik routes:

  • A browser hostname protected by Authentik Forward Auth, using hass-auth-header to log the user into Home Assistant.
  • A second hostname that bypassed Authentik and showed Home Assistant’s normal login page so the Companion App could authenticate.

That workaround worked, and judging by the traffic/comments it helped quite a few people. But the Home Assistant authentication landscape has changed enough that I wouldn’t build a fresh setup that way now.

What changed?

The BeryJu/hass-auth-header component used by the original guide was archived on 23 October 2025. Its maintainer specifically points users toward newer Home Assistant OIDC integrations instead.

A maintained community project called OpenID Connect for Home Assistant now lets Home Assistant itself authenticate against Authentik using standard OIDC.

That means Authentik no longer needs to sit in front of Home Assistant as a Forward Auth middleware. Traefik just proxies Home Assistant normally, and Home Assistant performs the Authentik login itself.

Why this is better for the Companion App: the maintained OIDC integration is designed to handle Home Assistant login in both the web interface and Companion App. Its maintainer specifically advises removing reverse-proxy-level authentication when migrating from header-auth setups, because an extra authentication layer in front of Home Assistant can interfere with the app’s login flow.

Recommended 2026 setup

The new layout is much simpler:

Browser / Companion App
          ↓
https://home-assistant.richay.au
          ↓
       Traefik
          ↓
   Home Assistant
          ↓
 Authentik via OIDC


There is only one Home Assistant hostname, and there is no Authentik Forward Auth middleware in front of it.

1. Keep Traefik as a normal reverse proxy

Current Traefik does not need any special configuration for Home Assistant’s WebSocket traffic. A normal HTTPS router is enough.

Home Assistant changed its HTTP defaults in 2026.8:

  • Home Assistant OS: new default HTTP server port is 80.
  • Home Assistant Container: default remains 8123.
  • If you’ve manually configured another port, use whatever is shown under Settings → System → Network → HTTP server.

For a current Home Assistant OS install using port 80, my Traefik dynamic config would look roughly like this:

http:
  routers:
    home-assistant:
      entryPoints:
        - "https"
      rule: "Host(`home-assistant.richay.au`)"
      tls: {}
      service: home-assistant

  services:
    home-assistant:
      loadBalancer:
        servers:
          - url: "http://10.10.10.10:80" # Home Assistant OS 2026.8+ default


If you’re running Home Assistant Container, or your HAOS instance still uses the old/custom port, change the backend to:

url: "http://10.10.10.10:8123"


There is deliberately no Authentik middleware on this router. OIDC happens inside Home Assistant.

2. Configure Home Assistant to trust Traefik

Another 2026 change: from Home Assistant 2026.8, the HTTP server/reverse-proxy settings moved out of configuration.yaml and into the UI.

Go to:

Settings → System → Network → HTTP server


Enable Trust X-Forwarded-For and add Traefik under Trusted proxies.

If Traefik reaches Home Assistant from a Docker network such as 172.22.0.0/16, that is the network you would trust.

Don’t blindly trust your entire home LAN. The old version of this article included the whole 10.10.10.0/24 home network as a trusted proxy. Home Assistant’s setting is specifically for reverse proxies that are allowed to supply X-Forwarded-For. Trust only the Traefik IP/network that actually proxies the request.

If you’re still running a Home Assistant release before 2026.8, the equivalent old YAML was:

http:
  use_x_forwarded_for: true
  trusted_proxies:
    - 172.22.0.0/16 # Replace with your Traefik proxy network


3. Install the Home Assistant OIDC integration

Install OpenID Connect for Home Assistant through HACS.

After installing and restarting Home Assistant, go to:

Settings → Devices & Services → Add Integration → OpenID Connect/SSO Authentication


The integration includes an Authentik-specific setup flow.

4. Create the Authentik OIDC application

In Authentik, create an Application with an OAuth2/OpenID Connect Provider.

The current integration guide recommends a Strict redirect URI of:

https://home-assistant.richay.au/auth/oidc/callback


Select an Authentik signing key so the provider uses signed ID tokens. The current Authentik setup guide for this integration uses an RS256 signing key.

Make note of the:

  • Client ID
  • Client Secret, if you’re using a confidential client
  • Application/provider slug

The OIDC discovery URL will look like:

https://auth.example.com/application/o/home-assistant/.well-known/openid-configuration


Replace the Authentik hostname and provider slug with your own.

5. Configure Home Assistant

In the Home Assistant OIDC integration:

  1. Select Authentik.
  2. Enter the Authentik discovery URL.
  3. Enter the Client ID and Client Secret if required.
  4. Configure user/group/role mapping as needed.
  5. Test SSO before removing or disabling any local login method.

Keep a local break-glass Home Assistant admin. Don’t make an external identity provider your only way back into the system until you’ve tested browser and Companion App login properly.

6. Use the same URL in the Companion App

The Companion App can now use the same address as the browser:

https://home-assistant.richay.au


There should no longer be a need for a second home-companion.* hostname just to bypass Authentik, because Authentik is no longer authenticating at the reverse-proxy layer.

Keeping Home Assistant private with Tailscale

You don’t have to publish the Home Assistant hostname to the open internet.

Home Assistant’s own current remote-access documentation lists VPN access such as Tailscale as a secure option. If your phone stays connected to Tailscale, the Companion App can continue updating sensors and reaching Home Assistant through the VPN.

I use Split DNS for this type of setup so the nice *.richay.au names resolve to my internal reverse proxy only while I’m at home or connected to Tailscale.

My guide is here: Tailscale Split DNS by Domain for Secure Home Server Access.

Legacy method — the original two-hostname workaround

LEGACY / NOT RECOMMENDED FOR NEW INSTALLS

I’m keeping this section because this article was widely used and existing installations may still be running it. The method relies on the now-archived hass-auth-header custom component.

The original workaround looked like this:

Browser
  ↓
home-assistant.example.com
  ↓
Traefik + Authentik Forward Auth
  ↓
X-authentik-username
  ↓
hass-auth-header
  ↓
Home Assistant

Companion App
  ↓
home-companion.example.com
  ↓
Traefik WITHOUT Authentik
  ↓
Normal Home Assistant login


That architecture solved the Companion App problem because the app never had to pass through Authentik Forward Auth.

However, the second hostname is not magically secure. If home-companion.example.com is publicly reachable, anyone can reach Home Assistant’s normal login screen through it. Making the hostname obscure is not meaningful protection.

If you continue using this old method, make the Companion hostname private using LAN DNS, Tailscale Split DNS, a VPN, firewall rules, or another real access-control boundary.

Legacy hass-auth-header configuration

The custom component itself still expected something similar to:

auth_header:
  username_header: X-authentik-username


Reverse-proxy trust is separate. On current Home Assistant releases configure Traefik under Settings → System → Network → HTTP server rather than adding a new http: YAML block.

The archived hass-auth-header project itself warns that a bad configuration can allow unintended access to Home Assistant. If you keep using it, only trust headers arriving from your actual reverse proxy and plan a migration to OIDC.

Why Authentik Forward Auth can still break the Companion App

This part of the original article remains relevant.

Reverse proxying Home Assistant is fine. The problem is adding a separate authentication wall in front of Home Assistant.

The current Home Assistant OIDC integration’s maintainer explicitly describes reverse proxy + proxy-level authentication as an unsupported scenario for the Companion App. When the login flow leaves the Home Assistant domain for an extra proxy authentication step, the app can fail to complete its own authorization hand-off.

That’s why the modern solution is:

Traefik: reverse proxy only
Home Assistant: handles authentication
Authentik: OIDC identity provider


rather than:

Traefik: reverse proxy + authentication gate
Home Assistant: receives pre-authenticated headers


Troubleshooting

If the browser works but the Companion App does not:

  • Make sure you are not still applying an Authentik Forward Auth middleware to the Home Assistant router.
  • Make sure the app is using the same Home Assistant hostname configured in the Authentik redirect URI.
  • Confirm Home Assistant trusts Traefik as a reverse proxy.
  • Confirm the OIDC callback is exactly /auth/oidc/callback.
  • Test local Home Assistant login as a fallback before assuming the issue is OIDC.
  • Check Home Assistant logs and Authentik provider/outpost logs during the login attempt.

If Home Assistant returns 400 Bad Request behind Traefik, check the HTTP Server settings and confirm the Traefik IP/network is listed under Trusted proxies.

References

The old workaround did its job, but this is one of those rare cases where the newer method is actually simpler: one hostname, proper OIDC, no header-auth hack, and the Companion App doesn’t need its own secret back door 🙂

Ansible – updating proxmox host kernel with LXC shared GPU

Status: ARCHIVED — HISTORICAL NVIDIA WORKAROUND
Last reviewed: 31 August 2026
Originally applied to: Proxmox + shared NVIDIA GPU into LXC using manually installed NVIDIA drivers

Do not copy the old playbook as a current NVIDIA update procedure. It hard-codes an old NVIDIA driver and treats manual driver reinstallation after a kernel update as normal. With a properly configured DKMS installation, the NVIDIA kernel module should normally rebuild for the new kernel automatically.

Is this still relevant?

Mostly no. I’m keeping the page because the problem it was trying to solve still exists, but the automation itself belongs to an older version of my setup.

The original idea was simple: after upgrading the Proxmox kernel, check whether the NVIDIA module loaded. If it didn’t, reinstall the NVIDIA .run driver on the host, reboot, then reinstall the matching NVIDIA userspace driver inside the Plex LXC.

The old playbook was built around a fixed NVIDIA driver:

NVIDIA-Linux-x86_64-535.154.05.run


That’s the first reason not to use it today. More importantly, a kernel update by itself should not normally require reinstalling the whole NVIDIA driver.

What should happen now?

For an NVIDIA driver installed with DKMS:

  1. A new Proxmox kernel is installed.
  2. The matching Proxmox kernel headers are available.
  3. DKMS builds the NVIDIA kernel module for that new kernel.
  4. You reboot into the new kernel.
  5. nvidia-smi continues to work.

NVIDIA’s current documentation says that kernel updates can occasionally leave DKMS without the correct headers or fail to rebuild the module. When that happens, the modern fix is normally to diagnose/rebuild DKMS rather than blindly reinstalling an old driver package.

Useful checks after a Proxmox kernel update

uname -r
nvidia-smi
dkms status


Make sure the headers for the running kernel are installed:

apt update
apt install proxmox-headers-$(uname -r)


Depending on the driver installation method and package naming, you can then rebuild the NVIDIA DKMS module rather than reinstalling the entire driver.

Proxmox 9 note: NVIDIA kernel/module compatibility can still genuinely break on newer Proxmox kernels. There have been current Proxmox 9.2 reports involving kernel 7.x, DKMS build failures and conflicts with newer kernel drivers. So if nvidia-smi dies after an upgrade, don’t assume your LXC passthrough configuration is the problem — check the host NVIDIA module first.

What about the LXC?

For shared NVIDIA GPU access, the LXC uses the NVIDIA kernel driver loaded by the Proxmox host. The container does not build its own kernel module.

The old playbook installed the same NVIDIA package inside the LXC using:

./NVIDIA-Linux-x86_64-535.154.05.run --no-kernel-module


That installs the userspace side without attempting to build a kernel module inside the container.

The important distinction is that a kernel update does not automatically mean those userspace libraries changed. You normally only need to revisit the LXC-side NVIDIA libraries when the actual NVIDIA driver version changes or the userspace/kernel versions no longer match.

Proxmox device passthrough is cleaner now too

Current Proxmox supports native LXC device passthrough using dev[n] entries, including configurable path, UID, GID and mode. That has replaced a lot of the older hand-written cgroup/bind-mount hacks used in historical GPU-sharing guides.

The NVIDIA-specific wrinkle remains: the host still needs a functioning NVIDIA kernel driver before any /dev/nvidia* devices can be shared into the LXC.

If I automated this today

I would automate verification, not automatic driver reinstallation.

For example, an Ansible playbook can check the host after updates and deliberately fail if the NVIDIA stack is broken:

- name: Verify NVIDIA after Proxmox update
  hosts: proxmox
  become: true

  tasks:
    - name: Check running kernel
      ansible.builtin.command: uname -r
      register: running_kernel
      changed_when: false

    - name: Check NVIDIA
      ansible.builtin.command: nvidia-smi
      register: nvidia_smi
      changed_when: false
      failed_when: false

    - name: Check DKMS state
      ansible.builtin.command: dkms status
      register: dkms_status
      changed_when: false
      failed_when: false

    - name: Stop if NVIDIA is broken
      ansible.builtin.fail:
        msg: |
          NVIDIA did not initialise after the kernel update.
          Kernel: {{ running_kernel.stdout }}
          nvidia-smi:
          {{ nvidia_smi.stdout }}
          {{ nvidia_smi.stderr }}

          DKMS:
          {{ dkms_status.stdout }}
      when: nvidia_smi.rc != 0


That gives you the automation benefit without having Ansible silently run an obsolete .run installer against whatever kernel happened to arrive that day.

Original playbook — archived for reference

ARCHIVED CODE — DO NOT USE AS-IS. This is the original playbook from this article. It is preserved so old links and search results still make sense, not because I recommend deploying it in 2026.

########
- hosts: nvidia
  become: true
  become_user: root
  tasks:
    - name: Wait for 10.77.69.2 to become available
      wait_for_connection:
        delay: 5
        timeout: 300

    - name: Check if NVIDIA kernel module is loaded
      shell: lsmod | grep -q '^nvidia'
      register: nvidia_module_check
      ignore_errors: true

    - name: Set NVIDIA module check result as fact
      set_fact:
        nvidia_module_rc: "{{ nvidia_module_check.rc }}"

    - name: Reinstall NVIDIA driver if module is not loaded
      shell: sh /root/NVIDIA-Linux-x86_64-535.154.05.run --silent
      args:
        executable: /bin/bash
      when: nvidia_module_check.rc != 0

    - name: Set fact if NVIDIA driver was installed
      set_fact:
        driver_installed: true
      when: nvidia_module_check.rc != 0

    - name: Reboot system if NVIDIA driver was reinstalled
      reboot:
      when: nvidia_module_check.rc != 0

    - name: Wait for 10.77.69.2 to become available after reboot
      wait_for_connection:
        delay: 10
        timeout: 600
      when: nvidia_module_check.rc != 0

########
- hosts: plex
  become: true
  become_user: root
  tasks:
    - name: Install NVIDIA driver in LXC
      shell: sh /root/NVIDIA-Linux-x86_64-535.154.05.run --no-kernel-module --silent
      args:
        executable: /bin/bash
      when: hostvars['10.77.69.2'].driver_installed | default(false)

    - name: Reboot 10.77.69.103
      reboot:
      when: hostvars['10.77.69.2'].driver_installed | default(false)

    - name: Wait for 10.77.69.103 to become available
      wait_for_connection:
        delay: 10
        timeout: 300
      when: hostvars['10.77.69.2'].driver_installed | default(false)


Why I’m archiving it instead of deleting it

The original problem was real and NVIDIA + new Proxmox kernels can still occasionally go cactus. But the correct troubleshooting path has changed.

Today I’d check:

  • Is the running Proxmox kernel supported by the installed NVIDIA driver?
  • Are the matching Proxmox kernel headers installed?
  • Did DKMS actually build/install the NVIDIA module for the running kernel?
  • Does nvidia-smi work on the host?
  • Do the /dev/nvidia* devices exist?
  • Only then: does the LXC have the right devices and compatible NVIDIA userspace libraries?

That’s a better model than treating every kernel update as “reinstall NVIDIA everywhere and reboot twice”.

References

ExpressVPN – Hourly random smart server reconnect (Linux)

Status: CURRENT — MAJOR 2026 UPDATE
Last reviewed: 31 August 2026
Applies to: Current ExpressVPN Linux app / CLI using expressvpnctl

The original idea still works, but ExpressVPN replaced its old Linux CLI in late 2025. Commands such as expressvpn list all, expressvpn refresh and expressvpn connect belong to the old client. The current CLI is expressvpnctl.

The original version of this article forced ExpressVPN to disconnect, choose another server and reconnect every hour.

I originally borrowed the idea from an Ubuntu 101 article and then tweaked it with suggestions from commenters. The old method did the job, but the ExpressVPN Linux app has changed enough that copying the old script today will mostly result in Linux staring back at you like you’ve personally offended it.

Do you actually need to change VPN server every hour?

Probably not.

Changing servers every hour doesn’t magically make the VPN more private. It also interrupts existing connections, changes your public IP and can annoy websites/services that suddenly see you teleport from one location to another.

But there are still legitimate reasons to automate a reconnect — for example, working around a server that gets shitty after running for a long time, periodically refreshing the public IP, or because you simply enjoy making networking unnecessarily complicated. I can’t judge; look at the rest of this website 🙂

What changed in ExpressVPN for Linux?

ExpressVPN introduced a redesigned Linux app in version 5.0.0 in November 2025 and replaced the old command-line interface with a new CLI.

The command now starts with:

expressvpnctl


The current app also has built-in:

  • Smart Location
  • Auto-connect rules
  • Network Lock / kill switch
  • Split tunnelling
  • Background mode for headless CLI use
  • Automatic protocol selection

So we don’t need the old @reboot expressvpn connect cron bodge anymore.

1. Enable ExpressVPN background mode

Current ExpressVPN requires either the GUI to be running or background mode to be enabled before CLI connection commands can work unattended.

For a headless/server-style setup:

expressvpnctl background enable


Check the current CLI options at any time with:

expressvpnctl -h


2. Test Smart Location

ExpressVPN’s current Smart Location chooses a location based on things such as speed and proximity.

Connect explicitly to Smart Location with:

expressvpnctl connect smart


To see the currently available location names:

expressvpnctl get regions


And if you want to manually choose one:

expressvpnctl connect "Australia - Sydney"


Use the exact location name shown by expressvpnctl get regions. Server/location names can change, so don’t assume my example is immortal.

3. Create the hourly Smart Location script

I now keep little user scripts under ~/.local/bin rather than dumping homemade scripts into /usr/sbin.

mkdir -p ~/.local/bin
nano ~/.local/bin/expressvpn-smart-reconnect.sh


Paste:

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

export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

# Ask ExpressVPN to select its current Smart Location.
# No explicit disconnect first - just switch/reconnect.
expressvpnctl connect smart


Then make it executable:

chmod +x ~/.local/bin/expressvpn-smart-reconnect.sh


Test it manually:

~/.local/bin/expressvpn-smart-reconnect.sh


Why I don’t disconnect first anymore: the old script deliberately ran disconnect before reconnecting. The current CLI can connect/switch directly to another location. Avoiding a deliberate disconnected window is cleaner, especially on a machine carrying traffic you actually care about.

4. Use a systemd user timer instead of cron

Cron would still work, but systemd timers give better logging and are easier to inspect when something goes cactus.

Create the user service:

mkdir -p ~/.config/systemd/user
nano ~/.config/systemd/user/expressvpn-smart-reconnect.service


Paste:

[Unit]
Description=Reconnect ExpressVPN using Smart Location

[Service]
Type=oneshot
ExecStart=%h/.local/bin/expressvpn-smart-reconnect.sh


Now create the timer:

nano ~/.config/systemd/user/expressvpn-smart-reconnect.timer


Paste:

[Unit]
Description=Reconnect ExpressVPN every hour

[Timer]
OnCalendar=hourly
Persistent=true

[Install]
WantedBy=timers.target


Reload systemd and enable the timer:

systemctl --user daemon-reload
systemctl --user enable --now expressvpn-smart-reconnect.timer


Check it:

systemctl --user status expressvpn-smart-reconnect.timer
systemctl --user list-timers --all | grep expressvpn


And check the reconnect log after it runs:

journalctl --user -u expressvpn-smart-reconnect.service --no-pager -n 50


If this is a headless machine and you want the user timer to keep running when you’re not logged in, enable lingering for that Linux user:

sudo loginctl enable-linger "$USER"


Optional: actually choose a random location

Smart Location is not random. It deliberately chooses what ExpressVPN thinks is the best location. It may happily choose the same location again.

If you genuinely want random rotation, I prefer keeping a small list of locations I am happy to use rather than scraping whatever format expressvpnctl get regions happens to print this month.

First get the current valid names:

expressvpnctl get regions


Then create:

nano ~/.local/bin/expressvpn-random-reconnect.sh


Example:

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

export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

# Pick locations YOU are happy to use.
# Confirm current names first with: expressvpnctl get regions
LOCATIONS=(
  "Australia - Sydney"
  "Australia - Melbourne"
)

LOCATION="${LOCATIONS[RANDOM % ${#LOCATIONS[@]}]}"

printf 'Switching ExpressVPN to: %s\n' "$LOCATION"
expressvpnctl connect "$LOCATION"


Make it executable:

chmod +x ~/.local/bin/expressvpn-random-reconnect.sh


If you prefer this mode, change the systemd service’s ExecStart to:

ExecStart=%h/.local/bin/expressvpn-random-reconnect.sh


Then reload it:

systemctl --user daemon-reload
systemctl --user restart expressvpn-smart-reconnect.timer


Yeah, the timer name still says “smart” if you do that. Rename it too if that sort of thing keeps you awake at night. My brain would absolutely notice it six months later and become irrationally annoyed.

Network Lock / kill switch

ExpressVPN’s current Linux CLI has Network Lock enabled by default. It is designed to block traffic when the VPN connection unexpectedly drops.

You can explicitly enable it with:

expressvpnctl set networklock true


For the best experience ExpressVPN recommends leaving the VPN protocol on Automatic unless you have a reason to force something else:

expressvpnctl set protocol auto


Don’t confuse “hourly server switching” with a kill switch. The timer changes location. Network Lock is what protects traffic when the VPN unexpectedly disappears. They solve completely different problems.

Checking your public IP

The old article used expressvpn status. With the new client I prefer simply confirming the outside world sees a different IP:

curl -4 https://ifconfig.me ; echo


Run it before and after a reconnect. If the location/server actually changed, the public IP will normally change too.

Old method — archived

ARCHIVED — OLD EXPRESSVPN LINUX CLI

The original article used the pre-5.x Linux CLI. These commands are preserved so old search results and installations make sense, but don’t use them with the current client.

The original connection-at-boot cron entry was:

MAILTO=""
@reboot expressvpn connect


And the old randomisation script did roughly this:

expressvpn disconnect
expressvpn refresh

VPN=$(expressvpn list all | tail -n +4 | awk '{ print $1 }' | shuf -n 1)

expressvpn connect "$VPN" 


Finally it ran every hour with:

0 */1 * * * /usr/sbin/smartexpressvpn.sh


The cron concept wasn’t wrong. The commands underneath it simply got replaced when ExpressVPN rebuilt the Linux app.

A note about the original random script

The old script parsed human-readable command output using tail, awk, grep and shuf. It even relied on specific column positions.

That kind of script is fine until the application changes one heading or adds one bloody space and suddenly your “VPN location” variable contains a decorative column title.

The current version either asks ExpressVPN directly for smart, or chooses from a small explicit list of valid locations. Much less clever. Much less likely to fuck itself.

References

The idea survived; the commands didn’t. Which is pretty much the lifecycle of every Linux how-to eventually 🙂

Accessing Kali Linux through Guacamole SSH

Status: CURRENT — SECURITY WARNING / LEGACY WORKAROUND
Last reviewed: 31 August 2026
Applies to: Apache Guacamole SSH connections to modern OpenSSH/Kali Linux

Do not start by adding HostKeyAlgorithms +ssh-rsa to Kali. That re-enables the old RSA/SHA-1 signature algorithm which OpenSSH deliberately disabled by default. Update Guacamole/guacd and its SSH libraries first. Only use the old setting as a temporary compatibility workaround if you understand why the negotiation is failing.

Back in 2022 I had Guacamole refusing to SSH into a Kali Linux machine.

I found a Reddit comment suggesting this:

HostKeyAlgorithms +ssh-rsa


I added it to /etc/ssh/sshd_config on Kali and Guacamole started working.

At the time that solved the problem. In 2026, though, this absolutely needs some context before someone copies it into a current SSH server and accidentally makes security worse just to satisfy an ancient client.

Important correction: RSA itself wasn’t deprecated

The old explanation floating around in 2022 often said that “RSA was deprecated”. That’s not quite right.

OpenSSH 8.8 disabled the ssh-rsa signature algorithm by default because it uses the broken SHA-1 hash algorithm.

Existing RSA keys did not suddenly become useless. OpenSSH has supported stronger RSA signatures for years:

rsa-sha2-256
rsa-sha2-512


So this is an important distinction:

RSA key              = not automatically bad
ssh-rsa signature     = RSA + SHA-1, legacy
rsa-sha2-256 / 512    = RSA + SHA-2, modern


OpenSSH itself describes re-enabling RSA/SHA-1 as a stopgap for legacy implementations until they can be upgraded.

Why Guacamole used to have trouble

Guacamole’s SSH support is provided by libguac-client-ssh and the underlying libssh2 library.

Older Guacamole/guacd builds could therefore inherit the algorithm limitations of an older libssh2 build. A perfectly current OpenSSH server could reject the only host-key algorithm the old Guacamole side knew how to negotiate.

This is why weakening Kali was able to “fix” the connection: Kali began offering an old algorithm that the old client understood.

It worked, but we fixed the compatibility problem at the wrong end.

Recommended 2026 fix: update Guacamole first

Apache Guacamole 1.6.0 is the current release at the time of this review. Current Guacamole SSH support still uses libssh2, while newer Guacamole/libssh2 versions support modern SSH algorithms and newer key formats.

If you’re still running some prehistoric Guacamole container from the era when this article was written, update it before touching Kali’s SSH security.

For Docker, first check what you are actually running:

docker ps --format 'table {{.Names}}\t{{.Image}}' | grep -Ei 'guacamole|guacd'
docker logs --tail 50 guacd


Keep the Guacamole web application and guacd on matching current versions where practical.

For example:

services:
  guacd:
    image: guacamole/guacd:1.6.0

  guacamole:
    image: guacamole/guacamole:1.6.0


Don’t blindly replace two lines in a production Compose file from this snippet — database extensions, environment variables and your existing Guacamole setup still matter. The point is to get off the ancient SSH client stack first.

Check the Kali SSH server

On Kali, check the OpenSSH version:

ssh -V


Check which SSH host keys exist:

ls -l /etc/ssh/ssh_host_*_key.pub


A normal modern OpenSSH installation should generally have modern host keys available, commonly Ed25519 and/or ECDSA along with RSA.

If standard host keys are genuinely missing, OpenSSH can generate any missing defaults with:

sudo ssh-keygen -A


ssh-keygen -A generates host keys that don’t already exist; it isn’t a command for randomly replacing your existing server identity.

See what sshd is actually offering

Rather than guessing at the config, ask OpenSSH for the effective setting:

sudo sshd -T | grep -i '^hostkeyalgorithms'


Current OpenSSH defaults include modern algorithms such as:

ssh-ed25519
ecdsa-sha2-nistp256
rsa-sha2-512
rsa-sha2-256


Notice what’s missing from the normal modern default:

ssh-rsa


That’s deliberate.

Check Guacamole’s error before changing anything

If you’re using Docker:

docker logs --tail 200 guacd


Look for messages about SSH negotiation, key exchange, host-key algorithms or authentication.

Don’t assume every Guacamole SSH failure is an ssh-rsa problem. Bad credentials, a changed host key, an old key-exchange algorithm, networking, or a completely different SSH setting can all produce a failed connection.

Host key problem vs login-key problem

These are two different things and mixing them up causes a lot of shitty SSH advice on the internet.

  • HostKeyAlgorithms controls the algorithm the SSH server uses to prove its identity to the client.
  • PubkeyAcceptedAlgorithms controls signature algorithms accepted when a user authenticates with a public key.

If Guacamole cannot even negotiate the server’s host key, blindly adding PubkeyAcceptedAlgorithms +ssh-rsa isn’t fixing the same problem — it’s just weakening another setting for bonus points.

Last resort: temporarily re-enable ssh-rsa

CAUTION — LEGACY COMPATIBILITY ONLY

Only do this if you’ve confirmed that an old Guacamole/libssh2 client genuinely requires ssh-rsa and you cannot update it immediately. OpenSSH disabled this algorithm because it uses SHA-1. Remove the workaround once the client has been fixed.

Back up the SSH config first:

sudo cp /etc/ssh/sshd_config /etc/ssh/sshd_config.before-ssh-rsa


Edit the server config:

sudo nano /etc/ssh/sshd_config


The historical workaround was:

HostKeyAlgorithms +ssh-rsa


The leading + matters: it appends ssh-rsa to the existing defaults rather than replacing the whole list.

Before touching the running SSH daemon, test the configuration:

sudo sshd -t


Do not continue if that returns an error. Keeping your current SSH session open while testing this is also a bloody good idea.

On Kali/Debian, reload SSH:

sudo systemctl reload ssh


Then immediately test Guacamole again.

Remove the workaround afterwards

Once Guacamole/guacd is upgraded and can negotiate a modern host-key signature, remove the compatibility line again.

You can find any old SHA-1 allowances with:

sudo grep -Rni 'ssh-rsa' /etc/ssh/sshd_config /etc/ssh/sshd_config.d 2>/dev/null


After removing the setting:

sudo sshd -t
sudo systemctl reload ssh


One more Guacamole security improvement

Current Guacamole supports SSH host verification using either an ssh_known_hosts file or a per-connection host-key value.

Surprisingly, Guacamole leaves host identity verification disabled if you don’t provide either of those.

For a private homelab that’s easy to ignore, but verifying the SSH server’s host key protects against connecting to the wrong machine or a man-in-the-middle. If you’re already cleaning up an old Guacamole SSH setup, it’s worth doing properly rather than merely making the red error box disappear.

Old article verdict

The old fix genuinely worked, so I’m not deleting it or pretending it never existed.

But the order of operations in 2026 should be:

1. Update Guacamole + guacd
2. Check guacd SSH logs
3. Confirm Kali has modern SSH host keys
4. Confirm modern algorithms are being offered
5. Fix the old client/library if possible
6. ONLY THEN temporarily enable ssh-rsa if absolutely required


Not:

Guacamole won't connect
        ↓
WEAKEN SSH UNTIL IT DOES
        ↓
sweet, fixed :D


Past me got the connection working. Current me would just like to stop past me from leaving a SHA-1-shaped rake on the lawn for someone else to step on 😅.

References

Nextcloud 0770 Data Directory Permission Error

Status: CURRENT — WITH CAUTION
Last reviewed: 31 August 2026
Applies to: Current Nextcloud, including Docker/LinuxServer installs and data directories on filesystems that cannot represent normal Unix permissions

The check_data_directory_permissions option still exists in current Nextcloud. However, Nextcloud’s own documentation says disabling this check is intended for rare setups where the underlying storage cannot correctly represent the expected permissions. In normal installs, fix the permissions instead of turning the check off.

This is a common Nextcloud first-run error, especially after moving the data directory onto another filesystem or NAS.

Nextcloud warning asking for data directory permissions to be changed to 0770

In my case I had moved Nextcloud’s data onto a Windows NAS. Nextcloud looked at the mounted filesystem, couldn’t see the Unix-style 0770 permissions it expected, and basically went:

NOPE. GIVE ME 0770.


The annoying bit is that some filesystems — particularly SMB/CIFS mounts — don’t necessarily expose Unix permissions in the same way a normal local Linux filesystem does. So chmod 0770 may either do nothing useful or report something that doesn’t really represent what the NAS is enforcing.

Do not disable the check first

This is the important bit that my original article was missing.

Nextcloud’s current documentation says check_data_directory_permissions defaults to true and that disabling it is intended for rare environments where permissions cannot be corrected normally. For regular installations, Nextcloud explicitly discourages changing the flag.

So before bypassing anything, check whether you actually have a normal permission problem.

1. Check where the data directory actually is

For a normal install, check your Nextcloud config:

sudo grep -n "'datadirectory'" /var/www/nextcloud/config/config.php


For a Docker container, you can also inspect its mounts:

docker inspect nextcloud   --format '{{range .Mounts}}{{println .Source "->" .Destination}}{{end}}'


If you’re using the LinuxServer Nextcloud image, the current recommended layout maps persistent storage to:

/config  # Nextcloud configuration/application state
/data    # User data


Make sure you are fixing the storage Nextcloud is actually using and not chmod’ing some completely unrelated directory while Nextcloud watches in disappointment.

2. If it’s a normal Linux filesystem, fix the permissions

On a normal local Linux filesystem, don’t bypass the check just because it’s easier.

The web/PHP process needs genuine access to the data directory. The exact owner depends on your install, but a traditional Debian/Ubuntu Nextcloud install commonly runs as www-data.

For example, after confirming the correct path and ownership for your setup:

sudo chown -R www-data:www-data /path/to/nextcloud-data
sudo chmod 0770 /path/to/nextcloud-data


Don’t blindly run that on a giant existing NAS share. Recursive ownership changes can ruin permissions for other services/users. Work out who actually owns the files first. A five-second copy/paste fix can create a really shit afternoon.

3. When disabling the check actually makes sense

The bypass can be reasonable when all of these are true:

  • The data directory is on a filesystem/mount that cannot represent the expected Unix mode properly — SMB/CIFS is a common example.
  • Nextcloud’s process really can read, write, create, rename and delete files in the directory.
  • The storage itself is securely restricted even though Nextcloud cannot verify that restriction through Unix mode bits.
  • You understand that disabling this setting skips a safety check; it does not magically fix access.

If Nextcloud can’t actually write to the share, changing this flag won’t fix shit. You’ll simply get past one warning and hit a different error five minutes later.

Preferred method: use occ

Rather than manually editing PHP, current Nextcloud provides the occ config:system:set command.

Regular Debian/Ubuntu install

cd /var/www/nextcloud

sudo -E -u www-data php occ config:system:set   check_data_directory_permissions   --value=false   --type=boolean


LinuxServer Docker image

LinuxServer’s current Nextcloud image lets you run occ directly inside the container:

docker exec -it nextcloud occ config:system:set   check_data_directory_permissions   --value=false   --type=boolean


Confirm the setting:

docker exec -it nextcloud occ config:system:get   check_data_directory_permissions


You should get:

false


Much cleaner than opening config.php, adding a comma in the wrong place and discovering that PHP has opinions.

Manual config.php method

The old method is still valid if occ isn’t available yet — for example during an installation that hasn’t completed.

Regular installation

sudo nano /var/www/nextcloud/config/config.php


LinuxServer Docker

The LinuxServer config still lives inside the container at:

/config/www/nextcloud/config/config.php


You can open a shell:

docker exec -it nextcloud /bin/bash


Then edit:

nano /config/www/nextcloud/config/config.php


Add this inside the $CONFIG array:

'check_data_directory_permissions' => false,


Save it and reload Nextcloud.

Docker image matters: that /config/www/nextcloud/config/config.php path is for the LinuxServer image. Other Nextcloud images use different paths — the official image commonly keeps its application under /var/www/html. Don’t wander into a random container path because Google showed you a 2022 LinuxServer guide. Yes, including this one 😅.

If you’re using a Windows NAS, consider External Storage instead

This is the other thing I’d do differently today.

If your goal is simply to make files already sitting on a Windows/Samba NAS available inside Nextcloud, you don’t necessarily need to make that share Nextcloud’s primary data directory.

Nextcloud has an External Storage app with native SMB/CIFS support. You can connect the NAS as a folder inside Nextcloud instead.

Current Nextcloud recommends the PHP smbclient module where possible because it is more reliable than falling back to the standalone smbclient executable.

That architecture looks more like:

Nextcloud primary data
        ↓
normal local/Docker storage

Existing Windows NAS
        ↓
Nextcloud External Storage
        ↓
SMB/CIFS


For a lot of homelab setups that’s cleaner than making Nextcloud’s entire data directory depend on a network share and then spending the weekend arm-wrestling CIFS permissions.

How to turn the check back on

If you later move back to storage with proper Unix permissions, put the safety check back:

docker exec -it nextcloud occ config:system:set   check_data_directory_permissions   --value=true   --type=boolean


Or in config.php:

'check_data_directory_permissions' => true,


What this setting does NOT do

  • It does not grant Nextcloud write permission.
  • It does not fix a badly mounted SMB/NFS share.
  • It does not make a publicly readable data directory safe.
  • It does not fix ownership or UID/GID mismatches.
  • It simply tells Nextcloud to stop rejecting the directory because its permission-mode check doesn’t look the way Nextcloud expects.

Think of it as telling Nextcloud, “yes mate, I know the permission bits look weird; I’ve checked the storage myself.” Not “YOLO, permissions are optional now.”

Old article verdict

The actual setting from my 2022 fix is still completely real:

'check_data_directory_permissions' => false,


What changed is the recommendation around it. My old article made it sound like the easy fix was automatically the right fix. Current Nextcloud documentation is much clearer: use this for weird storage where normal permission correction genuinely isn’t possible; otherwise fix the underlying permissions.

So past me wasn’t wrong. Past me was just a little too excited about finding the switch that made the angry red box fuck off 😀

References

Mount a Windows / SMB Share in Linux with fstab

Status: CURRENT — 2026 CLEANUP
Last reviewed: 31 August 2026
Applies to: Debian/Ubuntu Linux mounting an SMB/CIFS share from Windows, TrueNAS, Samba or another NAS

The basic /etc/fstab method from my original guide is still perfectly useful. The big changes are tightening the old 0777 permissions, putting credentials somewhere sensible, and using systemd’s network/automount options so a slow or unavailable NAS doesn’t make boot unnecessarily cactus.

One of the first hurdles I hit when I started playing with Linux VMs was getting them to use storage sitting on my NAS.

Windows/Samba shares use SMB/CIFS, and Linux can mount them just like another filesystem. Once it’s in /etc/fstab, the VM can bring the share in automatically and applications can use a normal Linux path instead of knowing anything about SMB.

Basically:

//NAS/share
     ↓
mount.cifs
     ↓
/mnt/nas-share
     ↓
Linux apps / Docker / whatever other nonsense I'm running :)


1. Install cifs-utils

On Debian/Ubuntu:

sudo apt update
sudo apt install cifs-utils


cifs-utils provides the mount.cifs helper used by the normal Linux mount command and /etc/fstab.

2. Create a mount point

I now prefer /mnt for a system/NAS mount rather than /media, which is more commonly used for removable/user-mounted media.

sudo mkdir -p /mnt/nas-share


Call it whatever makes sense. If the share contains TV, movies or backups, use a name that future-you won’t have to decode at 2am.

3. Create a credentials file

Do not put the SMB username and password directly into /etc/fstab. It works, but now every local user that can read the file gets a free NAS password. Nice one.

Create a root-owned credentials file:

sudo install -m 600 /dev/null /etc/samba/credentials-nas
sudo nano /etc/samba/credentials-nas


Put:

username=NAS_USERNAME
password=NAS_PASSWORD


If the SMB server actually uses a Windows/AD domain, add:

domain=DOMAIN_NAME


If it doesn’t, leave domain out completely. No need to invent one for emotional support.

Double-check the file is locked down:

sudo chown root:root /etc/samba/credentials-nas
sudo chmod 600 /etc/samba/credentials-nas
sudo ls -l /etc/samba/credentials-nas


You want something equivalent to:

-rw------- 1 root root ... /etc/samba/credentials-nas


4. Work out the Linux UID and GID

If you want files on the mounted share to appear owned by a particular Linux user, get that user’s numeric UID/GID:

id yourusername


For example:

uid=1000(yourusername) gid=1000(yourusername) groups=...


Use the numeric values in fstab. Numeric IDs avoid relying on name lookup while the system is mounting filesystems during boot.

5. Test the SMB mount manually first

Before permanently touching /etc/fstab, test the exact share and credentials.

sudo mount -t cifs //NAS_OR_IP/SHARE /mnt/nas-share   -o credentials=/etc/samba/credentials-nas,uid=1000,gid=1000,file_mode=0660,dir_mode=0770


Then:

findmnt /mnt/nas-share
ls -lah /mnt/nas-share


If that fails, fix SMB credentials, DNS/networking or share permissions before adding it to boot. Otherwise you’re just making a broken mount more persistent 😅.

Unmount the test:

sudo umount /mnt/nas-share


6. Add it to /etc/fstab

Back up fstab first because a typo here can make boot considerably more exciting than intended:

sudo cp /etc/fstab /etc/fstab.before-nas
sudo nano /etc/fstab


A sensible current example:

//NAS_OR_IP/SHARE /mnt/nas-share cifs credentials=/etc/samba/credentials-nas,uid=1000,gid=1000,iocharset=utf8,file_mode=0660,dir_mode=0770,nofail,_netdev,x-systemd.automount,x-systemd.idle-timeout=10min 0 0


What those options are doing

  • credentials=... — keeps the username/password out of fstab.
  • uid=1000,gid=1000 — presents files locally as owned by that Linux UID/GID.
  • file_mode=0660 — local presentation: owner/group read + write, no access for everyone else.
  • dir_mode=0770 — owner/group can access/write directories, no access for everyone else.
  • iocharset=utf8 — sensible filename character handling.
  • nofail — don’t fail the entire boot just because the NAS is unavailable.
  • _netdev — explicitly tells the init system this filesystem depends on networking.
  • x-systemd.automount — creates an automount so the share is mounted when first accessed rather than blocking boot waiting for it.
  • x-systemd.idle-timeout=10min — allows systemd to unmount it after ten minutes idle; the next access automatically mounts it again.

Don’t want the mount to idle-unmount? Remove x-systemd.idle-timeout=10min. I like automounting because NASes and networks occasionally decide to have a little fucking nap during boot, but a permanently busy application mount won’t normally become idle anyway.

Why I removed vers=3.0

My original fstab line explicitly used:

vers=3.0


That isn’t necessarily wrong, but I no longer pin a protocol version unless I have a reason to.

Modern CIFS clients can negotiate a suitable modern SMB dialect with the server. Hard-coding a version can become unnecessary baggage later when both ends support something newer.

If a particular old NAS needs a specific version, add it deliberately after checking what the server supports. Do not fall all the way back to SMB1 just to make an ancient box stop complaining — that’s a different flavour of cactus.

Why 0777 was a bit enthusiastic

The original guide used:

file_mode=0777,dir_mode=0777


Which basically says “everyone gets everything”. That made permission problems disappear very efficiently because permissions themselves had also disappeared 😂.

For a single-user VM I now prefer something like:

file_mode=0660
dir_mode=0770


Adjust them for your actual use case. If multiple Linux services need the mount, using a shared group and a deliberate GID is much cleaner than opening it to everybody.

Important: Linux mode bits are not your NAS ACL

This catches people constantly.

uid, gid, file_mode and dir_mode control how the SMB mount is presented to Linux locally. They do not override whatever permissions the NAS/Windows server applies to the SMB account.

If the NAS user only has read access, giving the mount file_mode=0777 does not magically make the server allow writes.

That’s why sometimes you can stare at chmod for half an hour and nothing changes. You’re arguing with the wrong bloody computer.

7. Validate fstab before rebooting

After saving /etc/fstab, reload systemd’s generated mount units:

sudo systemctl daemon-reload


Check the file for obvious problems:

sudo findmnt --verify --verbose


Then trigger the mount by accessing it:

ls /mnt/nas-share
findmnt /mnt/nas-share


If it works now, a reboot shouldn’t contain any horrible surprises.

Troubleshooting

If it doesn’t mount, start simple:

ping NAS_OR_IP
sudo mount -v /mnt/nas-share
dmesg | tail -n 50


  • Permission denied: check the SMB username/password and NAS share ACL.
  • Host unreachable: this is networking/DNS, not an fstab permission problem.
  • Protocol negotiation errors: check which SMB versions the NAS actually supports.
  • Files appear as the wrong Linux user: check the numeric uid/gid.
  • Works manually but not at boot: keep _netdev/x-systemd.automount and check the system journal for the generated mount unit.

Using this with Docker

I generally prefer mounting the SMB share once on the Linux host and then bind-mounting the resulting local path into Docker containers.

For example:

services:
  some-app:
    volumes:
      - /mnt/nas-share:/data


That keeps SMB credentials and mount behaviour on the host instead of teaching five different containers how to connect to the same NAS. Fewer moving parts, fewer places for shit to break.

Old article verdict

The original approach was absolutely fine:

//NAS/share /media/mountpoint cifs credentials=/home/user/.sharelogin,... 0 0


The things I’d change today are:

  • Keep credentials in a root-only system location.
  • Use /mnt for the persistent NAS mount.
  • Don’t default everything to 0777.
  • Don’t pin vers=3.0 unless the server actually needs it.
  • Add _netdev, nofail and systemd automounting for friendlier boot behaviour.
  • Test and verify fstab before rebooting instead of crossing your fingers.

Same idea, just less “it works, don’t touch it” and more “it works and I actually know why now” 🙂

References