The source decides what is shared
The share creator sets:
- Which directory to share (source_path)
- Access mode (readonly or readwrite)
- Who can access (1-to-1 or project-wide)
- When it expires (optional)
- Enable/disable anytime
Share a directory from one container to others, including containers on a different physical server. That covers multi-service applications, team collaboration, and data exchange without duplicating files.
Storage shares API:
Create and manage shares:
Receive and mount shares:
Storage shares use a two-party system:
The source decides what is shared
The share creator sets:
The target decides whether to mount
The share receiver controls:
Key principle: the source controls what is shared; the target controls whether it is mounted.
Share one directory with a single target container:
# Create 1-to-1 container share (readonly)hoody storage create --container $SOURCE_ID \ --source-path "/hoody/storage/shared-assets" \ --target-container-id $TARGET_ID \ --mode readonly \ --description "Static assets for frontend container"const share = await client.api.storageShares.create(SOURCE_CONTAINER_ID, { source_path: '/hoody/storage/shared-assets', target_container_id: TARGET_CONTAINER_ID, mode: 'readonly', description: 'Static assets for frontend container'});console.log(share.data.id); // Share ID for mountingcurl -X POST "https://api.hoody.com/api/v1/containers/$SOURCE_ID/storage/shares" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "source_path": "/hoody/storage/shared-assets", "target_container_id": "'$TARGET_ID'", "mode": "readonly", "description": "Static assets for frontend container" }'One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Creates the 1-to-1 share described above as a single GET link.
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://api.hoody.com/api/v1/containers/SOURCE_ID/storage/shares&method=POST&bearer_token=TOKEN&json={"source_path":"/hoody/storage/shared-assets","target_container_id":"TARGET_ID","mode":"readonly","description":"Static%20assets%20for%20frontend%20container"}&response=transparent The link carries a credential and executes with it, so it is as sensitive as the credential itself — and it passes through the cURL service's request log on the way, not just the target's. Share it only where you would share the secret, and prefer a delegated token with minimal permissions and an expiry: see API tokens.
Use when:
Share a directory with every container in a project:
# Create project-wide share: all containers in project can accesshoody storage create --container $SOURCE_ID \ --source-path "/hoody/storage/config" \ --target-project-id $PROJECT_ID \ --mode readonly \ --description "Shared configuration for all services"const share = await client.api.storageShares.create(SOURCE_CONTAINER_ID, { source_path: '/hoody/storage/config', target_project_id: PROJECT_ID, mode: 'readonly', description: 'Shared configuration for all services'});// Every container in the project automatically mounts this sharecurl -X POST "https://api.hoody.com/api/v1/containers/$SOURCE_ID/storage/shares" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "source_path": "/hoody/storage/config", "target_project_id": "'$PROJECT_ID'", "mode": "readonly", "description": "Shared configuration for all services" }'One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Creates the project-wide share described above as a single GET link.
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://api.hoody.com/api/v1/containers/SOURCE_ID/storage/shares&method=POST&bearer_token=TOKEN&json={"source_path":"/hoody/storage/config","target_project_id":"PROJECT_ID","mode":"readonly","description":"Shared%20configuration%20for%20all%20services"}&response=transparent The link carries a credential and executes with it, so it is as sensitive as the credential itself — and it passes through the cURL service's request log on the way, not just the target's. Share it only where you would share the secret, and prefer a delegated token with minimal permissions and an expiry: see API tokens.
Every container in the project mounts this share by default.
Use when:
{ "mode": "readonly"}Target containers can:
Suited to:
{ "mode": "readwrite"}Target containers can:
Changes visible to:
Suited to:
/hoody/databases/)Read-write can be paused without failing the share. A cross-server readwrite share remains
active when read-write publication is paused, but do not assume reads remain available: an
already-live share may remain readable, whereas a share degraded during initial publication is
not mounted for consumers. The share still returns 201. After creation, retrieve it with
GET /api/v1/containers/{id}/storage/shares/{shareId} and check status_message first; for a
newly paused publication it gives the required action: set a disk size limit when the owner
container has no enforced quota; remove a nested subvolume that escapes the quota; wait for the
owner host to regain enforced disk quotas when the host itself cannot enforce them. Read-write
publication resumes automatically after any of those conditions is fixed. During reconciliation of
an already-published share, an owner-host quota-enforce capability miss can instead leave
status_message null or unchanged even though the live export is rendered read-only.
Cross-server read-write behavior is conditional. When CROSS_HOST_NFS_ENABLED=true but
CROSS_HOST_NFS_ALLOW_RW is not exactly true, creating a readwrite share that would span
hosts is rejected with HTTP 422 before the share is created; same-host read-write shares are
unaffected. When cross-server read-write is enabled, quota enforcement can instead pause
publication without failing the share.
# In source containermkdir -p /hoody/storage/team-assets
# Add filescp logo.png /hoody/storage/team-assets/cp styles.css /hoody/storage/team-assets/ Response (201) returns the full share object, including id (the new share’s ID) needed for mounting.
Share now accessible in /hoody/shares/{share_alias}/.
# In target containerls /hoody/shares/shared-assets/# logo.png styles.css
# Files from source container, accessible in targetcat /hoody/shares/shared-assets/styles.cssWhen a target container mounts a share, the files appear at:
/hoody/shares/{share-alias}/Example:
# Source creates share with alias "config"POST /storage/shares{ "source_path": "/hoody/storage/app-config", "alias": "config"}
# Target mounts sharePATCH /api/v1/containers/{target_id}/storage/incoming/{share_id}/mount{"mount": true}
# Files now accessible at:/hoody/shares/config/├── app.yaml├── database.json└── secrets.envThe alias is reflected in the mount point name. Hoody’s infrastructure determines the exact mount path, and the caller cannot set it directly.
A backend shares its upload directory with several frontends:
# Backend Container (source)POST /containers/{backend_id}/storage/shares{ "source_path": "/hoody/storage/user-uploads", "target_project_id": "{project_id}", "mode": "readwrite", "alias": "uploads"}
# Frontend Container 1 (accepts)PATCH /containers/{frontend_1}/storage/incoming/{share_id}/mount{"mount": true}
# Frontend Container 2 (accepts)PATCH /containers/{frontend_2}/storage/incoming/{share_id}/mount{"mount": true}
# Now all three containers see same /hoody/shares/uploads/# Upload from any frontend → visible to backend and other frontendsA config container shares settings with all services in readonly mode:
# Config ContainerPOST /containers/{config_id}/storage/shares{ "source_path": "/hoody/storage/production-config", "target_project_id": "{project_id}", "mode": "readonly", "alias": "config"}
# All service containers mount it# Services read from /hoody/shares/config/# Only config container can update (others readonly)Two developers share a workspace between their containers:
# Developer A's containerPOST /containers/{dev_a}/storage/shares{ "source_path": "/home/user/project", "target_container_id": "{dev_b_container}", "mode": "readwrite", "alias": "shared-project"}
# Developer B mounts in their container# Both containers read and write the same files# A write from one is visible to the otherServices share their logs with a monitoring container in readonly mode:
# Service 1POST /storage/shares{ "source_path": "/hoody/storage/service1/logs", "target_container_id": "{monitor_container}", "mode": "readonly"}
# Service 2POST /storage/shares{ "source_path": "/hoody/storage/service2/logs", "target_container_id": "{monitor_container}", "mode": "readonly"}
# Monitor container sees all logs/hoody/shares/service1-logs//hoody/shares/service2-logs/A share reports one of these statuses:
| Status | Description | Next Steps |
|---|---|---|
active | The share specification is retained. This is not proof that it is enabled, unexpired, mounted, or writable | Check enabled, expires_at, and status_message |
failed | Mount failed (see status_message) | Check errors, fix, retry |
active means “this share specification is retained”, not “everything landed”. A share stays
active while cross-server read-write publication is paused or provisioning is deferred to the
background reconciler; those conditions are explained in status_message. It can also stay active
while a consumer is unmounted, but status_message does not report per-consumer mount state.
Check status:
GET /api/v1/containers/{id}/storage/shares/{shareId}
# Response includes: "status": "active" AND "status_message"# status_message is null when nothing is degraded. Read it, not just status.The source container can disable a share without deleting it:
# Disable share temporarilyPATCH /api/v1/containers/{source_id}/storage/shares/{share_id}{ "enabled": false, "description": "Temporarily disabled for maintenance"}
# Files unmount from target containers# Share configuration preserved
# Re-enable laterPATCH /api/v1/containers/{source_id}/storage/shares/{share_id}{ "enabled": true}Use for: maintenance windows, testing, and gradual rollouts.
Set an expiration timestamp at creation:
POST /api/v1/containers/{id}/storage/shares{ "source_path": "/hoody/storage/temp-files", "target_container_id": "{target}", "mode": "readonly", "expires_at": 1735689600 # Unix timestamp: 2025-01-01}
# Share auto-unmounts and notifies before expirySuited to: temporary access, demo environments, and time-limited shares.
# Shares you created from specific containerhoody storage list --container $SOURCE_ID
# All shares you created (across all containers)hoody storage list-all// Shares from specific containerconst shares = await client.api.storageShares.list(SOURCE_CONTAINER_ID);console.log(shares.data); // Array of shares you created
// All shares across all containersconst allShares = await client.api.storageShares.listGlobalIterator();# Shares you created from specific containercurl "https://api.hoody.com/api/v1/containers/$SOURCE_ID/storage/shares" \ -H "Authorization: Bearer $TOKEN"
# All shares you created (across all containers)curl "https://api.hoody.com/api/v1/storage/shares" \ -H "Authorization: Bearer $TOKEN"One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Lists the shares you created, either from one source container or across all of them.
# From one container
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://api.hoody.com/api/v1/containers/SOURCE_ID/storage/shares&method=GET&bearer_token=TOKEN&response=transparent
# All shares you created
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://api.hoody.com/api/v1/storage/shares&method=GET&bearer_token=TOKEN&response=transparent The link carries a credential and executes with it, so it is as sensitive as the credential itself — and it passes through the cURL service's request log on the way, not just the target's. Share it only where you would share the secret, and prefer a delegated token with minimal permissions and an expiry: see API tokens.
# Incoming shares for specific containerhoody storage incoming list --container $TARGET_ID
# All incoming shares (all your containers)hoody storage incoming list-all// Incoming shares for specific containerconst incoming = await client.api.storageShares.listIncoming(TARGET_CONTAINER_ID);console.log(incoming.data); // Shares offered to this container
// All incoming shares across all containersconst allIncoming = await client.api.storageShares.listIncomingGlobalIterator();# Incoming shares for specific containercurl "https://api.hoody.com/api/v1/containers/$TARGET_ID/storage/incoming" \ -H "Authorization: Bearer $TOKEN"
# All incoming shares (all your containers)curl "https://api.hoody.com/api/v1/storage/incoming" \ -H "Authorization: Bearer $TOKEN"One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Lists shares offered to you, either for one target container or across all of them.
# For one container
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://api.hoody.com/api/v1/containers/TARGET_ID/storage/incoming&method=GET&bearer_token=TOKEN&response=transparent
# All incoming shares
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://api.hoody.com/api/v1/storage/incoming&method=GET&bearer_token=TOKEN&response=transparent The link carries a credential and executes with it, so it is as sensitive as the credential itself — and it passes through the cURL service's request log on the way, not just the target's. Share it only where you would share the secret, and prefer a delegated token with minimal permissions and an expiry: see API tokens.
# Upgrade share from readonly to readwritehoody storage update --container $SOURCE_ID --share-id $SHARE_ID \ --mode readwrite --description "Now allows writes"await client.api.storageShares.update(SOURCE_CONTAINER_ID, SHARE_ID, { mode: 'readwrite', description: 'Now allows writes' });curl -X PATCH "https://api.hoody.com/api/v1/containers/$SOURCE_ID/storage/shares/$SHARE_ID" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"mode": "readwrite", "description": "Now allows writes"}'One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Upgrades the share to readwrite as a single GET link.
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://api.hoody.com/api/v1/containers/SOURCE_ID/storage/shares/SHARE_ID&method=PATCH&bearer_token=TOKEN&json={"mode":"readwrite","description":"Now%20allows%20writes"}&response=transparent The link carries a credential and executes with it, so it is as sensitive as the credential itself — and it passes through the cURL service's request log on the way, not just the target's. Share it only where you would share the secret, and prefer a delegated token with minimal permissions and an expiry: see API tokens.
Target containers must remount to pick up the new mode.
# Delete share: unmounts from all target containershoody storage delete $SHARE_ID --yesawait client.api.storageShares.delete(SHARE_ID);// Unmounts from all target containers, configuration deleted permanentlycurl -X DELETE "https://api.hoody.com/api/v1/storage/shares/$SHARE_ID" \ -H "Authorization: Bearer $TOKEN"One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Deletes the share as a single GET link, unmounting it from every target container.
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://api.hoody.com/api/v1/storage/shares/SHARE_ID&method=DELETE&bearer_token=TOKEN&response=transparent The link carries a credential and executes with it, so it is as sensitive as the credential itself — and it passes through the cURL service's request log on the way, not just the target's. Share it only where you would share the secret, and prefer a delegated token with minimal permissions and an expiry: see API tokens.
Storage shares work across different physical servers with full POSIX compliance:
┌──────────────────────────────┐│ Server US-West-1 ││ ┌──────────────────────┐ ││ │ Source Container │ ││ │ /shared/data/ │ ││ └──────────────────────┘ ││ ↓ Hoody handles │└──────────────────────────────┘ ↓ cross-server┌──────────────────────────────┐│ Server EU-Central-1 ││ ┌──────────────────────┐ ││ │ Target Container │ ││ │ /hoody/shares/data/ │ ││ └──────────────────────┘ │└──────────────────────────────┘What you get:
CROSS_HOST_NFS_ENABLED=true, cross-server read-only shares use the normal share workflow; cross-server readwrite also requires CROSS_HOST_NFS_ALLOW_RW=trueThe share remains mounted but inaccessible.
/hoody/shares/{alias}/Best practice: do not rely on shares from containers that stop frequently.
Yes. Create multiple shares from the same source_path:
# Share /hoody/storage/assets with 3 containersPOST /storage/shares {"source_path": "/hoody/storage/assets", "target_container_id": "A"}POST /storage/shares {"source_path": "/hoody/storage/assets", "target_container_id": "B"}POST /storage/shares {"source_path": "/hoody/storage/assets", "target_container_id": "C"}
# Or share once to entire project (all containers see it)POST /storage/shares {"source_path": "/hoody/storage/assets", "target_project_id": "{project}"}Not recommended for SQLite databases. You can share /hoody/databases/ directories, but doing so bypasses the concurrent-write safety:
# NOT Recommended: Sharing /hoody/databases via storage sharesPOST /storage/shares{ "source_path": "/hoody/databases", "mode": "readwrite"}
# Problem: Network-shared SQLite loses local-filesystem optimizations# Better: Each container uses /hoody/databases/ locally (same-server concurrent writes)# Better: Use hoody-sqlite HTTP API for cross-container database accessWhy not recommended:
/hoody/databases/ concurrent-write safety is optimized for local same-server accessBetter solutions:
/hoody/databases/ directly (concurrent-write-safe)Share remains available but unmounted. Target can accept later:
# Initially rejectPATCH /api/v1/containers/{target_id}/storage/incoming/{share_id}/mount{"mount": false}
# Accept later when neededPATCH /api/v1/containers/{target_id}/storage/incoming/{share_id}/mount{"mount": true}
# Files appear in /hoody/shares/{alias}/Yes. Share configuration and mount state survive:
Yes:
PATCH /api/v1/containers/{source_id}/storage/shares/{share_id}{ "alias": "new-alias"}Target containers must unmount and remount to use the new alias. The old mount point /hoody/shares/old-alias/ becomes invalid.
Source container: the files count toward the source’s storage.
Target containers: mounted shares do not count toward the target’s storage quota, because they are references rather than copies.
Problem: Share status is "failed" with error message
Common causes:
Source path doesn’t exist:
# In source container, verify path existsls -la /hoody/storage/shared-path
# Create if missingmkdir -p /hoody/storage/shared-path
# Update share to retryPATCH /api/v1/containers/{source_id}/storage/shares/{share_id}{"enabled": true}Permission issues:
# Fix permissions in source containerchown -R root:root /hoody/storage/shared-pathchmod -R 755 /hoody/storage/shared-pathSource container stopped:
Not failed, but degraded: a share whose read-write is paused stays active, not failed.
If writes are rejected while status reads active, check status_message first. When
populated, it names the required action: set a disk size limit when the owner container has no
enforced quota; remove a nested subvolume that escapes the quota; wait for the owner host to
regain enforced disk quotas when the host itself cannot enforce them. Read-write publication
resumes automatically after any of those conditions is fixed. For an already-published share,
however, an owner-host quota-enforce capability miss can render the export read-only while
deliberately leaving status_message null or unchanged; restore quota enforcement on the owner
host in that case.
Problem: Share mounted but /hoody/shares/{alias}/ is empty
Solutions:
Verify the share is active, and read status_message:
GET /api/v1/containers/{target_id}/storage/incoming# Check: "status": "active", "enabled": true# `status_message` can explain degraded or deferred provisioning.# This response does not report whether this consumer is mounted.Check source container is running:
GET /api/v1/containers/{source_id}# Verify: "status": "running"Verify files exist in source:
# In source containerls /hoody/storage/shared-path# Should show filesUnmount and remount:
PATCH /api/v1/containers/{target_id}/storage/incoming/{share_id}/mount{"mount": false}
PATCH /api/v1/containers/{target_id}/storage/incoming/{share_id}/mount{"mount": true}Problem: Cannot access files in /hoody/shares/{alias}/
Cause: Source container restarted while target was accessing files
Solution:
# Unmount and remountPATCH /api/v1/containers/{target_id}/storage/incoming/{share_id}/mount{"mount": false}
PATCH /api/v1/containers/{target_id}/storage/incoming/{share_id}/mount{"mount": true}
# Or restart target container (auto-remounts)POST /api/v1/containers/{target_id}/restart# (Consolidated lifecycle route: POST /api/v1/containers/{id}/{operation}# where {operation} is one of: start | stop | force-stop | restart | pause | resume)Problem: PATCH /mount succeeds but files don’t appear
Check:
Share is enabled:
GET /api/v1/containers/{target_id}/storage/incoming# Find the share in the list and verify: "enabled": trueShare not expired:
# Check expires_at (Unix timestamp)# If expired, ask source to extend or remove expirationTarget container has permission:
# Good: Specific subdirectory{"source_path": "/hoody/storage/assets"}
# Risky: Entire Hoody Kit storage{"source_path": "/hoody/storage"}
# Sharing entire /hoody/storage exposes all service dataOne share for all containers:
# Instead of creating 10 identical 1-to-1 sharesPOST /storage/shares {"target_project_id": "{project}"}
# All containers in project can mount# Simpler management, one configurationMultiple writers can conflict:
# Container A writes /hoody/shares/data/file.txt# Container B writes /hoody/shares/data/file.txt (same file)
# Last write wins (potential data loss)Solution: use application-level locking, or coordinate writes (for example, a different directory per container).
For SQLite databases, use /hoody/databases/, which is concurrent-write safe.
Deleting a share unmounts it from every target:
# Snapshot source container firstPOST /api/v1/containers/{source_id}/snapshots{"alias": "before-share-deletion"}
# Then delete shareDELETE /api/v1/storage/shares/{share_id}# Clear documentation{ "description": "Read-only access to team logo, CSS, and JS assets for frontend containers. Source: /hoody/storage/static-web-assets"}
# Vague{ "description": "shared files"}This matters once you are managing dozens of shares.
Storage ecosystem:
Related features:
Summary:
/hoody/databases/ for shared SQLite databases