TrueNAS Apps
Configuring TrueNAS apps
Purpose of this article
- Deploy a containerized application on TrueNAS with a granular permission structure
Background
I wanted a stack of self-hosted applications for managing my media library. Originally I planned to use a Proxmox-based VM with Docker, or a set of LXCs. Ultimately I deployed the apps as TrueNAS/iX Systems Applications (available only in TrueNAS Community/Enterprise; effectively Docker with extra steps).
I chose this method for these reasons:
- Full convergence of compute and storage (no network transport for transcodes or file operations)
- Single-pane-of-glass management
- The apps I wanted were available from the TrueNAS Application repo
General process
Each app is installed and configured essentially the same way:
- First, create a group that all related apps can join. This means apps that need to act on the same datasets - lets’s say we’re transcoding 100 TB of Blu-Rays you ripped from your physical collection, or we want to tag and organize a big digital music library. I named mine
mediastackand gave it a meaningful GID that fits my schema. - Download any app, start configuration, and view its desired storage volumes. Note what it needs: does it want a config directory, a data directory, a transcodes directory? Consult its documentation.
- Pick what type of volume(s) you’ll use:
- The default is to create “iX Volumes” which are similar to Docker volumes.
- My preference is to configure everything with bind mounts so that all data can be directly managed.
- You can mix types - e.g. iX Volume for ephemeral data; bind mount for shared media library.
- Create a UID/GID for each service, or shared by the services
- Create storage volumes for the services
- Configure and run the services
Procedure
The method described here is atomic. For an easier setup process, but less flexibility and security, you can create one or more service UID/GIDs and share them among one or more applications.
Build group(s) and user(s)
- Create a group for the app (manually, so you can match the GID/UID)
- Uncheck “SMB group”
- Create a user for the app
- Match the GID/UID of the group
- Check “No password”, ensure no shell access, and uncheck “SMB user”
- Set the primary group/GID to match the app group, and set
mediastack(or your choice of shared group name, if applicable) as auxiliary group
Build datasets
-
Create a dataset for the app
- It’s smart to put VMs on a dedicated pool - for general purpose use, an SSD mirror works perfectly
- I organize all my pools in the following fashion:
vmpool/vmpool/clrootvmpool/clroot/<unencryptedVM>/configvmpool/clroot/<unencryptedVM>/data
vmpool/encrootvmpool/encroot/<encryptedVM>/configvmpool/encroot/<encryptedVM>/data
- Since the pool root dataset should never be (but can be) encrypted, I like to set up top-level “clear” and “encrypt” root datasets
- I lay out the storage like this even if I only expect to use data of one type or the other
- This allows for maximum flexibility as future needs change
-
Create datasets for each volume as shown above (
../<VM>/config,../<VM>/data, etc)
Set permissions
-
Grant the following permissions: on
encrootandclrootto themediastackgroup:- Special > Execute
- This permission allows the VM user to traverse/descend only
- Set recursive (applies new permissions to existing data)
- DO NOT set to apply to child datasets
- Special > Execute
-
Grant the following permissions on the relevant
<VM>dataset to either themediastackgroup or to the relevant<VM>user/group combo:- Full control
- DO apply to child datasets
-
Grant any additional required permissions to datasets the VM must access, for example:
mediapool/encroot/vid_showsmediapool/encroot/vid_moviesmediapool/encroot/downloads- … etc
Configure the app
-
Go back to the app setup menu
-
Set up the app, and if you have the option to choose a run-as user, select the user you created and the relevant group (
mediastackor the group that matches the app user)’ -
Configure networking
- I like to set port numbers that correspond to or resemble the UID/GID and adhere to my personal schema
-
When you reach storage bindings, change “iX Volume” to “Mount”
-
Point the GUI at the desired directory (config >
vmpool/clroot/<VM>/config, and so on)- No need to touch the permissions buttons as we’ve already configured them
After finalizing the changes the app should spin up nearly immediately and the GUI console should print a link to the web UI (usually applicable) socket.
Configure networking
Your architecture is up to you. You can put all the related apps on their own internal Docker network and bind only Jellyfin to both the external network and the Docket network. You can let all your apps bind to to the host machine’s IP. The correct choice depends on your wants and needs.
I’ve implemented the second option. Networks are for using. Data does not move between the apps over the network because in my case the compute and storage are converged and my network isn’t shared. YMMV.
On to DNS and reverse proxying. I like to have friendly names for everything. This means going into my firewall (OPNsense) and setting up DNS mappings and Caddy reverse proxy handlers. I use subdomains (ex. jellyfin.example.internal). Most apps support using a URL base and paths (ex. example.internal/media/jellyfin) but this requires additional app-side configuration. Modern web frontends come locked-down to prevent exploits.
As I mentioned at the top of this section, I am not a web security expert, but here are some relevant links for you to learn more. X-Forwarded-For headers are relevant for proxy configuration even in a private network.
… etc.
Networking and proxy overview
The diagram below is an example of my network configuration as it applies to my individual media apps. Your needs may be totally different.
---
config:
theme: 'obsidian'
---
flowchart
subgraph Flow
style Flow fill:black
direction TB
subgraph Requester
style Requester fill:black
wantapp@{ shape: circle, label: "Wants app" } -- Step 1 --> askdns
askdns@{ shape: lean-r, label: "<code style="background-color:darkred">DNS query:</code> <code style="background-color: saddlebrown;"> whois app.example.internal?</code>" }
askhttp[HTTP request to <code style="background-color: green;">Caddy IP</code>]
wantapp -- Step 2 --> askhttp
end
subgraph DNS
style DNS fill:black
dns@{ shape: cyl, label: "DNS server" }
dnstab@{ shape: win-pane, label: "Host entry in <b>dnsmasq</b> pointing to <b>Caddy<b>" }
dns o==o dnstab
end
subgraph Caddy
style Caddy fill:black
direction TB
app@{ shape: bow-rect, label: "App data<br /> <code>10.0.10.100:5003</code>" }
style app fill:darkblue,stroke:navy,stroke-width:4px,font-size:14pt
caddy@{ shape: stadium, label: "<code style="background-color: green;">Caddy</code>" }
style caddy fill:#008000,stroke:#008000,font-size:16pt
chandler@{ shape: subproc, label: "<b>Caddy handler rule</b><br /><br />Reverse proxies <code style="background-color: saddlebrown;">app.example.internal</code> to <code>10.0.10.100:5003</code>" }
end
chandler <==> caddy & app
askdns --> dns -- <code style="background-color: darkred;">DNS answer:</code> <code style="background-color: green;">Caddy IP</code> --> wantapp
askhttp -- HTTP GET ---> caddy
caddy == HTTP 200 OK ==> wantapp
end