diff --git a/create-a-container/client/package-lock.json b/create-a-container/client/package-lock.json
index d0517d00..1d0ec772 100644
--- a/create-a-container/client/package-lock.json
+++ b/create-a-container/client/package-lock.json
@@ -9,7 +9,7 @@
       "version": "0.0.0",
       "dependencies": {
         "@hookform/resolvers": "^3.9.1",
-        "@mieweb/ui": "*",
+        "@mieweb/ui": "latest",
         "@tanstack/react-query": "^5.62.0",
         "lucide-react": "^0.460.0",
         "react": "^19.0.0",
diff --git a/create-a-container/client/src/components/containers/CollaboratorsManager.tsx b/create-a-container/client/src/components/containers/CollaboratorsManager.tsx
new file mode 100644
index 00000000..65d28746
--- /dev/null
+++ b/create-a-container/client/src/components/containers/CollaboratorsManager.tsx
@@ -0,0 +1,163 @@
+import { useState } from 'react';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { Button, Input, ServiceBadge, useToast } from '@mieweb/ui';
+import { UserPlus } from 'lucide-react';
+import { ApiError } from '@/lib/api';
+import { keys, queries } from '@/lib/queries';
+
+/**
+ * Presentational list of collaborator usernames rendered as removable chips.
+ * Shared by the live manager and the create-form's collaborators field so
+ * both surfaces look identical.
+ */
+export function CollaboratorChips({
+  usernames,
+  onRemove,
+  disabled,
+  emptyText = 'Not shared with anyone yet.',
+}: {
+  usernames: string[];
+  onRemove: (username: string) => void;
+  disabled?: boolean;
+  emptyText?: string;
+}) {
+  if (usernames.length === 0) {
+    return <p className="text-sm text-muted-foreground">{emptyText}</p>;
+  }
+  return (
+    <ul className="flex flex-wrap gap-2" aria-label="Shared with">
+      {usernames.map((username) => (
+        <li key={username}>
+          <ServiceBadge
+            variant="secondary"
+            size="sm"
+            removable
+            onRemove={() => {
+              if (!disabled) onRemove(username);
+            }}
+          >
+            {username}
+          </ServiceBadge>
+        </li>
+      ))}
+    </ul>
+  );
+}
+
+/**
+ * An accessible username input + add button. Surfaces a validation/error
+ * message (e.g. "user does not exist") inline beneath the field.
+ */
+export function AddCollaboratorField({
+  onAdd,
+  pending,
+  error,
+  label = 'Username',
+}: {
+  onAdd: (username: string) => void;
+  pending?: boolean;
+  error?: string | null;
+  label?: string;
+}) {
+  const [value, setValue] = useState('');
+
+  const submit = () => {
+    const username = value.trim();
+    if (!username) return;
+    onAdd(username);
+    setValue('');
+  };
+
+  return (
+    <div className="flex items-end gap-2">
+      <div className="flex-1">
+        <Input
+          label={label}
+          placeholder="Enter a username"
+          autoCapitalize="none"
+          autoCorrect="off"
+          spellCheck={false}
+          value={value}
+          error={error || undefined}
+          hasError={!!error}
+          onChange={(e) => setValue(e.target.value)}
+          onKeyDown={(e) => {
+            // Submit on Enter without bubbling to an enclosing form.
+            if (e.key === 'Enter') {
+              e.preventDefault();
+              submit();
+            }
+          }}
+        />
+      </div>
+      <Button
+        type="button"
+        variant="outline"
+        leftIcon={<UserPlus className="size-4" />}
+        isLoading={pending}
+        disabled={!value.trim()}
+        onClick={submit}
+      >
+        Share
+      </Button>
+    </div>
+  );
+}
+
+/**
+ * Live sharing manager for an existing container: lists current collaborators
+ * and lets the owner/admin add or remove them via the API. Used both in the
+ * list page's share dialog and the edit form's Sharing section.
+ */
+export function CollaboratorsManager({
+  siteId,
+  containerId,
+  collaborators,
+}: {
+  siteId: string;
+  containerId: number;
+  collaborators: string[];
+}) {
+  const qc = useQueryClient();
+  const toast = useToast();
+  const [addError, setAddError] = useState<string | null>(null);
+
+  const invalidate = () => {
+    qc.invalidateQueries({ queryKey: keys.container(siteId, containerId) });
+    qc.invalidateQueries({ queryKey: keys.containers(siteId) });
+  };
+
+  const add = useMutation({
+    mutationFn: (username: string) => queries.shareContainer(siteId, containerId, username),
+    onSuccess: (_data, username) => {
+      setAddError(null);
+      toast.success(`Shared with ${username}`);
+      invalidate();
+    },
+    onError: (err: ApiError) => setAddError(err.message),
+  });
+
+  const remove = useMutation({
+    mutationFn: (username: string) => queries.unshareContainer(siteId, containerId, username),
+    onSuccess: (_data, username) => {
+      toast.success(`Stopped sharing with ${username}`);
+      invalidate();
+    },
+    onError: (err: ApiError) => toast.error(err.message),
+  });
+
+  return (
+    <div className="flex flex-col gap-4">
+      <CollaboratorChips
+        usernames={collaborators}
+        onRemove={(u) => remove.mutate(u)}
+        disabled={remove.isPending}
+      />
+      <AddCollaboratorField
+        onAdd={(u) => add.mutate(u)}
+        pending={add.isPending}
+        error={addError}
+      />
+    </div>
+  );
+}
diff --git a/create-a-container/client/src/components/containers/ContainerFilters.tsx b/create-a-container/client/src/components/containers/ContainerFilters.tsx
new file mode 100644
index 00000000..4695fee8
--- /dev/null
+++ b/create-a-container/client/src/components/containers/ContainerFilters.tsx
@@ -0,0 +1,173 @@
+import { useState } from 'react';
+import { Button, Dropdown, DropdownItem, Input } from '@mieweb/ui';
+import { ChevronDown, Filter, User as UserIcon, X } from 'lucide-react';
+
+export interface FilterOption {
+  value: string;
+  label: string;
+}
+
+/**
+ * A compact multiselect built on @mieweb/ui's Dropdown (multiSelect mode).
+ * Selected values are controlled by the parent so filter state stays in the URL.
+ */
+function MultiSelect({
+  label,
+  emptyLabel,
+  icon,
+  options,
+  selected,
+  onChange,
+  searchable = false,
+  showSelectAll = false,
+}: {
+  label: string;
+  /** Trigger summary shown when nothing is selected. */
+  emptyLabel: string;
+  icon?: React.ReactElement;
+  options: FilterOption[];
+  selected: string[];
+  onChange: (next: string[]) => void;
+  searchable?: boolean;
+  showSelectAll?: boolean;
+}) {
+  const summary =
+    selected.length === 0
+      ? emptyLabel
+      : selected.length === 1
+        ? (options.find((o) => o.value === selected[0])?.label ?? selected[0])
+        : `${selected.length} selected`;
+
+  return (
+    <Dropdown
+      multiSelect
+      selectedValues={selected}
+      onSelectedValuesChange={onChange}
+      searchable={searchable}
+      searchPlaceholder="Search…"
+      searchAriaLabel={`Search ${label}`}
+      showSelectAll={showSelectAll}
+      trigger={
+        <Button
+          variant="secondary"
+          size="sm"
+          leftIcon={icon}
+          aria-label={`${label}: ${summary}`}
+        >
+          <span className="font-normal text-muted-foreground">{label}:</span>
+          <span className="ml-1">{summary}</span>
+          <ChevronDown className="ml-1 size-4" aria-hidden="true" />
+        </Button>
+      }
+    >
+      {options.map((o) => (
+        <DropdownItem key={o.value} value={o.value}>
+          {o.label}
+        </DropdownItem>
+      ))}
+    </Dropdown>
+  );
+}
+
+export interface ContainerFiltersProps {
+  userOptions: FilterOption[];
+  selectedUsers: string[];
+  onUsersChange: (next: string[]) => void;
+  statusOptions: FilterOption[];
+  selectedStatuses: string[];
+  onStatusesChange: (next: string[]) => void;
+  templateOptions: FilterOption[];
+  selectedTemplates: string[];
+  onTemplatesChange: (next: string[]) => void;
+  hostname: string;
+  onHostnameChange: (next: string) => void;
+  onClearAll: () => void;
+}
+
+/**
+ * Filter bar for the containers list. "User" is always visible and drives the
+ * server query; the remaining filters (status, template, hostname) live behind
+ * a "More filters" disclosure and filter the loaded rows client-side.
+ */
+export function ContainerFilters({
+  userOptions,
+  selectedUsers,
+  onUsersChange,
+  statusOptions,
+  selectedStatuses,
+  onStatusesChange,
+  templateOptions,
+  selectedTemplates,
+  onTemplatesChange,
+  hostname,
+  onHostnameChange,
+  onClearAll,
+}: ContainerFiltersProps) {
+  const [showMore, setShowMore] = useState(false);
+  const extraCount =
+    selectedStatuses.length + selectedTemplates.length + (hostname ? 1 : 0);
+  const activeCount = selectedUsers.length + extraCount;
+
+  return (
+    <div className="flex flex-col gap-2 rounded-lg border border-border p-3">
+      <div className="flex flex-wrap items-center gap-2">
+        <MultiSelect
+          label="User"
+          emptyLabel="All"
+          icon={<UserIcon className="size-4" />}
+          options={userOptions}
+          selected={selectedUsers}
+          onChange={onUsersChange}
+          searchable
+          showSelectAll
+        />
+        <Button
+          variant="ghost"
+          size="sm"
+          leftIcon={<Filter className="size-4" />}
+          aria-expanded={showMore}
+          onClick={() => setShowMore((s) => !s)}
+        >
+          More filters{extraCount > 0 ? ` (${extraCount})` : ''}
+        </Button>
+        {activeCount > 0 && (
+          <Button
+            variant="ghost"
+            size="sm"
+            leftIcon={<X className="size-4" />}
+            onClick={onClearAll}
+            className="ml-auto text-muted-foreground"
+          >
+            Clear
+          </Button>
+        )}
+      </div>
+      {showMore && (
+        <div className="flex flex-wrap items-center gap-2 border-t border-border pt-2">
+          <MultiSelect
+            label="Status"
+            emptyLabel="Any"
+            options={statusOptions}
+            selected={selectedStatuses}
+            onChange={onStatusesChange}
+          />
+          <MultiSelect
+            label="Template"
+            emptyLabel="Any"
+            options={templateOptions}
+            selected={selectedTemplates}
+            onChange={onTemplatesChange}
+            searchable
+          />
+          <Input
+            value={hostname}
+            onChange={(e) => onHostnameChange(e.target.value)}
+            placeholder="Search hostname…"
+            aria-label="Search by hostname"
+            className="h-8 w-48"
+          />
+        </div>
+      )}
+    </div>
+  );
+}
diff --git a/create-a-container/client/src/components/containers/HttpLinks.tsx b/create-a-container/client/src/components/containers/HttpLinks.tsx
new file mode 100644
index 00000000..2880b409
--- /dev/null
+++ b/create-a-container/client/src/components/containers/HttpLinks.tsx
@@ -0,0 +1,31 @@
+import { ExternalLink } from 'lucide-react';
+import type { Container } from '@/lib/types';
+import { linkClass } from './shared';
+
+/** External links for a container's HTTP services, optionally capped at `limit`. */
+export function HttpLinks({ c, limit }: { c: Container; limit?: number }) {
+  if (c.httpEntries.length === 0) return <span className="text-muted-foreground">—</span>;
+  const entries = limit ? c.httpEntries.slice(0, limit) : c.httpEntries;
+  return (
+    <span className="inline-flex flex-wrap items-center gap-x-3 gap-y-0.5">
+      {entries.map((h) =>
+        h.externalUrl ? (
+          <a
+            key={`${c.id}-${h.port}`}
+            href={h.externalUrl}
+            target="_blank"
+            rel="noreferrer"
+            className={`inline-flex items-center gap-1 text-xs ${linkClass}`}
+          >
+            <ExternalLink className="size-3 shrink-0" aria-hidden="true" />
+            <span className="break-all">{h.externalUrl.replace(/^https?:\/\//, '')}</span>
+          </a>
+        ) : (
+          <span key={`${c.id}-${h.port}`} className="text-xs">
+            :{h.port}
+          </span>
+        ),
+      )}
+    </span>
+  );
+}
diff --git a/create-a-container/client/src/components/containers/Meta.tsx b/create-a-container/client/src/components/containers/Meta.tsx
new file mode 100644
index 00000000..be74c6bd
--- /dev/null
+++ b/create-a-container/client/src/components/containers/Meta.tsx
@@ -0,0 +1,13 @@
+import type { ReactNode } from 'react';
+
+/** Inline labelled value used for container card metadata (Node, User, HTTP, …). */
+export function Meta({ label, children }: { label: string; children: ReactNode }) {
+  return (
+    <span className="inline-flex items-baseline gap-1.5">
+      <span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
+        {label}
+      </span>
+      <span className="text-xs">{children}</span>
+    </span>
+  );
+}
diff --git a/create-a-container/client/src/components/containers/NodeLink.tsx b/create-a-container/client/src/components/containers/NodeLink.tsx
new file mode 100644
index 00000000..498af3b3
--- /dev/null
+++ b/create-a-container/client/src/components/containers/NodeLink.tsx
@@ -0,0 +1,18 @@
+import type { Container } from '@/lib/types';
+import { linkClass } from './shared';
+
+/** Node name linked to the node's Proxmox web UI (LXC view when provisioned). */
+export function NodeLink({ c }: { c: Container }) {
+  if (!c.nodeApiUrl) return <>{c.nodeName || '—'}</>;
+  return (
+    <a
+      href={`${c.nodeApiUrl}${c.containerId ? `/#v1:0:=lxc%2F${c.containerId}:4:::::::` : ''}`}
+      target="_blank"
+      rel="noopener noreferrer"
+      title="Open node in Proxmox web UI"
+      className={linkClass}
+    >
+      {c.nodeName || c.nodeApiUrl}
+    </a>
+  );
+}
diff --git a/create-a-container/client/src/pages/containers/ResourcesSection.tsx b/create-a-container/client/src/components/containers/ResourcesSection.tsx
similarity index 100%
rename from create-a-container/client/src/pages/containers/ResourcesSection.tsx
rename to create-a-container/client/src/components/containers/ResourcesSection.tsx
diff --git a/create-a-container/client/src/components/containers/RowActions.tsx b/create-a-container/client/src/components/containers/RowActions.tsx
new file mode 100644
index 00000000..0a21add2
--- /dev/null
+++ b/create-a-container/client/src/components/containers/RowActions.tsx
@@ -0,0 +1,68 @@
+import { Link } from 'react-router';
+import { Button } from '@mieweb/ui';
+import { Pencil, Share2, Trash2 } from 'lucide-react';
+import type { Container } from '@/lib/types';
+
+/** Per-row actions for the containers list: logs, share, edit, delete. */
+export function RowActions({
+  c,
+  siteId,
+  onDelete,
+  deleting,
+  canShare,
+  onShare,
+}: {
+  c: Container;
+  siteId?: string;
+  onDelete: (id: number) => void;
+  deleting: boolean;
+  canShare: boolean;
+  onShare: (c: Container) => void;
+}) {
+  return (
+    <>
+      {c.creationJobId && (
+        <Link to={`/jobs/${c.creationJobId}`}>
+          <Button variant="ghost" size="sm">
+            Logs
+          </Button>
+        </Link>
+      )}
+      {canShare && (
+        <Button
+          variant="ghost"
+          size="sm"
+          aria-label={`Share ${c.hostname}`}
+          leftIcon={<Share2 className="size-4" />}
+          onClick={() => onShare(c)}
+        >
+          <span className="hidden sm:inline">Share</span>
+        </Button>
+      )}
+      <Link to={`/sites/${siteId}/containers/${c.id}/edit`}>
+        <Button
+          variant="ghost"
+          size="sm"
+          aria-label="Edit"
+          leftIcon={<Pencil className="size-4" />}
+        >
+          <span className="hidden sm:inline">Edit</span>
+        </Button>
+      </Link>
+      <Button
+        variant="ghost"
+        size="sm"
+        aria-label="Delete"
+        leftIcon={<Trash2 className="size-4" />}
+        onClick={() => {
+          if (confirm(`Delete container "${c.hostname}"?`)) onDelete(c.id);
+        }}
+        disabled={deleting}
+        // Danger tint on hover only; the built-in ghost transition still applies.
+        className="hover:bg-red-100 hover:text-red-700 dark:hover:bg-red-900/40 dark:hover:text-red-200"
+      >
+        <span className="hidden sm:inline">Delete</span>
+      </Button>
+    </>
+  );
+}
diff --git a/create-a-container/client/src/components/containers/SshLinks.tsx b/create-a-container/client/src/components/containers/SshLinks.tsx
new file mode 100644
index 00000000..9e6bae2a
--- /dev/null
+++ b/create-a-container/client/src/components/containers/SshLinks.tsx
@@ -0,0 +1,39 @@
+import { Code2, Terminal } from 'lucide-react';
+import type { Container } from '@/lib/types';
+import { linkClass } from './shared';
+
+/** A container's SSH endpoint with VS Code Remote and terminal deep links. */
+export function SshLinks({ c, sessionUser }: { c: Container; sessionUser?: string }) {
+  if (!c.sshHost || !c.sshPort) return <span className="text-muted-foreground">—</span>;
+  return (
+    <span className="inline-flex items-center gap-2 font-mono text-xs">
+      <span className="whitespace-nowrap">
+        {c.sshHost}:{c.sshPort}
+      </span>
+      {sessionUser && (
+        <>
+          <a
+            href={`vscode://vscode-remote/ssh-remote+${sessionUser}@${c.sshHost}:${c.sshPort}/`}
+            target="_blank"
+            rel="noopener noreferrer"
+            title="Open in VS Code"
+            aria-label={`Open SSH in VS Code for container ${c.hostname}`}
+            className={linkClass}
+          >
+            <Code2 className="size-4" aria-hidden="true" />
+          </a>
+          <a
+            href={`ssh://${sessionUser}@${c.sshHost}:${c.sshPort}`}
+            target="_blank"
+            rel="noopener noreferrer"
+            title="Open SSH in terminal"
+            aria-label={`Open SSH terminal for container ${c.hostname}`}
+            className={linkClass}
+          >
+            <Terminal className="size-4" aria-hidden="true" />
+          </a>
+        </>
+      )}
+    </span>
+  );
+}
diff --git a/create-a-container/client/src/components/containers/StatusBadge.tsx b/create-a-container/client/src/components/containers/StatusBadge.tsx
new file mode 100644
index 00000000..f9ef7058
--- /dev/null
+++ b/create-a-container/client/src/components/containers/StatusBadge.tsx
@@ -0,0 +1,35 @@
+import { Badge } from '@mieweb/ui';
+import type { ContainerStatus } from '@/lib/types';
+
+// Human-readable labels for the live status values.
+export const STATUS_LABELS: Record<ContainerStatus, string> = {
+  running: 'Running',
+  offline: 'Offline',
+  creating: 'Creating',
+  failed: 'Failed',
+  missing: 'Missing',
+  unknown: 'Unknown',
+};
+
+function statusVariant(
+  s: ContainerStatus,
+): 'default' | 'success' | 'warning' | 'danger' | 'secondary' {
+  switch (s) {
+    case 'running':
+      return 'success';
+    case 'creating':
+      return 'warning';
+    case 'failed':
+      return 'danger';
+    case 'offline':
+    case 'missing':
+      return 'secondary';
+    default:
+      return 'default';
+  }
+}
+
+/** Status badge. The status is the live value embedded in the list response. */
+export function StatusBadge({ status }: { status: ContainerStatus }) {
+  return <Badge variant={statusVariant(status)}>{STATUS_LABELS[status] ?? status}</Badge>;
+}
diff --git a/create-a-container/client/src/components/containers/shared.ts b/create-a-container/client/src/components/containers/shared.ts
new file mode 100644
index 00000000..1ff74f6d
--- /dev/null
+++ b/create-a-container/client/src/components/containers/shared.ts
@@ -0,0 +1,8 @@
+/** Link styling shared by the container link components. */
+export const linkClass = 'text-(--color-primary,#1d4ed8) hover:underline';
+
+/** Shorten a full image ref to just its name+tag, e.g. ghcr.io/mieweb/base:latest -> base:latest */
+export function templateTitle(template: string | null): string {
+  if (!template) return '—';
+  return template.split('/').pop() || template;
+}
diff --git a/create-a-container/client/src/lib/queries.ts b/create-a-container/client/src/lib/queries.ts
index c413027e..1177f3f5 100644
--- a/create-a-container/client/src/lib/queries.ts
+++ b/create-a-container/client/src/lib/queries.ts
@@ -26,7 +26,7 @@ export const keys = {
   nodes: (siteId: number | string) => ['sites', String(siteId), 'nodes'] as const,
   node: (siteId: number | string, id: number | string) =>
     ['sites', String(siteId), 'nodes', String(id)] as const,
-  containers: (siteId: number | string, params?: Record<string, string | undefined>) =>
+  containers: (siteId: number | string, params?: Record<string, string | string[] | undefined>) =>
     ['sites', String(siteId), 'containers', params ?? {}] as const,
   container: (siteId: number | string, id: number | string) =>
     ['sites', String(siteId), 'containers', String(id)] as const,
@@ -63,13 +63,13 @@ export const queries = {
   // Containers
   listContainers: (
     siteId: number | string,
-    params?: { user?: string; nodeId?: string; hostname?: string },
+    params?: { user?: string[]; nodeId?: string; hostname?: string },
   ) => {
     const qs = new URLSearchParams();
-    // `user` is sent verbatim, including '*' (all owners, admin only) and ''
-    // (own containers). Only omit it when undefined so the default still maps
-    // to the requesting user server-side.
-    if (params?.user !== undefined) qs.set('user', params.user);
+    // `user` is sent in bracket notation (user[0]=..., user[1]=...) so the
+    // server's 'extended' query parser always receives an array. Omitted/empty
+    // returns everything the caller may see server-side.
+    params?.user?.forEach((u, i) => qs.set(`user[${i}]`, u));
     if (params?.nodeId) qs.set('nodeId', params.nodeId);
     if (params?.hostname) qs.set('hostname', params.hostname);
     const suffix = qs.toString() ? `?${qs.toString()}` : '';
@@ -77,6 +77,17 @@ export const queries = {
   },
   getContainer: (siteId: number | string, id: number | string) =>
     api.get<Container>(`/api/v1/sites/${siteId}/containers/${id}`),
+  // Container sharing (collaborators). Both return the updated collaborator
+  // username list.
+  shareContainer: (siteId: number | string, id: number | string, username: string) =>
+    api.post<{ collaborators: string[] }>(
+      `/api/v1/sites/${siteId}/containers/${id}/collaborators`,
+      { username },
+    ),
+  unshareContainer: (siteId: number | string, id: number | string, username: string) =>
+    api.delete<{ collaborators: string[] }>(
+      `/api/v1/sites/${siteId}/containers/${id}/collaborators/${encodeURIComponent(username)}`,
+    ),
   containerBootstrap: (siteId: number | string) =>
     api.get<ContainerNewBootstrap>(`/api/v1/sites/${siteId}/containers/new`),
   containerMetadata: (siteId: number | string, image: string) =>
diff --git a/create-a-container/client/src/lib/types.ts b/create-a-container/client/src/lib/types.ts
index dcd62bfe..357da690 100644
--- a/create-a-container/client/src/lib/types.ts
+++ b/create-a-container/client/src/lib/types.ts
@@ -74,6 +74,8 @@ export interface Container {
   containerId: number | null;
   hostname: string;
   owner: string;
+  /** Usernames this container is shared with (collaborators). */
+  collaborators: string[];
   ipv4Address: string | null;
   macAddress: string | null;
   status: ContainerStatus;
diff --git a/create-a-container/client/src/pages/containers/ContainerFormPage.tsx b/create-a-container/client/src/pages/containers/ContainerFormPage.tsx
index 87ec7ebe..a05c5d4b 100644
--- a/create-a-container/client/src/pages/containers/ContainerFormPage.tsx
+++ b/create-a-container/client/src/pages/containers/ContainerFormPage.tsx
@@ -25,7 +25,8 @@ import { api, ApiError } from '@/lib/api';
 import { keys, queries } from '@/lib/queries';
 import { FormPageHeader } from '@/components/FormPageHeader';
 import { randomHostname } from '@/lib/randomHostname';
-import { ResourcesSection } from './ResourcesSection';
+import { ResourcesSection } from '@/components/containers/ResourcesSection';
+import { AddCollaboratorField, CollaboratorChips, CollaboratorsManager } from '@/components/containers/CollaboratorsManager';
 import type { ContainerCreateResult, ContainerMetadata } from '@/lib/types';
 
 function useDebouncedValue<T>(value: T, delay = 500): T {
@@ -85,6 +86,9 @@ const schema = z.object({
   restart: z.boolean().optional(),
   services: z.array(serviceSchema),
   environmentVars: z.array(envVarSchema),
+  // Usernames to share a new container with (collaborators). Existence is
+  // validated server-side on submit. Unused in edit mode (live manager instead).
+  collaborators: z.array(z.string()),
 });
 type FormData = z.infer<typeof schema>;
 
@@ -121,12 +125,15 @@ export function ContainerFormPage() {
     defaultValues: {
       services: [],
       environmentVars: [],
+      collaborators: [],
       nvidiaRequested: false,
       restart: false,
     },
   });
   const services = useFieldArray({ control, name: 'services' });
   const envVars = useFieldArray({ control, name: 'environmentVars' });
+  // Guards the one-time form initialization from the loaded container (edit).
+  const initializedRef = useRef(false);
 
   // Default external domain for new HTTP services. The bootstrap endpoint
   // returns domains already sorted so the site's default domains come first,
@@ -143,9 +150,15 @@ export function ContainerFormPage() {
   const hostname = watch('hostname');
   const debouncedHostname = useDebouncedValue(hostname || '', 500);
   const customTemplate = watch('customTemplate');
+  const collaborators = watch('collaborators') || [];
 
   useEffect(() => {
-    if (container && isEdit) {
+    if (container && isEdit && !initializedRef.current) {
+      // Initialize the form from the loaded container exactly once. Re-running
+      // reset on every `container` change would wipe unsaved edits whenever the
+      // container query refetches (e.g. after the Sharing manager adds/removes a
+      // collaborator and invalidates this query).
+      initializedRef.current = true;
       reset({
         hostname: container.hostname,
         template: container.template || '',
@@ -173,6 +186,7 @@ export function ContainerFormPage() {
           key,
           value,
         })),
+        collaborators: [],
       });
     }
   }, [container, isEdit, reset]);
@@ -271,6 +285,8 @@ export function ContainerFormPage() {
         services: servicesObj,
         environmentVars: values.environmentVars.filter((e) => e.key.trim()),
         restart: values.restart,
+        // Only meaningful on create; the edit form manages sharing live.
+        collaborators: values.collaborators,
       };
       type UpdateResult = {
         containerId: number;
@@ -666,6 +682,51 @@ export function ContainerFormPage() {
               </div>
             ))}
           </CardContent>
+        </Card>
+        <Card padding="none" className={sectionCardClass}>
+          <CardHeader className={sectionHeaderClass}>
+            <CardTitle className="text-base">Sharing</CardTitle>
+          </CardHeader>
+          <CardContent className={sectionContentClass}>
+            {isEdit && container ? (
+              <>
+                <p className="text-sm text-muted-foreground">
+                  Share this container with other users for collaboration. They will see it in
+                  their All containers tab.
+                </p>
+                <CollaboratorsManager
+                  siteId={siteId!}
+                  containerId={container.id}
+                  collaborators={container.collaborators}
+                />
+              </>
+            ) : (
+              <>
+                <p className="text-sm text-muted-foreground">
+                  Optionally add other users as collaborators. They will see this container in
+                  their All containers tab once it is created.
+                </p>
+                <CollaboratorChips
+                  usernames={collaborators}
+                  emptyText="No collaborators."
+                  onRemove={(u) =>
+                    setValue(
+                      'collaborators',
+                      collaborators.filter((x) => x !== u),
+                    )
+                  }
+                />
+                <AddCollaboratorField
+                  label="Collaborator"
+                  onAdd={(u) => {
+                    if (!collaborators.includes(u)) {
+                      setValue('collaborators', [...collaborators, u]);
+                    }
+                  }}
+                />
+              </>
+            )}
+          </CardContent>
           <CardFooter className="flex flex-wrap justify-end gap-2 border-t border-border bg-muted/30 px-6 py-3">
             <Button
               type="button"
diff --git a/create-a-container/client/src/pages/containers/ContainersListPage.tsx b/create-a-container/client/src/pages/containers/ContainersListPage.tsx
index ffe35d0d..fc5cd04d 100644
--- a/create-a-container/client/src/pages/containers/ContainersListPage.tsx
+++ b/create-a-container/client/src/pages/containers/ContainersListPage.tsx
@@ -1,14 +1,18 @@
-import { useState } from 'react';
+import { useMemo, useState } from 'react';
 import { Link, useLocation, useParams, useSearchParams } from 'react-router';
 import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
 import {
   Alert,
   AlertDescription,
   AlertTitle,
-  Badge,
   Button,
   Card,
   CardTitle,
+  Modal,
+  ModalBody,
+  ModalClose,
+  ModalHeader,
+  ModalTitle,
   PageHeader,
   Spinner,
   Table,
@@ -20,196 +24,30 @@ import {
   useToast,
 } from '@mieweb/ui';
 import {
-  Code2,
   Container as ContainerIcon,
-  ExternalLink,
   LayoutGrid,
-  Pencil,
   Plus,
   Rows3,
   Server,
-  Terminal,
-  Trash2,
   User,
 } from 'lucide-react';
 import { api, ApiError } from '@/lib/api';
 import { useSession } from '@/lib/auth';
 import { keys, queries } from '@/lib/queries';
+import { HttpLinks } from '@/components/containers/HttpLinks';
+import { Meta } from '@/components/containers/Meta';
+import { NodeLink } from '@/components/containers/NodeLink';
+import { RowActions } from '@/components/containers/RowActions';
+import { STATUS_LABELS, StatusBadge } from '@/components/containers/StatusBadge';
+import { SshLinks } from '@/components/containers/SshLinks';
+import { templateTitle } from '@/components/containers/shared';
+import { CollaboratorsManager } from '@/components/containers/CollaboratorsManager';
+import { ContainerFilters, type FilterOption } from '@/components/containers/ContainerFilters';
 import type { Container, ContainerStatus } from '@/lib/types';
 
 type ViewMode = 'cards' | 'table';
 const VIEW_STORAGE_KEY = 'containers:view';
 
-function statusVariant(
-  s: ContainerStatus,
-): 'default' | 'success' | 'warning' | 'danger' | 'secondary' {
-  switch (s) {
-    case 'running':
-      return 'success';
-    case 'creating':
-      return 'warning';
-    case 'failed':
-      return 'danger';
-    case 'offline':
-    case 'missing':
-      return 'secondary';
-    default:
-      return 'default';
-  }
-}
-
-// Human-readable labels for the live status values.
-const STATUS_LABELS: Record<ContainerStatus, string> = {
-  running: 'Running',
-  offline: 'Offline',
-  creating: 'Creating',
-  failed: 'Failed',
-  missing: 'Missing',
-  unknown: 'Unknown',
-};
-
-/** Status badge. The status is the live value embedded in the list response. */
-function StatusBadge({ status }: { status: ContainerStatus }) {
-  return <Badge variant={statusVariant(status)}>{STATUS_LABELS[status] ?? status}</Badge>;
-}
-
-const linkClass = 'text-(--color-primary,#1d4ed8) hover:underline';
-
-/** Shorten a full image ref to just its name+tag, e.g. ghcr.io/mieweb/base:latest -> base:latest */
-function templateTitle(template: string | null): string {
-  if (!template) return '—';
-  return template.split('/').pop() || template;
-}
-
-function NodeLink({ c }: { c: Container }) {
-  if (!c.nodeApiUrl) return <>{c.nodeName || '—'}</>;
-  return (
-    <a
-      href={`${c.nodeApiUrl}${c.containerId ? `/#v1:0:=lxc%2F${c.containerId}:4:::::::` : ''}`}
-      target="_blank"
-      rel="noopener noreferrer"
-      title="Open node in Proxmox web UI"
-      className={linkClass}
-    >
-      {c.nodeName || c.nodeApiUrl}
-    </a>
-  );
-}
-
-function HttpLinks({ c, limit }: { c: Container; limit?: number }) {
-  if (c.httpEntries.length === 0) return <span className="text-muted-foreground">—</span>;
-  const entries = limit ? c.httpEntries.slice(0, limit) : c.httpEntries;
-  return (
-    <span className="inline-flex flex-wrap items-center gap-x-3 gap-y-0.5">
-      {entries.map((h) =>
-        h.externalUrl ? (
-          <a
-            key={`${c.id}-${h.port}`}
-            href={h.externalUrl}
-            target="_blank"
-            rel="noreferrer"
-            className={`inline-flex items-center gap-1 text-xs ${linkClass}`}
-          >
-            <ExternalLink className="size-3 shrink-0" aria-hidden="true" />
-            <span className="break-all">{h.externalUrl.replace(/^https?:\/\//, '')}</span>
-          </a>
-        ) : (
-          <span key={`${c.id}-${h.port}`} className="text-xs">
-            :{h.port}
-          </span>
-        ),
-      )}
-    </span>
-  );
-}
-
-function SshLinks({ c, sessionUser }: { c: Container; sessionUser?: string }) {
-  if (!c.sshHost || !c.sshPort) return <span className="text-muted-foreground">—</span>;
-  return (
-    <span className="inline-flex items-center gap-2 font-mono text-xs">
-      <span className="whitespace-nowrap">
-        {c.sshHost}:{c.sshPort}
-      </span>
-      {sessionUser && (
-        <>
-          <a
-            href={`vscode://vscode-remote/ssh-remote+${sessionUser}@${c.sshHost}:${c.sshPort}/`}
-            target="_blank"
-            rel="noopener noreferrer"
-            title="Open in VS Code"
-            aria-label={`Open SSH in VS Code for container ${c.hostname}`}
-            className={linkClass}
-          >
-            <Code2 className="size-4" aria-hidden="true" />
-          </a>
-          <a
-            href={`ssh://${sessionUser}@${c.sshHost}:${c.sshPort}`}
-            target="_blank"
-            rel="noopener noreferrer"
-            title="Open SSH in terminal"
-            aria-label={`Open SSH terminal for container ${c.hostname}`}
-            className={linkClass}
-          >
-            <Terminal className="size-4" aria-hidden="true" />
-          </a>
-        </>
-      )}
-    </span>
-  );
-}
-
-function RowActions({
-  c,
-  siteId,
-  onDelete,
-  deleting,
-}: {
-  c: Container;
-  siteId?: string;
-  onDelete: (id: number) => void;
-  deleting: boolean;
-}) {
-  return (
-    <>
-      {c.creationJobId && (
-        <Link to={`/jobs/${c.creationJobId}`}>
-          <Button variant="ghost" size="sm">
-            Logs
-          </Button>
-        </Link>
-      )}
-      <Link to={`/sites/${siteId}/containers/${c.id}/edit`}>
-        <Button variant="ghost" size="sm" aria-label="Edit" leftIcon={<Pencil className="size-4" />}>
-          <span className="hidden sm:inline">Edit</span>
-        </Button>
-      </Link>
-      <Button
-        variant="ghost"
-        size="sm"
-        aria-label="Delete"
-        leftIcon={<Trash2 className="size-4" />}
-        onClick={() => {
-          if (confirm(`Delete container "${c.hostname}"?`)) onDelete(c.id);
-        }}
-        disabled={deleting}
-      >
-        <span className="hidden sm:inline">Delete</span>
-      </Button>
-    </>
-  );
-}
-
-function Meta({ label, children }: { label: string; children: React.ReactNode }) {
-  return (
-    <span className="inline-flex items-baseline gap-1.5">
-      <span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
-        {label}
-      </span>
-      <span className="text-xs">{children}</span>
-    </span>
-  );
-}
-
 export function ContainersListPage() {
   const { siteId } = useParams<{ siteId: string }>();
   const qc = useQueryClient();
@@ -218,24 +56,62 @@ export function ContainersListPage() {
   const sessionUser = session?.user;
   const isAdmin = !!session?.isAdmin;
   const location = useLocation();
-  const [searchParams] = useSearchParams();
+  const [searchParams, setSearchParams] = useSearchParams();
   const nodeId = searchParams.get('nodeId') || undefined;
-  // `user=*` lists every owner on the site for admins; for non-admins the
-  // server scopes it back to their own containers, so the toggle is available
-  // to everyone (ahead of future shareable/collaborative containers). The param
-  // is the single source of truth so the view is bookmarkable and slots in
-  // beside the existing hostname/nodeId filters.
-  const isAll = searchParams.get('user') === '*';
-  const userFilter = isAll ? '*' : undefined;
-  // Only admins ever see more than one owner in the `*` view, so the owner
-  // column is meaningful for them alone.
-  const showOwner = isAdmin && isAll;
+  // The URL is the single source of truth for the filter bar, so views are
+  // bookmarkable and shareable. `user` is a comma-separated owner list that
+  // drives the server query; an empty list returns everything the caller may
+  // see (all owners for admins; own + shared for non-admins).
+  // Status/template/hostname refine the loaded rows client-side.
+  const parseList = (v: string | null) => (v ? v.split(',').filter(Boolean) : []);
+  const selectedUsers = parseList(searchParams.get('user'));
+  const selectedStatuses = parseList(searchParams.get('status'));
+  const selectedTemplates = parseList(searchParams.get('template'));
+  const hostnameQuery = searchParams.get('q') ?? '';
+  // A row may belong to another owner unless the filter is narrowed to just
+  // the caller, so the owner column only disappears then.
+  const showOwner = selectedUsers.length === 0 || selectedUsers.some((u) => u !== sessionUser);
   const dnsWarnings = (location.state as { dnsWarnings?: string[] } | null)?.dnsWarnings;
 
+  const setListParam = (key: string, values: string[]) =>
+    setSearchParams(
+      (prev) => {
+        const next = new URLSearchParams(prev);
+        if (values.length > 0) next.set(key, values.join(','));
+        else next.delete(key);
+        return next;
+      },
+      { replace: true },
+    );
+  const setTextParam = (key: string, value: string) =>
+    setSearchParams(
+      (prev) => {
+        const next = new URLSearchParams(prev);
+        if (value) next.set(key, value);
+        else next.delete(key);
+        return next;
+      },
+      { replace: true },
+    );
+  // The unfiltered default already shows everything the caller may see;
+  // select-all in the dropdown is just an explicit form of the same thing.
+  const clearAllFilters = () =>
+    setSearchParams(
+      (prev) => {
+        const next = new URLSearchParams(prev);
+        ['user', 'status', 'template', 'q'].forEach((k) => next.delete(k));
+        return next;
+      },
+      { replace: true },
+    );
+
   const [view, setView] = useState<ViewMode>(() => {
     const stored = typeof window !== 'undefined' ? window.localStorage.getItem(VIEW_STORAGE_KEY) : null;
     return stored === 'table' ? 'table' : 'cards';
   });
+  // Container whose sharing dialog is open. Tracked by id so the dialog reflects
+  // live collaborator changes after the list query refetches.
+  const [shareTargetId, setShareTargetId] = useState<number | null>(null);
   const changeView = (next: ViewMode) => {
     setView(next);
     try {
@@ -250,12 +126,30 @@ export function ContainersListPage() {
     queryFn: () => queries.getSite(siteId!),
     enabled: !!siteId,
   });
+  // Empty selection returns everything the caller may see server-side.
+  const userParam = selectedUsers.length > 0 ? selectedUsers : undefined;
   const { data, isLoading, error } = useQuery({
-    queryKey: keys.containers(siteId!, { user: userFilter, nodeId }),
-    queryFn: () => queries.listContainers(siteId!, { user: userFilter, nodeId }),
+    queryKey: keys.containers(siteId!, { user: userParam, nodeId }),
+    queryFn: () => queries.listContainers(siteId!, { user: userParam, nodeId }),
     enabled: !!siteId,
   });
 
+  // Options for the "User" filter. Admins may filter by any user; non-admins
+  // may only narrow to owners who have shared a container with them, so their
+  // option list is derived from the unfiltered list (own + shared). With no
+  // owner filter selected this is the same query as the list above, so React
+  // Query dedupes it into a single fetch.
+  const { data: allUsers } = useQuery({
+    queryKey: keys.users(),
+    queryFn: queries.listUsers,
+    enabled: !!siteId && isAdmin,
+  });
+  const { data: visibleContainers } = useQuery({
+    queryKey: keys.containers(siteId!, {}),
+    queryFn: () => queries.listContainers(siteId!),
+    enabled: !!siteId && !isAdmin,
+  });
+
   const del = useMutation({
     mutationFn: (id: number) => api.delete(`/api/v1/sites/${siteId}/containers/${id}`),
     onSuccess: () => {
@@ -265,33 +159,73 @@ export function ContainersListPage() {
     onError: (err: ApiError) => toast.error(err.message),
   });
 
-  const hasContainers = !!data && data.length > 0;
+  // Client-side refinement of the loaded rows by status/template/hostname.
+  const visible = useMemo(() => {
+    const q = hostnameQuery.trim().toLowerCase();
+    return (data ?? []).filter(
+      (c) =>
+        (selectedStatuses.length === 0 || selectedStatuses.includes(c.status)) &&
+        (selectedTemplates.length === 0 ||
+          (c.template != null && selectedTemplates.includes(c.template))) &&
+        (q === '' || c.hostname.toLowerCase().includes(q)),
+    );
+  }, [data, selectedStatuses, selectedTemplates, hostnameQuery]);
+
+  const userOptions = useMemo<FilterOption[]>(() => {
+    const byLabel = (a: FilterOption, b: FilterOption) => a.label.localeCompare(b.label);
+    if (isAdmin) {
+      const opts = (allUsers ?? []).map((u) => ({
+        value: u.uid,
+        label: u.uid === sessionUser ? `${u.cn} (me)` : u.cn,
+      }));
+      return opts.sort(byLabel);
+    }
+    const owners = new Set<string>();
+    if (sessionUser) owners.add(sessionUser);
+    (visibleContainers ?? []).forEach((c) => owners.add(c.owner));
+    const opts = [...owners].map((o) => ({
+      value: o,
+      label: o === sessionUser ? `${o} (me)` : o,
+    }));
+    return opts.sort(byLabel);
+  }, [isAdmin, allUsers, visibleContainers, sessionUser]);
+
+  const statusOptions = useMemo<FilterOption[]>(
+    () =>
+      (Object.keys(STATUS_LABELS) as ContainerStatus[]).map((s) => ({
+        value: s,
+        label: STATUS_LABELS[s],
+      })),
+    [],
+  );
+
+  const templateOptions = useMemo<FilterOption[]>(() => {
+    const seen = new Map<string, string>();
+    (data ?? []).forEach((c) => {
+      if (c.template) seen.set(c.template, templateTitle(c.template));
+    });
+    return [...seen.entries()]
+      .map(([value, label]) => ({ value, label }))
+      .sort((a, b) => a.label.localeCompare(b.label));
+  }, [data]);
+
+  const hasContainers = visible.length > 0;
+  const serverHasContainers = !!data && data.length > 0;
+
+  // Sharing is owner/admin only. Derive the open dialog's container from the
+  // live list so its collaborator chips update after add/remove.
+  const canShareContainer = (c: Container) => isAdmin || c.owner === sessionUser;
+  const shareTarget = data?.find((c) => c.id === shareTargetId) ?? null;
 
   return (
     <div className="flex flex-col gap-6">
       <PageHeader
-        title={isAll ? 'All Containers' : 'Containers'}
+        title="Containers"
         subtitle={site ? `Site: ${site.name}` : undefined}
         icon={<ContainerIcon className="size-6" />}
         actions={
           <div className="flex flex-wrap items-center gap-2">
-            <div
-              role="group"
-              aria-label="Container ownership scope"
-              className="inline-flex rounded-md border border-border p-0.5"
-            >
-              <Link to={`/sites/${siteId}/containers`} aria-label="My containers">
-                <Button variant={isAll ? 'ghost' : 'secondary'} size="sm" aria-pressed={!isAll}>
-                  Mine
-                </Button>
-              </Link>
-              <Link to={`/sites/${siteId}/containers?user=*`} aria-label="All containers">
-                <Button variant={isAll ? 'secondary' : 'ghost'} size="sm" aria-pressed={isAll}>
-                  All
-                </Button>
-              </Link>
-            </div>
-            {hasContainers && (
+            {serverHasContainers && (
               <div
                 role="group"
                 aria-label="Container view"
@@ -333,6 +267,21 @@ export function ContainersListPage() {
         }
       />
 
+      <ContainerFilters
+        userOptions={userOptions}
+        selectedUsers={selectedUsers}
+        onUsersChange={(v) => setListParam('user', v)}
+        statusOptions={statusOptions}
+        selectedStatuses={selectedStatuses}
+        onStatusesChange={(v) => setListParam('status', v)}
+        templateOptions={templateOptions}
+        selectedTemplates={selectedTemplates}
+        onTemplatesChange={(v) => setListParam('template', v)}
+        hostname={hostnameQuery}
+        onHostnameChange={(v) => setTextParam('q', v)}
+        onClearAll={clearAllFilters}
+      />
+
       {error && (
         <Alert variant="danger">
           <AlertDescription>{(error as ApiError).message}</AlertDescription>
@@ -359,16 +308,22 @@ export function ContainersListPage() {
         <Alert variant="info">
           <AlertTitle>No containers</AlertTitle>
           <AlertDescription>
-            {showOwner
-              ? 'No containers exist on this site yet.'
+            {selectedUsers.length > 0
+              ? 'No containers match the selected users.'
               : 'Create your first container with the button above.'}
           </AlertDescription>
         </Alert>
       )}
+      {serverHasContainers && !hasContainers && (
+        <Alert variant="info">
+          <AlertTitle>No matches</AlertTitle>
+          <AlertDescription>No containers match the current filters.</AlertDescription>
+        </Alert>
+      )}
 
       {hasContainers && view === 'cards' && (
         <div className="grid gap-2">
-          {data.map((c: Container) => (
+          {visible.map((c: Container) => (
             <Card
               key={c.id}
               as="article"
@@ -383,7 +338,14 @@ export function ContainersListPage() {
                 <StatusBadge status={c.status} />
               </div>
               <div className="ml-auto flex shrink-0 items-center gap-1 lg:order-3 lg:ml-0">
-                <RowActions c={c} siteId={siteId} onDelete={del.mutate} deleting={del.isPending} />
+                <RowActions
+                  c={c}
+                  siteId={siteId}
+                  onDelete={del.mutate}
+                  deleting={del.isPending}
+                  canShare={canShareContainer(c)}
+                  onShare={(target) => setShareTargetId(target.id)}
+                />
               </div>
               <div className="flex w-full min-w-0 flex-wrap items-center gap-x-4 gap-y-1 lg:order-2 lg:w-auto lg:flex-1">
                 <Meta label="Node">
@@ -429,7 +391,7 @@ export function ContainersListPage() {
             </TableRow>
           </TableHeader>
           <TableBody>
-            {data.map((c: Container) => (
+            {visible.map((c: Container) => (
               <TableRow key={c.id}>
                 <TableCell className="font-medium">{c.hostname}</TableCell>
                 <TableCell>
@@ -456,13 +418,46 @@ export function ContainersListPage() {
                   <SshLinks c={c} sessionUser={sessionUser} />
                 </TableCell>
                 <TableCell className="flex flex-wrap justify-end gap-2">
-                  <RowActions c={c} siteId={siteId} onDelete={del.mutate} deleting={del.isPending} />
+                  <RowActions
+                    c={c}
+                    siteId={siteId}
+                    onDelete={del.mutate}
+                    deleting={del.isPending}
+                    canShare={canShareContainer(c)}
+                    onShare={(target) => setShareTargetId(target.id)}
+                  />
                 </TableCell>
               </TableRow>
             ))}
           </TableBody>
         </Table>
       )}
+
+      <Modal
+        open={shareTarget !== null}
+        onOpenChange={(open) => !open && setShareTargetId(null)}
+        size="md"
+      >
+        <ModalHeader>
+          <ModalTitle>
+            {shareTarget ? `Share ${shareTarget.hostname}` : 'Share container'}
+          </ModalTitle>
+          <ModalClose />
+        </ModalHeader>
+        <ModalBody className="flex flex-col gap-4">
+          <p className="text-sm text-muted-foreground">
+            Share this container with other users for collaboration. Shared users can find it
+            by filtering the containers list by your username.
+          </p>
+          {shareTarget && siteId && (
+            <CollaboratorsManager
+              siteId={siteId}
+              containerId={shareTarget.id}
+              collaborators={shareTarget.collaborators}
+            />
+          )}
+        </ModalBody>
+      </Modal>
     </div>
   );
 }
diff --git a/create-a-container/migrations/20260630120000-create-container-collaborators.js b/create-a-container/migrations/20260630120000-create-container-collaborators.js
new file mode 100644
index 00000000..05bdf058
--- /dev/null
+++ b/create-a-container/migrations/20260630120000-create-container-collaborators.js
@@ -0,0 +1,52 @@
+'use strict';
+
+/**
+ * Container sharing: a join table between containers and the additional users
+ * (collaborators) who have been granted access to a container. Membership is
+ * keyed by `username` (the user's `uid`) to mirror how a container records its
+ * primary owner (`Containers.username`).
+ *
+ * @type {import('sequelize-cli').Migration}
+ */
+module.exports = {
+  async up(queryInterface, Sequelize) {
+    await queryInterface.createTable('ContainerCollaborators', {
+      id: {
+        allowNull: false,
+        autoIncrement: true,
+        primaryKey: true,
+        type: Sequelize.INTEGER,
+      },
+      containerId: {
+        type: Sequelize.INTEGER,
+        allowNull: false,
+        references: { model: 'Containers', key: 'id' },
+        onDelete: 'CASCADE',
+        onUpdate: 'CASCADE',
+      },
+      username: {
+        type: Sequelize.STRING(255),
+        allowNull: false,
+      },
+      createdAt: {
+        allowNull: false,
+        type: Sequelize.DATE,
+      },
+      updatedAt: {
+        allowNull: false,
+        type: Sequelize.DATE,
+      },
+    });
+
+    // A user can be a collaborator on a given container at most once.
+    await queryInterface.addConstraint('ContainerCollaborators', {
+      fields: ['containerId', 'username'],
+      type: 'unique',
+      name: 'container_collaborators_container_username_unique',
+    });
+  },
+
+  async down(queryInterface) {
+    await queryInterface.dropTable('ContainerCollaborators');
+  },
+};
diff --git a/create-a-container/models/container-collaborator.js b/create-a-container/models/container-collaborator.js
new file mode 100644
index 00000000..5ec49f39
--- /dev/null
+++ b/create-a-container/models/container-collaborator.js
@@ -0,0 +1,44 @@
+'use strict';
+const { Model } = require('sequelize');
+
+module.exports = (sequelize, DataTypes) => {
+  /**
+   * A grant giving a user (the `username`/uid) access to a container they do
+   * not own — i.e. a shared/collaborative container. The container's primary
+   * owner is still `Container.username`; this table only records the extra
+   * collaborators.
+   */
+  class ContainerCollaborator extends Model {
+    static associate(models) {
+      ContainerCollaborator.belongsTo(models.Container, {
+        foreignKey: 'containerId',
+        as: 'container',
+      });
+    }
+  }
+  ContainerCollaborator.init(
+    {
+      containerId: {
+        type: DataTypes.INTEGER,
+        allowNull: false,
+        references: { model: 'Containers', key: 'id' },
+      },
+      username: {
+        type: DataTypes.STRING(255),
+        allowNull: false,
+      },
+    },
+    {
+      sequelize,
+      modelName: 'ContainerCollaborator',
+      indexes: [
+        {
+          name: 'container_collaborators_container_username_unique',
+          unique: true,
+          fields: ['containerId', 'username'],
+        },
+      ],
+    },
+  );
+  return ContainerCollaborator;
+};
diff --git a/create-a-container/models/container.js b/create-a-container/models/container.js
index b6bfc83e..2f36acc7 100644
--- a/create-a-container/models/container.js
+++ b/create-a-container/models/container.js
@@ -18,6 +18,36 @@ module.exports = (sequelize, DataTypes) => {
       Container.belongsTo(models.Site, { foreignKey: 'siteId', as: 'site' });
       // a container may have a creation job
       Container.belongsTo(models.Job, { foreignKey: 'creationJobId', as: 'creationJob' });
+      // a container may be shared with additional users (collaborators)
+      Container.hasMany(models.ContainerCollaborator, {
+        foreignKey: 'containerId',
+        as: 'collaborators',
+        onDelete: 'CASCADE',
+      });
+    }
+
+    /**
+     * Whether a user may view/use this container: its primary owner or a
+     * collaborator it is shared with. Requires the `collaborators` association
+     * to be eager-loaded. Admin overrides live at the route layer (admin status
+     * is a session property, not derivable from a username here).
+     * @param {string} username - The candidate user's uid.
+     * @returns {boolean}
+     */
+    canView(username) {
+      if (this.username === username) return true;
+      return (this.collaborators || []).some((c) => c.username === username);
+    }
+
+    /**
+     * Whether a user may edit this container — manage its sharing (add/remove
+     * collaborators) or delete it: only its primary owner. Collaborators can
+     * use a shared container but cannot re-share or delete it.
+     * @param {string} username - The candidate user's uid.
+     * @returns {boolean}
+     */
+    canEdit(username) {
+      return this.username === username;
     }
 
     /**
diff --git a/create-a-container/openapi.v1.yaml b/create-a-container/openapi.v1.yaml
index 22e57415..043dcab8 100644
--- a/create-a-container/openapi.v1.yaml
+++ b/create-a-container/openapi.v1.yaml
@@ -314,13 +314,17 @@ paths:
         - { in: query, name: nodeId, schema: { type: integer }, description: Filter to a single node belonging to the site }
         - in: query
           name: user
-          schema: { type: string }
+          schema:
+            type: array
+            items: { type: string }
           description: >-
-            Owner filter. Omitted/empty returns the requesting user's own
-            containers (default). `*` returns every owner on the site for admins,
-            or just your own containers for non-admins. A specific username
-            returns that owner's containers and is admin-only (except requesting
-            yourself), yielding 403 otherwise.
+            Owner filter, sent in bracket notation (`user[0]=alice&user[1]=bob`)
+            so it always parses as a list. Omitted/empty returns everything the
+            caller may see: every container on the site for admins, or their own
+            plus any shared with them for non-admins. A list of usernames
+            returns those owners' containers for admins; for non-admins the list
+            is intersected with what they may already see (own plus shared), so
+            it can only narrow visibility.
       responses:
         '200': { description: Array of containers }
         '403': { description: 'Admin access required', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
@@ -339,6 +343,10 @@ paths:
                 customTemplate: { type: string }
                 entrypoint: { type: string }
                 nvidiaRequested: { type: boolean }
+                collaborators:
+                  type: array
+                  items: { type: string }
+                  description: Usernames to share the new container with (must exist)
                 environmentVars:
                   type: array
                   items: { type: object, properties: { key: { type: string }, value: { type: string } } }
@@ -369,6 +377,38 @@ paths:
     get: { tags: [Containers], responses: { '200': { description: Container } } }
     put: { tags: [Containers], responses: { '200': { description: Updated, optional restart job } } }
     delete: { tags: [Containers], responses: { '200': { description: Deleted, with DNS cleanup warnings } } }
+  /sites/{siteId}/containers/{id}/collaborators:
+    parameters:
+      - { in: path, name: siteId, required: true, schema: { type: integer } }
+      - { in: path, name: id, required: true, schema: { type: integer } }
+    get:
+      tags: [Containers]
+      summary: List users a container is shared with (owner/collaborator/admin)
+      responses: { '200': { description: '{ collaborators: string[] }' } }
+    post:
+      tags: [Containers]
+      summary: Share a container with another user (owner/admin)
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              type: object
+              required: [username]
+              properties: { username: { type: string } }
+      responses:
+        '201': { description: '{ collaborators: string[] }' }
+        '404': { description: 'user_not_found when the username does not exist', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
+        '409': { description: 'already_shared / already_owner', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
+  /sites/{siteId}/containers/{id}/collaborators/{username}:
+    delete:
+      tags: [Containers]
+      summary: Stop sharing a container with a user (owner/admin)
+      parameters:
+        - { in: path, name: siteId, required: true, schema: { type: integer } }
+        - { in: path, name: id, required: true, schema: { type: integer } }
+        - { in: path, name: username, required: true, schema: { type: string } }
+      responses: { '200': { description: '{ collaborators: string[] }' } }
 
   /sites/{siteId}/nodes:
     parameters: [{ in: path, name: siteId, required: true, schema: { type: integer } }]
diff --git a/create-a-container/routers/api/v1/containers.js b/create-a-container/routers/api/v1/containers.js
index a80fd3fe..6084b095 100644
--- a/create-a-container/routers/api/v1/containers.js
+++ b/create-a-container/routers/api/v1/containers.js
@@ -6,6 +6,7 @@
 const express = require('express');
 const {
   Container,
+  ContainerCollaborator,
   Service,
   HTTPService,
   TransportService,
@@ -15,6 +16,7 @@ const {
   ExternalDomain,
   Job,
   Setting,
+  User,
   Sequelize,
   sequelize,
 } = require('../../../models');
@@ -85,8 +87,13 @@ function normalizeDockerRef(ref) {
   return `${host}/${org}/${image}:${tag}`;
 }
 
-async function loadSite(req) {
-  const site = await Site.findByPk(parseInt(req.params.siteId, 10));
+/**
+ * Load a site by id (typically `req.params.siteId`) or 404.
+ * @param {*} siteId - Candidate site id; parsed as a base-10 integer.
+ * @returns {Promise<object>} The Site instance.
+ */
+async function loadSite(siteId) {
+  const site = await Site.findByPk(parseInt(siteId, 10));
   if (!site) throw new ApiError(404, 'site_not_found', 'Site not found');
   return site;
 }
@@ -117,6 +124,11 @@ function serializeContainer(c, site, status) {
     // "all containers" view can show a User column; for the per-user list it
     // simply equals the requesting user.
     owner: c.username,
+    // Additional users this container is shared with. Sorted for a stable UI;
+    // present on every payload so consumers can render/manage sharing.
+    collaborators: (c.collaborators || [])
+      .map((x) => x.username)
+      .sort((a, b) => a.localeCompare(b)),
     ipv4Address: c.ipv4Address,
     macAddress: c.macAddress,
     // Live status computed from Proxmox + jobs + config (see utils/container-status).
@@ -176,18 +188,34 @@ const CONTAINER_INCLUDE = [
   { association: 'node' },
   // Eager-load the create job so status resolution needs no per-container query.
   { association: 'creationJob' },
+  // Users the container is shared with, for the serializer's `collaborators`.
+  { association: 'collaborators' },
 ];
 
 /**
- * Build the `where` clause for a container list query, scoped to the given
- * site's nodes and narrowed by the supported query-string filters
- * (`hostname`, `nodeId`). The `nodeId` filter is intersected with the site's
- * own nodes so it can never widen the result set beyond the site.
+ * Build the `where` clause for a container list query — the single arbiter of
+ * what a list request may return. Scopes to the given site's nodes, narrows by
+ * the supported query-string filters (`hostname`, `nodeId`, `user`), and
+ * enforces ownership visibility, folding in shared containers.
+ *
+ * The `nodeId` filter is intersected with the site's own nodes so it can never
+ * widen the result set beyond the site.
+ *
+ * `query.user` must be an array (callers default it with `req.query.user ??=
+ * []`; clients send bracket notation, e.g. `user[0]=alice`, which the
+ * 'extended' query parser coerces to an array). Empty -> everything the caller
+ * may see: every container on the site for admins, their own plus any shared
+ * with them for non-admins. A list of names -> those owners for admins; for
+ * non-admins the same list intersected with what they may already see (own
+ * plus shared), so a non-admin can only narrow down to owners who have shared
+ * a container with them and never widen their visibility.
+ *
  * @param {object} query - req.query
  * @param {number[]} nodeIds - IDs of the nodes belonging to the site
+ * @param {object} session - req.session ({ user, isAdmin })
  * @returns {object} Sequelize where clause
  */
-function buildContainerListWhere(query, nodeIds) {
+function buildContainerListWhere(query, nodeIds, session) {
   const where = { nodeId: nodeIds };
   if (query.hostname) where.hostname = query.hostname;
   if (query.nodeId) {
@@ -196,34 +224,106 @@ function buildContainerListWhere(query, nodeIds) {
     // otherwise force an empty result rather than leaking other sites' nodes.
     where.nodeId = Number.isInteger(nodeId) && nodeIds.includes(nodeId) ? nodeId : -1;
   }
+
+  const names = query.user;
+  if (session.isAdmin) {
+    // Admins may see every owner on the site; a name list simply narrows it.
+    if (names.length > 0) where.username = names;
+    return where;
+  }
+  // Non-admins may only see their own containers plus any shared with them.
+  // The shared set is expressed as an `IN (SELECT …)` subquery so the whole
+  // visibility check resolves inside the main containers query — a single
+  // round trip — instead of a separate lookup. (A plain JOIN on the
+  // eager-loaded `collaborators` association would filter the joined rows and
+  // truncate the serialized collaborator list, so a subquery is used.) It is
+  // built with the dialect's query generator so identifier quoting and value
+  // escaping stay correct across sqlite/mysql/postgres; selectQuery emits a
+  // trailing ';' which is invalid inside IN (…), hence the slice.
+  const shared = sequelize.dialect.queryGenerator
+    .selectQuery(
+      ContainerCollaborator.getTableName(),
+      { attributes: ['containerId'], where: { username: session.user } },
+      ContainerCollaborator,
+    )
+    .slice(0, -1);
+  const visible = [
+    { username: session.user },
+    { id: { [Sequelize.Op.in]: Sequelize.literal(`(${shared})`) } },
+  ];
+  if (names.length === 0) {
+    where[Sequelize.Op.or] = visible;
+  } else {
+    // Intersect the requested owners with what the caller may already see.
+    where[Sequelize.Op.and] = [{ [Sequelize.Op.or]: visible }, { username: names }];
+  }
   return where;
 }
 
 /**
- * Resolve the `user` list filter into a Sequelize `username` constraint,
- * enforcing authorization. The rules mirror the existing `hostname` filter in
- * shape (a plain query param) but add ownership scoping:
- *   - absent/empty -> the requesting user's own containers (default for all)
- *   - '*'          -> every owner on the site (admin), or just your own (non-admin)
- *   - '<username>' -> that specific owner (admin only, unless it's yourself)
- * Non-admins may use `*`, but it is scoped to their own containers rather than
- * returning a 403. This keeps the "All" toggle usable for everyone today and
- * leaves room for future shareable/collaborative containers to widen what a
- * non-admin sees here. Requesting a specific *other* owner still 403s.
- * @param {string|undefined} requestedUser - req.query.user
- * @param {{ user: string, isAdmin: boolean }} session - req.session
- * @returns {string|undefined} username to filter by, or undefined for "all"
+ * Load a container by `:id` scoped to the request's site and authorize the
+ * session against it. Returns 404 (not 403) on any failure so the route never
+ * leaks the existence of containers the caller may not see.
+ * @param {object} req - Express request.
+ * @param {object} [opts]
+ * @param {boolean} [opts.requireManage=false] - Require owner/admin (sharing,
+ *   delete) rather than the looser view/edit access.
+ * @param {Array} [opts.include] - Override the eager-load graph.
+ * @returns {Promise<{site: object, container: object}>}
  */
-function resolveUsernameFilter(requestedUser, session) {
-  if (!requestedUser) return session.user;
-  if (requestedUser === '*') {
-    // Admins see every owner; non-admins are scoped to their own containers.
-    return session.isAdmin ? undefined : session.user;
+async function loadContainerForSession(req, { requireManage = false, include } = {}) {
+  const site = await loadSite(req.params.siteId);
+  const id = parseInt(req.params.id, 10);
+  if (!Number.isInteger(id) || id <= 0) {
+    throw new ApiError(404, 'not_found', 'Container not found');
   }
-  if (requestedUser !== session.user && !session.isAdmin) {
-    throw new ApiError(403, 'forbidden', 'Admin access required');
+  const container = await Container.findByPk(id, {
+    include: include || [{ association: 'node' }, { association: 'collaborators' }],
+  });
+  if (!container || !container.node || container.node.siteId !== site.id) {
+    throw new ApiError(404, 'not_found', 'Container not found');
+  }
+  const authorized =
+    req.session.isAdmin ||
+    (requireManage
+      ? container.canEdit(req.session.user)
+      : container.canView(req.session.user));
+  if (!authorized) throw new ApiError(404, 'not_found', 'Container not found');
+  return { site, container };
+}
+
+/**
+ * Validate a username to share a container with. Confirms the user exists and
+ * is not already the container's primary owner, and returns the canonical uid.
+ * @param {*} rawUsername - Candidate username from the request body.
+ * @param {object} container - The container being shared.
+ * @param {object} [opts] - Optional { transaction } for the lookup.
+ * @returns {Promise<string>} The canonical uid to store as a collaborator.
+ */
+async function resolveShareUsername(rawUsername, container, { transaction } = {}) {
+  const username = typeof rawUsername === 'string' ? rawUsername.trim() : '';
+  if (!username) throw new ApiError(400, 'invalid_request', 'A username is required');
+  const user = await User.findOne({ where: { uid: username }, transaction });
+  if (!user) throw new ApiError(404, 'user_not_found', `User "${username}" does not exist`);
+  if (user.uid === container.username) {
+    throw new ApiError(409, 'already_owner', `${user.uid} already owns this container`);
   }
-  return requestedUser;
+  return user.uid;
+}
+
+/**
+ * Load the current collaborator usernames for a container, sorted for a stable
+ * UI. Shared shape returned by the share/unshare endpoints.
+ * @param {number} containerId
+ * @returns {Promise<string[]>}
+ */
+async function listCollaboratorUsernames(containerId) {
+  const rows = await ContainerCollaborator.findAll({
+    where: { containerId },
+    attributes: ['username'],
+    order: [['username', 'ASC']],
+  });
+  return rows.map((r) => r.username);
 }
 
 // GET /containers/metadata?image=...
@@ -250,7 +350,7 @@ router.get(
 router.get(
   '/new',
   asyncHandler(async (req, res) => {
-    const site = await loadSite(req);
+    const site = await loadSite(req.params.siteId);
     const externalDomains = await site.getSortedExternalDomains();
     const nvidiaAvailable =
       (await Node.count({ where: { siteId: site.id, nvidiaAvailable: true } })) > 0;
@@ -262,16 +362,15 @@ router.get(
 router.get(
   '/',
   asyncHandler(async (req, res) => {
-    const site = await loadSite(req);
+    const site = await loadSite(req.params.siteId);
     const nodes = await Node.findAll({ where: { siteId: site.id }, attributes: ['id'] });
     const nodeIds = nodes.map((n) => n.id);
-    const where = buildContainerListWhere(req.query, nodeIds);
-    // `user` filter: defaults to the requesting user, so everyone (incl. admins)
-    // sees their own containers by default. `user=*` lists every owner for admins
-    // and stays scoped to the requester for non-admins; `user=<name>` targets a
-    // specific owner (admin-only). undefined means "all owners".
-    const username = resolveUsernameFilter(req.query.user, req.session);
-    if (username !== undefined) where.username = username;
+    // `user` filter backs the list page's User filter: omitted, it returns
+    // everything the caller may see (all owners for admins; own + shared for
+    // non-admins); `user[0]=<name>` narrows to specific owners. See
+    // buildContainerListWhere.
+    req.query.user ??= [];
+    const where = buildContainerListWhere(req.query, nodeIds, req.session);
     const rows = await Container.findAll({ where, include: CONTAINER_INCLUDE });
     // Resolve live statuses for the whole page in one pass: one Proxmox snapshot
     // per node (shared), and no per-container DB queries (create job is loaded above).
@@ -287,7 +386,7 @@ router.get(
 router.get(
   '/:id',
   asyncHandler(async (req, res) => {
-    const site = await loadSite(req);
+    const site = await loadSite(req.params.siteId);
     // Guard against non-numeric ids (e.g. "undefined", "NaN"): parseInt would
     // yield NaN and reach the DB as an invalid integer comparison, surfacing as
     // a 500. Treat anything non-numeric as "not found".
@@ -296,10 +395,15 @@ router.get(
       throw new ApiError(404, 'not_found', 'Container not found');
     }
     const c = await Container.findOne({
-      where: { id: containerId, username: req.session.user },
+      where: { id: containerId },
       include: CONTAINER_INCLUDE,
     });
-    if (!c || !c.node || c.node.siteId !== site.id) {
+    if (
+      !c ||
+      !c.node ||
+      c.node.siteId !== site.id ||
+      !(req.session.isAdmin || c.canView(req.session.user))
+    ) {
       throw new ApiError(404, 'not_found', 'Container not found');
     }
     const status = await computeContainerStatus({ container: c, Job });
@@ -311,7 +415,7 @@ router.get(
 router.post(
   '/',
   asyncHandler(async (req, res) => {
-    const site = await loadSite(req);
+    const site = await loadSite(req.params.siteId);
     const t = await sequelize.transaction();
     try {
       let {
@@ -322,6 +426,7 @@ router.post(
         environmentVars,
         entrypoint,
         nvidiaRequested,
+        collaborators,
       } = req.body || {};
 
       if (!hostname || !hostname.trim()) throw new ApiError(400, 'invalid_request', 'hostname is required');
@@ -385,6 +490,25 @@ router.post(
         { transaction: t },
       );
 
+      // Collaborators the creator chose to share with. Each must be an existing
+      // user and is validated up front so a typo fails the whole create (rolled
+      // back) rather than silently dropping a share. The creator is the owner
+      // already, so skip them if they list themselves.
+      if (Array.isArray(collaborators) && collaborators.length > 0) {
+        const seen = new Set();
+        for (const raw of collaborators) {
+          const trimmed = typeof raw === 'string' ? raw.trim() : '';
+          if (!trimmed || trimmed === container.username) continue;
+          const username = await resolveShareUsername(raw, container, { transaction: t });
+          if (seen.has(username)) continue;
+          seen.add(username);
+          await ContainerCollaborator.create(
+            { containerId: container.id, username },
+            { transaction: t },
+          );
+        }
+      }
+
       if (services && typeof services === 'object') {
         for (const key in services) {
           const svc = services[key];
@@ -461,12 +585,17 @@ router.post(
 router.put(
   '/:id',
   asyncHandler(async (req, res) => {
-    const site = await loadSite(req);
+    const site = await loadSite(req.params.siteId);
     const container = await Container.findOne({
-      where: { id: parseInt(req.params.id, 10), username: req.session.user },
-      include: [{ model: Node, as: 'node', where: { siteId: site.id } }],
+      where: { id: parseInt(req.params.id, 10) },
+      include: [
+        { model: Node, as: 'node', where: { siteId: site.id } },
+        { association: 'collaborators' },
+      ],
     });
-    if (!container) throw new ApiError(404, 'not_found', 'Container not found');
+    if (!container || !(req.session.isAdmin || container.canView(req.session.user))) {
+      throw new ApiError(404, 'not_found', 'Container not found');
+    }
 
     const { services, environmentVars, entrypoint } = req.body || {};
     const forceRestart = req.body?.restart === true || req.body?.restart === 'true';
@@ -611,11 +740,12 @@ router.put(
 router.delete(
   '/:id',
   asyncHandler(async (req, res) => {
-    const site = await loadSite(req);
+    const site = await loadSite(req.params.siteId);
     const container = await Container.findOne({
-      where: { id: parseInt(req.params.id, 10), username: req.session.user },
+      where: { id: parseInt(req.params.id, 10) },
       include: [
         { model: Node, as: 'node' },
+        { association: 'collaborators' },
         {
           model: Service,
           as: 'services',
@@ -632,6 +762,11 @@ router.delete(
     if (!container || !container.node || container.node.siteId !== site.id) {
       throw new ApiError(404, 'not_found', 'Container not found');
     }
+    // Deleting is owner/admin only; collaborators may use but not destroy a
+    // shared container.
+    if (!(req.session.isAdmin || container.canEdit(req.session.user))) {
+      throw new ApiError(404, 'not_found', 'Container not found');
+    }
     const node = container.node;
     let dnsWarnings = [];
     const httpServices = (container.services || [])
@@ -664,6 +799,9 @@ router.delete(
         console.log(`Node-side deletion skipped or failed: ${err.message}`);
       }
     }
+    // Remove sharing grants explicitly so the rows are gone regardless of
+    // whether the DB enforces the ON DELETE CASCADE foreign key.
+    await ContainerCollaborator.destroy({ where: { containerId: container.id } });
     await container.destroy();
 
     // Remove the VM from NetBox if the integration is configured
@@ -675,4 +813,44 @@ router.delete(
   }),
 );
 
+// GET /containers/:id/collaborators — list users a container is shared with.
+// Visible to anyone who can access the container (owner, collaborator, admin).
+router.get(
+  '/:id/collaborators',
+  asyncHandler(async (req, res) => {
+    const { container } = await loadContainerForSession(req);
+    return ok(res, { collaborators: await listCollaboratorUsernames(container.id) });
+  }),
+);
+
+// POST /containers/:id/collaborators — share with another user (owner/admin).
+// Body: { username }. 404 user_not_found if the username doesn't exist.
+router.post(
+  '/:id/collaborators',
+  asyncHandler(async (req, res) => {
+    const { container } = await loadContainerForSession(req, { requireManage: true });
+    const username = await resolveShareUsername(req.body?.username, container);
+    const [, isNew] = await ContainerCollaborator.findOrCreate({
+      where: { containerId: container.id, username },
+    });
+    if (!isNew) {
+      throw new ApiError(409, 'already_shared', `Already shared with ${username}`);
+    }
+    return created(res, { collaborators: await listCollaboratorUsernames(container.id) });
+  }),
+);
+
+// DELETE /containers/:id/collaborators/:username — stop sharing (owner/admin).
+router.delete(
+  '/:id/collaborators/:username',
+  asyncHandler(async (req, res) => {
+    const { container } = await loadContainerForSession(req, { requireManage: true });
+    const removed = await ContainerCollaborator.destroy({
+      where: { containerId: container.id, username: req.params.username },
+    });
+    if (!removed) throw new ApiError(404, 'not_found', 'Collaborator not found');
+    return ok(res, { collaborators: await listCollaboratorUsernames(container.id) });
+  }),
+);
+
 module.exports = router;
diff --git a/create-a-container/server.js b/create-a-container/server.js
index 650384b3..145d903f 100644
--- a/create-a-container/server.js
+++ b/create-a-container/server.js
@@ -39,6 +39,9 @@ async function main() {
   app.set('views', path.join(__dirname, 'views'));
   app.set('view engine', 'ejs');
   app.set('trust proxy', 1);
+  // Parse query strings with qs so bracket notation (e.g. `user[0]=alice`)
+  // yields real arrays. Express 5 defaults to the 'simple' parser.
+  app.set('query parser', 'extended');
 
   // setup middleware
   const accessLogStream = process.env.ACCESS_LOG
