Run managed Samba and S3 storage on Ubuntu 26.04 with ZFS
Contents
This guide turns one Ubuntu 26.04 LTS server with a ZFS pool into a small LAN-only NAS. Samba, Cockpit, and the 45Drives management plugins run together in an Incus system container. Garage provides an S3-compatible API from a Docker container on the host.
Keeping Samba and its management interface in the same system container gives
Cockpit direct access to the Unix users, groups, Samba password database, and
service it manages. It avoids synchronizing /etc/passwd, smb.conf, or
passdb.tdb between application containers.
Target environment: Ubuntu 26.04 LTS (Resolute), OpenZFS, Incus, Docker Engine with the Compose plugin, Samba from the Ubuntu repositories, Garage 2.3.0, and 45Drives Cockpit plugins. Validate the complete procedure before changing this note to Tested on.
Compatibility note: Incus supports Ubuntu 26.04, but the published 45Drives packages currently document older Ubuntu releases. The generic plugin installation used below is therefore experimental on Ubuntu 26.04. Test upgrades on a disposable container and keep an Incus snapshot.
RAIDZ protects against a disk failure, but it does not protect against accidental deletion, pool failure, theft, or ransomware. Keep a separate backup of important data.
Architecture
| Component | Runtime | Host endpoint | Persistent storage |
|---|---|---|---|
| Cockpit | Incus container | NAS_IP:9090 |
Container root filesystem |
| Samba | Incus container | NAS_IP:445 |
/srv/nas/shares |
| Garage S3 API | Docker | NAS_IP:3900 |
/srv/nas/garage/data |
| Garage metadata | Docker | Not published | /srv/nas/garage/meta |
Incus proxy devices publish Cockpit and SMB on the selected host address. A shifted disk device maps ownership between the unprivileged Incus container and the host ZFS dataset. Garage remains an ordinary Compose service.
The examples assume:
- the existing ZFS pool is named
zroot; - the NAS has a static LAN address represented by
NAS_IP; - the LAN is trusted and none of these services is exposed to the Internet;
- Docker Engine and the Compose plugin are already installed;
- commands outside
incus execrun on the Ubuntu host.
Verify the host
Check the pool, Docker, and the host address before changing the system:
zpool status
zfs list
docker info --format 'Storage driver: {{.Driver}}'
docker compose version
ip -brief addressSet the address once for commands in the current shell:
export NAS_IP=192.0.2.10192.0.2.10 is a documentation address and will not work on a real LAN.
Replace it with the static address assigned to the NAS.
Create ZFS datasets
Create separate datasets for ordinary shared files, Garage metadata, and Garage object data:
sudo zfs create -o mountpoint=none zroot/nas
sudo zfs create \
-o mountpoint=/srv/nas/shares \
-o recordsize=1M \
-o compression=zstd \
-o atime=off \
-o acltype=posixacl \
-o xattr=sa \
zroot/nas/shares
sudo zfs create -o mountpoint=none zroot/nas/garage
sudo zfs create \
-o mountpoint=/srv/nas/garage/meta \
-o recordsize=16K \
-o compression=zstd \
-o atime=off \
zroot/nas/garage/meta
sudo zfs create \
-o mountpoint=/srv/nas/garage/data \
-o recordsize=1M \
-o compression=zstd \
-o atime=off \
zroot/nas/garage/dataDo not enable ZFS deduplication for these datasets. Confirm their effective properties and mount points:
zfs list -r zroot/nas
zfs get -r mountpoint,recordsize,compression,atime,acltype,xattr zroot/nas
findmnt -t zfs | grep '/srv/nas/'Install and initialize Incus
Ubuntu 26.04 includes a native Incus package. Install it, initialize the local daemon, and give the administrative account access to the Incus socket:
sudo apt update
sudo apt install --yes incus
sudo usermod --append --groups incus-admin "$USER"
sudo incus admin init --minimalOpen a new login session after changing group membership. Membership in
incus-admin is effectively root access to the host, so grant it only to
trusted administrators.
Verify the daemon and storage pool:
incus version
incus storage list
incus network listCreate the Samba system container
Launch an unprivileged Ubuntu 26.04 container:
incus launch images:ubuntu/26.04 samba-admin
incus exec samba-admin -- cloud-init status --waitCreate the target directory before attaching the host dataset. The shift=true
option translates UID and GID values without making the container privileged:
incus exec samba-admin -- install -d -m 0770 /srv/shares
incus config device add samba-admin shares disk \
source=/srv/nas/shares path=/srv/shares shift=truePublish SMB and Cockpit only on the selected host address:
incus config device add samba-admin smb proxy \
listen="tcp:${NAS_IP}:445" connect=tcp:127.0.0.1:445
incus config device add samba-admin cockpit proxy \
listen="tcp:${NAS_IP}:9090" connect=tcp:127.0.0.1:9090Inspect the resulting configuration:
incus config show samba-admin --expanded
incus list samba-adminInstall Samba and Cockpit
Install the services inside the system container:
incus exec samba-admin -- bash -lc '
apt update
apt install --yes \
samba smbclient cockpit cockpit-system \
curl git make moreutils unzip
systemctl enable --now smbd cockpit.socket
'Create a dedicated administrator. Use a unique password; this account controls
Cockpit and can elevate through sudo:
incus exec samba-admin -- adduser nasadmin
incus exec samba-admin -- usermod --append --groups sudo nasadminConfirm that both services are running:
incus exec samba-admin -- systemctl --no-pager status smbd cockpit.socket
incus exec samba-admin -- ss -lntpInstall the 45Drives plugins
cockpit-file-sharing manages shares through Samba’s registry configuration
and can set Samba passwords for existing Unix users. Install its generic build
rather than adding a distribution repository intended for an older Ubuntu
release:
incus exec samba-admin -- bash -lc '
apt install --yes cockpit-bridge attr findutils iproute2 \
samba-common-bin nodejs npm jq
npm install --global yarn
git clone --branch v4.5.5 --depth 1 \
https://github.com/45Drives/cockpit-file-sharing.git \
/usr/local/src/cockpit-file-sharing
make -C /usr/local/src/cockpit-file-sharing
make -C /usr/local/src/cockpit-file-sharing install
'Cockpit’s standard Accounts page can create local users. For a more complete users-and-groups interface, install the optional Cockpit Identities generic release. This plugin is older and is the least certain part of the Ubuntu 26.04 setup:
incus exec samba-admin -- bash -lc '
cd /usr/local/src
apt install --yes passwd psmisc perl openssh-client util-linux
curl -fLO \
https://github.com/45Drives/cockpit-identities/releases/download/v0.1.12/cockpit-identities_0.1.12_generic.zip
unzip cockpit-identities_0.1.12_generic.zip
make -C cockpit-identities_0.1.12_generic install
systemctl restart cockpit.socket
'Take a recoverable checkpoint after verifying that the plugins load:
incus snapshot create samba-admin plugins-installed
incus snapshot list samba-adminConfigure Samba for registry shares
The File Sharing plugin uses Samba’s registry backend. Back up the package configuration and replace it with a small global configuration that includes the registry:
incus exec samba-admin -- bash -lc '
cp --archive /etc/samba/smb.conf /etc/samba/smb.conf.package
cat >/etc/samba/smb.conf <<"EOF"
[global]
server role = standalone server
workgroup = WORKGROUP
server string = ZFS NAS
security = user
map to guest = never
server min protocol = SMB2_02
disable netbios = yes
smb ports = 445
load printers = no
printing = bsd
printcap name = /dev/null
include = registry
EOF
testparm -s
systemctl restart smbd
'Open https://NAS_IP:9090, accept the locally generated certificate warning,
and sign in as nasadmin. Then:
- Open Accounts or Identities and create the local users and groups.
- Open File Sharing → Samba → Manage Samba Passwords and set a separate Samba password for each user who needs SMB access.
- Create the
storageshare with path/srv/shares. - Set Guest Ok to off, Read Only to off, and Browsable to on.
- Add
valid users = @nasusersin advanced settings if access is group-based.
A Samba password does not replace the Unix account. Both records are required, and the user must have Unix permission to the shared directory.
Verify the generated registry configuration:
incus exec samba-admin -- net conf list
incus exec samba-admin -- pdbedit --list
incus exec samba-admin -- testparm -sConfigure share permissions
Create the group if it was not created in the UI, add users to it, and make the
shared directory group-writable. Replace <username> with an actual account:
incus exec samba-admin -- groupadd --force nasusers
incus exec samba-admin -- usermod --append --groups nasusers <username>
incus exec samba-admin -- chown root:nasusers /srv/shares
incus exec samba-admin -- chmod 2770 /srv/sharesThe setgid bit makes new directories inherit the nasusers group. Check both
the container view and the shifted ownership visible on the host:
incus exec samba-admin -- ls -ldn /srv/shares
sudo ls -ldn /srv/nas/sharesDo not manually change the shifted numeric ownership on the host unless you understand the container’s ID map.
Configure Garage on the host
Create a small Compose project for Garage only:
sudo install -d -m 0750 /opt/nas-storage
sudo chown "$USER":"$USER" /opt/nas-storage
cd /opt/nas-storageGenerate garage.toml and keep its secrets out of version control:
umask 077
RPC_SECRET=$(openssl rand -hex 32)
ADMIN_TOKEN=$(openssl rand -base64 32)
METRICS_TOKEN=$(openssl rand -base64 32)
cat >garage.toml <<EOF
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "sqlite"
metadata_auto_snapshot_interval = "6h"
replication_factor = 1
rpc_bind_addr = "[::]:3901"
rpc_public_addr = "garage:3901"
rpc_secret = "${RPC_SECRET}"
[s3_api]
s3_region = "garage"
api_bind_addr = "[::]:3900"
[admin]
api_bind_addr = "[::]:3903"
admin_token = "${ADMIN_TOKEN}"
metrics_token = "${METRICS_TOKEN}"
EOF
unset RPC_SECRET ADMIN_TOKEN METRICS_TOKEN
chmod 0600 garage.tomlCreate .env, replacing the documentation address:
umask 077
cat >.env <<EOF
NAS_IP=192.0.2.10
GARAGE_DEFAULT_ACCESS_KEY=GK$(openssl rand -hex 16)
GARAGE_DEFAULT_SECRET_KEY=$(openssl rand -hex 32)
GARAGE_DEFAULT_BUCKET=storage
EOFCreate compose.yaml:
name: nas-storage
services:
garage:
image: dxflrs/garage:v2.3.0
restart: unless-stopped
hostname: garage
command: ["/garage", "server", "--single-node", "--default-bucket"]
environment:
GARAGE_DEFAULT_ACCESS_KEY: ${GARAGE_DEFAULT_ACCESS_KEY}
GARAGE_DEFAULT_SECRET_KEY: ${GARAGE_DEFAULT_SECRET_KEY}
GARAGE_DEFAULT_BUCKET: ${GARAGE_DEFAULT_BUCKET}
ports:
- "${NAS_IP}:3900:3900"
volumes:
- ./garage.toml:/etc/garage.toml:ro
- /srv/nas/garage/meta:/var/lib/garage/meta
- /srv/nas/garage/data:/var/lib/garage/data
healthcheck:
test: ["CMD", "/garage", "status"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
security_opt:
- no-new-privileges:trueProtect the Garage datasets and start the service:
sudo chown root:root /srv/nas/garage/meta /srv/nas/garage/data
sudo chmod 0700 /srv/nas/garage/meta /srv/nas/garage/data
docker compose config --quiet
docker compose pull garage
docker compose up -d
docker compose ps
docker compose exec garage /garage status
docker compose exec garage /garage bucket listRestrict network access
Allow TCP ports 445, 9090, and 3900 only from the trusted LAN in the host
or upstream firewall. Do not expose SMB, Cockpit, or an unencrypted S3 endpoint
to the Internet. Docker-published ports can bypass simple UFW rules, so enforce
the Garage policy in the DOCKER-USER chain or on the upstream router.
Confirm the listeners on the host:
ss -lntp | grep -E "${NAS_IP}:(445|9090|3900)"Use a VPN for remote administration. If Cockpit must cross an untrusted network, place it behind a correctly configured TLS reverse proxy.
Verify Samba
From another Ubuntu machine, list the shares and transfer a test file:
sudo apt update
sudo apt install --yes smbclient
smbclient -L //NAS_IP -U <username>
printf 'Samba test\n' >/tmp/samba-test.txt
smbclient //NAS_IP/storage -U <username> \
-c 'put /tmp/samba-test.txt; ls; get samba-test.txt /tmp/samba-test.downloaded.txt'
cmp /tmp/samba-test.txt /tmp/samba-test.downloaded.txtOn Windows, open \\NAS_IP\storage. Windows may reuse an existing SMB
session; remove a stale one before testing different credentials:
net use \\NAS_IP\storage /deleteVerify S3
Load the Garage credentials into a client environment without copying the
whole .env file to an untrusted machine:
export AWS_ENDPOINT_URL=http://NAS_IP:3900
export AWS_DEFAULT_REGION=garage
export AWS_ACCESS_KEY_ID='<GARAGE_DEFAULT_ACCESS_KEY>'
export AWS_SECRET_ACCESS_KEY='<GARAGE_DEFAULT_SECRET_KEY>'
export GARAGE_BUCKET=storage
printf 'S3 test\n' >/tmp/s3-test.txt
aws s3 cp /tmp/s3-test.txt "s3://${GARAGE_BUCKET}/s3-test.txt"
aws s3 cp "s3://${GARAGE_BUCKET}/s3-test.txt" /tmp/s3-test.downloaded.txt
cmp /tmp/s3-test.txt /tmp/s3-test.downloaded.txtGarage does not implement every AWS S3 feature. Check its S3 compatibility matrix before using applications that depend on uncommon API operations.
Snapshots and backup
Snapshot the Samba system container before plugin or operating-system upgrades:
incus snapshot create samba-admin "before-upgrade-$(date -u +%Y%m%dT%H%M%SZ)"
incus snapshot list samba-adminThe container snapshot protects its users, Samba password database, registry configuration, and Cockpit installation. The attached host dataset is not part of that snapshot. Snapshot the ZFS data separately:
SNAPSHOT="manual-$(date -u +%Y%m%dT%H%M%SZ)"
sudo zfs snapshot -r "zroot/nas@${SNAPSHOT}"
zfs list -t snapshot -r zroot/nasFor the most conservative Garage backup, briefly stop writes and snapshot its metadata and data together:
cd /opt/nas-storage
docker compose stop garage
sudo zfs snapshot -r "zroot/nas/garage@backup-$(date -u +%Y%m%dT%H%M%SZ)"
docker compose start garage
docker compose exec garage /garage statusSnapshots remain in the same pool. Replicate them or make an independent backup. Never restore only Garage metadata or only Garage data.
Updates
Update Samba and Cockpit inside the system container, then verify both services:
incus exec samba-admin -- bash -lc '
apt update
apt full-upgrade --yes
systemctl --no-pager status smbd cockpit.socket
'Review 45Drives release notes before updating either plugin. Because the plugins are installed outside APT, ordinary package upgrades do not update them automatically.
Garage upgrades can require version-specific steps. Read the upstream upgrade instructions before changing the pinned image tag.
Troubleshooting
If Cockpit opens but users or shares cannot be managed, inspect the services, plugin files, Samba registry, and logs inside the container:
incus exec samba-admin -- systemctl --no-pager status cockpit.socket smbd
incus exec samba-admin -- ls /usr/share/cockpit
incus exec samba-admin -- net conf list
incus exec samba-admin -- journalctl --no-pager -n 200 -u smbd -u cockpit.socketIf a user can authenticate but cannot write, check group membership and the container-side permissions:
incus exec samba-admin -- id <username>
incus exec samba-admin -- pdbedit --list
incus exec samba-admin -- ls -ldn /srv/shares
incus exec samba-admin -- testparm -sIf the mounted dataset is missing or shows unexpected ownership, inspect the Incus disk device and ID mapping rather than changing ownership blindly:
incus config device show samba-admin
incus config show samba-admin --expanded
incus exec samba-admin -- findmnt /srv/shares
sudo ls -ldn /srv/nas/sharesFor Garage failures, inspect its status and logs on the host:
cd /opt/nas-storage
docker compose ps
docker compose logs --tail=200 garage
docker compose exec garage /garage status
curl -I http://NAS_IP:3900An HTTP 403 response from unauthenticated curl is sufficient to confirm
that the S3 endpoint is reachable. Finally, verify the ZFS pool itself:
zpool status -x
zfs list -r zroot/nas