Contents

Run managed Samba and S3 storage on Ubuntu 26.04 with ZFS

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 exec run on the Ubuntu host.

Verify the host

Check the pool, Docker, and the host address before changing the system:

1
2
3
4
5
zpool status
zfs list
docker info --format 'Storage driver: {{.Driver}}'
docker compose version
ip -brief address

Set the address once for commands in the current shell:

1
export NAS_IP=192.0.2.10

192.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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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/data

Do not enable ZFS deduplication for these datasets. Confirm their effective properties and mount points:

1
2
3
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:

1
2
3
4
sudo apt update
sudo apt install --yes incus
sudo usermod --append --groups incus-admin "$USER"
sudo incus admin init --minimal

Open 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:

1
2
3
incus version
incus storage list
incus network list

Create the Samba system container

Launch an unprivileged Ubuntu 26.04 container:

1
2
incus launch images:ubuntu/26.04 samba-admin
incus exec samba-admin -- cloud-init status --wait

Create the target directory before attaching the host dataset. The shift=true option translates UID and GID values without making the container privileged:

1
2
3
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=true

Publish SMB and Cockpit only on the selected host address:

1
2
3
4
5
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:9090

Inspect the resulting configuration:

1
2
incus config show samba-admin --expanded
incus list samba-admin

Install Samba and Cockpit

Install the services inside the system container:

1
2
3
4
5
6
7
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:

1
2
incus exec samba-admin -- adduser nasadmin
incus exec samba-admin -- usermod --append --groups sudo nasadmin

Confirm that both services are running:

1
2
incus exec samba-admin -- systemctl --no-pager status smbd cockpit.socket
incus exec samba-admin -- ss -lntp

Install 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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:

1
2
3
4
5
6
7
8
9
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:

1
2
incus snapshot create samba-admin plugins-installed
incus snapshot list samba-admin

Configure 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
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:

  1. Open Accounts or Identities and create the local users and groups.
  2. Open File Sharing → Samba → Manage Samba Passwords and set a separate Samba password for each user who needs SMB access.
  3. Create the storage share with path /srv/shares.
  4. Set Guest Ok to off, Read Only to off, and Browsable to on.
  5. Add valid users = @nasusers in 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:

1
2
3
incus exec samba-admin -- net conf list
incus exec samba-admin -- pdbedit --list
incus exec samba-admin -- testparm -s

Configure 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:

1
2
3
4
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/shares

The setgid bit makes new directories inherit the nasusers group. Check both the container view and the shifted ownership visible on the host:

1
2
incus exec samba-admin -- ls -ldn /srv/shares
sudo ls -ldn /srv/nas/shares

Do 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:

1
2
3
sudo install -d -m 0750 /opt/nas-storage
sudo chown "$USER":"$USER" /opt/nas-storage
cd /opt/nas-storage

Generate garage.toml and keep its secrets out of version control:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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.toml

Create .env, replacing the documentation address:

1
2
3
4
5
6
7
8
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
EOF

Create compose.yaml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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:true

Protect the Garage datasets and start the service:

1
2
3
4
5
6
7
8
9
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 list

Restrict 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:

1
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:

1
2
3
4
5
6
7
8
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.txt

On Windows, open \\NAS_IP\storage. Windows may reuse an existing SMB session; remove a stale one before testing different credentials:

1
net use \\NAS_IP\storage /delete

Verify S3

Load the Garage credentials into a client environment without copying the whole .env file to an untrusted machine:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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.txt

Garage 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:

1
2
incus snapshot create samba-admin "before-upgrade-$(date -u +%Y%m%dT%H%M%SZ)"
incus snapshot list samba-admin

The 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:

1
2
3
SNAPSHOT="manual-$(date -u +%Y%m%dT%H%M%SZ)"
sudo zfs snapshot -r "zroot/nas@${SNAPSHOT}"
zfs list -t snapshot -r zroot/nas

For the most conservative Garage backup, briefly stop writes and snapshot its metadata and data together:

1
2
3
4
5
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 status

Snapshots 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:

1
2
3
4
5
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:

1
2
3
4
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.socket

If a user can authenticate but cannot write, check group membership and the container-side permissions:

1
2
3
4
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 -s

If the mounted dataset is missing or shows unexpected ownership, inspect the Incus disk device and ID mapping rather than changing ownership blindly:

1
2
3
4
incus config device show samba-admin
incus config show samba-admin --expanded
incus exec samba-admin -- findmnt /srv/shares
sudo ls -ldn /srv/nas/shares

For Garage failures, inspect its status and logs on the host:

1
2
3
4
5
cd /opt/nas-storage
docker compose ps
docker compose logs --tail=200 garage
docker compose exec garage /garage status
curl -I http://NAS_IP:3900

An HTTP 403 response from unauthenticated curl is sufficient to confirm that the S3 endpoint is reachable. Finally, verify the ZFS pool itself:

1
2
zpool status -x
zfs list -r zroot/nas