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

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
incus version
incus storage list
incus network list
```

## Create the Samba system container

Launch an unprivileged Ubuntu 26.04 container:

```bash
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:

```bash
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:

```bash
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:

```bash
incus config show samba-admin --expanded
incus list samba-admin
```

## Install Samba and Cockpit

Install the services inside the system container:

```bash
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`:

```bash
incus exec samba-admin -- adduser nasadmin
incus exec samba-admin -- usermod --append --groups sudo nasadmin
```

Confirm that both services are running:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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`:

```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:true
```

Protect the Garage datasets and start the service:

```bash
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:

```bash
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:

```bash
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:

```powershell
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:

```bash
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](https://garagehq.deuxfleurs.fr/documentation/reference-manual/s3-compatibility/)
before using applications that depend on uncommon API operations.

## Snapshots and backup

Snapshot the Samba system container before plugin or operating-system upgrades:

```bash
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:

```bash
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:

```bash
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:

```bash
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](https://garagehq.deuxfleurs.fr/documentation/operations/upgrading/)
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
zpool status -x
zfs list -r zroot/nas
```

