Skip to content
Syno Package Land
Language: English

Container apps

A container app is a Compose project packaged as a DSM package. You keep your compose.yaml and add a small synology-app.yaml. synopkgland build produces an SPK that installs through Package Center and runs your containers with Container Manager. If your app has a web UI, DSM users open it from the DSM desktop through a gateway that checks their DSM session first.

Start from the headless example, which has no web port, one Shared Folder and one DSM user:

Terminal window
synopkgland init -t compose my-app

To package an app you already have, import its Compose file. init copies it to compose.yaml and writes a synology-app.yaml you then edit:

Terminal window
synopkgland init -t compose --from ./compose.yaml my-app

package and displayname come from --package, --name or the directory name. install keeps only the template entries whose variables the imported file references. Then set description and maintainer, add adminport if the app has a web UI, and add an install entry for every other Shared Folder or setting the app needs. See the field reference.

Pin every image by digest. images pin resolves each tag over anonymous registry HTTP, so private registries fail:

Terminal window
synopkgland images pin

This pair is a web app with an install setting. It passes check and build:

compose.yaml
services:
web:
image: traefik/whoami:v1.10.3@sha256:43a68d10b9dfcfc3ffbfe4dd42100dc9aeaf29b3a5636c856337a5940f1b4f1c
command: ["--port=8080"]
environment:
GREETING: "${GREETING}"
ports:
- "127.0.0.1:18080:8080"
volumes:
- "${SYNOPKG_PKGVAR}/data:/data"
synology-app.yaml
schema: 1
package: hello.whoami
version: 0.1.0
displayname: Hello Whoami
description: A small web service behind the DSM gateway.
maintainer: Example Developer
os_min_ver: "7.2.1-69057"
adminport: 18080
adminurl: /
install:
GREETING:
type: string
default: hello
integration:
web:
launch: dsm-window
access: dsm-users

Build and test:

Terminal window
synopkgland check
synopkgland build
CommandWhat it does
checkValidates synology-app.yaml and compose.yaml. It prints the services, published ports and data folders it found.
buildWrites the SPK. Without --arch it builds <package>-x86_64.spk and <package>-aarch64.spk for a web app, and one <package>.spk for a headless app. -o needs a single --arch.
devValidates, runs docker compose up -d, waits until the services are healthy (or running, if there is no healthcheck) and stops them on Ctrl+C.
testValidates, prepares the ${SYNOPKG_PKGVAR} and secrets folders under .synopkgland/, runs up and waits for ready. It leaves the containers running.
deployBuilds the SPK and installs it on a NAS. See deploy --help for --share, --run-as and --param.

dev and test need Docker on your computer.

Set adminport to the host port your container publishes. The package then gets a gateway: DSM opens /webman/3rdparty/<package>/cgi/gateway.cgi/<adminurl>, the gateway checks the DSM session and your access rule, and then forwards the request to the container.

Publish the web port on loopback, as in the example (127.0.0.1:18080:8080). The gateway is the only intended way in. check fails with COMPOSE_GATEWAY_PORT_EXPOSED if the adminport host port listens on any other address. Other ports published on all interfaces are listed in the check report and inspect fails on them. Other processes on the NAS can still reach a loopback port, so do not trust X-Syno-User for anything a local process must not do.

Behind the gateway, your app sees these changes:

  • The gateway sets X-Syno-User (the DSM user name) and X-Syno-Admin (true or false).
  • It drops Cookie, Remote-*, Forwarded, X-Syno-*, X-Forwarded-*, X-Remote-*, X-Auth* and X-Real-* from the request. The integration.web.cookies field therefore has no effect behind the gateway.
  • It removes Set-Cookie from the response, forces Cache-Control: private and adds Vary: Cookie.
  • It rewrites Location headers under the gateway path.
  • WebSocket and Server-Sent Events pass through. Your app sees the browser’s Host on a WebSocket request.
SettingValuesResult
integration.web.launchdsm-window (default), new-pagedsm-window adds a DSM main-menu and desktop entry that opens the app in a new browser tab. new-page adds no entry; Package Center Open opens the app in a new tab.
integration.web.accessdsm-users (default)Any signed-in DSM user.
dsm-adminsMembers of the administrators group.
dsm-groupsMembers of a group in integration.web.groups.
publicEveryone, without signing in. The package uses Web Station instead of the gateway.

Groups are matched literally. Write local names as family, directory groups as 'DOMAIN\group' or group@domain. Quote the backslash form in YAML.

integration:
web:
access: dsm-groups
groups: [family, 'EXAMPLE\Domain Users']

A Compose app cannot declare integration.web.portal: it has no executor, and only root can stop DSM’s web server from serving a Login Portal alias after uninstall. check fails with CONTRACT_PORTAL_REQUIRES_EXECUTOR. If you need a short address, an administrator sets up a reverse proxy in DSM.

The gateway treats any method other than GET, HEAD and OPTIONS as state-changing. Those requests need an X-SYNO-TOKEN header, and they must prove they come from the DSM origin: Sec-Fetch-Site, if sent, must be same-origin, and Origin, if sent, must equal Host. At least one of the two headers must be present. The same origin rule applies to a WebSocket handshake.

If you want your app’s own 403, 404, 500, 502, 503 or 504 responses to reach the browser with their body, send X-Synopkgland-Keep-Error-Body: 1 on the request. DSM’s web server otherwise replaces those statuses with its own error page. When this header is set, the gateway swaps the status as listed below and puts the real one in X-Synopkgland-Status.

Every gateway error has a plain-text body (code, newline, message) and the headers X-Synopkgland-Error: <code> and Cache-Control: no-store. DSM replaces responses with status 403, 404, 500, 502, 503 and 504, so the gateway sends a stand-in status and puts the intended one in X-Synopkgland-Status. Branch on X-Synopkgland-Error.

X-Synopkgland-ErrorStatus sentIntendedCause
session_required401401No session, or DSM rejected it.
forbidden401403Signed in, but the access rule refuses the user.
token_required401403State-changing request without a usable X-SYNO-TOKEN.
cross_site_blocked401403State-changing request or WebSocket handshake that is not same-origin.
not_found410404The request is not under the gateway path.
bad_path400400The path inside the container is not allowed.
redirect308308Adds a trailing slash.
body_too_large413413Request body over 64 MiB.
upgrade_unsupported501501Upgrade to something other than WebSocket.
upstream_unavailable521503Container stopped or refusing connections. Sends Retry-After: 5.
upstream_timeout524504No response headers within 60 s, or no connection within 3 s.
bad_gateway520502Invalid reply from the container.
auth_unavailable520500DSM could not verify the session or group membership.

The DSM main menu and desktop open a dsm-window app in a new browser tab, not in a DSM window. DSM opens every URL entry this way; the app gets no DSM window and no window title. When DSM’s Improve protection against cross-site request forgery attacks setting is on, that tab opens without a token and the gateway answers session_required. Turn the setting off, or build the app from the native-go template, which sends the token with every call. See Native apps.

Each install.<NAME> entry adds an installation wizard field. Reference the value in compose.yaml as ${NAME}. A name must match ^[A-Za-z_][A-Za-z0-9_]*$. A string, boolean, shared_folder or posix_all_shares setting that no Compose file references fails check with INSTALL_PARAMETER_UNREFERENCED.

typeWizard fieldNotes
stringText field, not blankdefault up to 256 characters, no control characters, no ${.
booleanCheckboxdeploy --param NAME=true or false.
shared_folderShared Folder pickerUse as a bind source, see below.
dsm_userUser pickerAt most one. The package runs every service as that user.
posix_all_sharesNoneMounts all volumes. Must be a whole bind source with no suffix. deploy fills it in from the administrator’s Shared Folder list.
secret, homescheck warns, deploy refuses them.

Bind a Shared Folder with ${NAME} as the start of the source, optionally followed by a sub-path. Declare a dsm_user next to a Shared Folder, so the containers run as a DSM user and not as root. Without one, check warns (DSM_USER_REQUIRED), and a Community listing is refused:

install:
MEDIA:
type: shared_folder
RUN_AS:
type: dsm_user
services:
web:
volumes:
- "${MEDIA}/photos:/photos:ro"

The build adds user: "${RUN_AS_UID}:${RUN_AS_GID}" and the user’s groups to every service. Do not set user: yourself when a dsm_user is declared. Use deploy --share MEDIA=<shared folder name> and --run-as RUN_AS=<DSM user> with the Shared Folder name and user name, not paths or ids.

Allowed bind sources are ${SYNOPKG_PKGVAR} (the app’s data folder), ${SYNO_APP_SECRETS_DIR}, and the install settings of type shared_folder, homes and posix_all_shares. A path inside the project, such as ./config, is allowed. Named volumes must be declared under the top-level volumes:. tmpfs is allowed. A sub-path after ${NAME} is / plus letters, digits, ., _ and -.

check rejects these in compose.yaml. The privilege block in synology-app.yaml cannot unlock any of them.

Compose featureError code
build:COMPOSE_BUILD_FORBIDDEN
privileged: trueCOMPOSE_PRIVILEGED_FORBIDDEN
network_mode: hostCOMPOSE_HOST_NETWORK_FORBIDDEN
pid: hostCOMPOSE_HOST_PID_FORBIDDEN
ipc: hostCOMPOSE_HOST_IPC_FORBIDDEN
devices: (non-empty)COMPOSE_DEVICES_FORBIDDEN
cap_add: (non-empty)COMPOSE_CAP_ADD_FORBIDDEN
Mounting docker.sock or the Container Manager socketCOMPOSE_SOCKET_MOUNT_FORBIDDEN
A host path such as /volume1/data, ~/data or ../dataCOMPOSE_HOST_BIND_FORBIDDEN
type: bind with a source that is not an allowed oneCOMPOSE_BIND_SOURCE_FORBIDDEN
${X} bind source that is not declared in installCOMPOSE_BIND_SOURCE_UNDECLARED
Invalid sub-path after ${X}COMPOSE_BIND_SUFFIX_INVALID
Named volume not declared under volumes:COMPOSE_NAMED_VOLUME_UNDECLARED
user: while a dsm_user is declaredCOMPOSE_USER_CONFLICTS_DSM_USER
user: 0, 0:0 or root with a Shared FolderCOMPOSE_USER_ROOT_FORBIDDEN
Numeric user: such as 1000:1000COMPOSE_USER_NUMERIC_FORBIDDEN
Pure-number item in a command or entrypoint listCOMPOSE_NUMERIC_COMMAND_ARG_FORBIDDEN
Top-level secrets: or configs:, or an undeclared ${NAME}COMPOSE_DYNAMIC_ROUTE_UNSUPPORTED
depends_on naming an unknown serviceCOMPOSE_SERVICE_NOT_FOUND
capabilities: in synology-app.yamlCAPABILITY_UNSUPPORTED

COMPOSE_USER_ROOT_FORBIDDEN is a warning when you build for yourself and an error for a Community listing. See Errors.

ItemValue
Minimum DSM7.2.1-69057 (init --min-dsm and build --min-dsm enforce it; check does not)
Architecturesx86_64, aarch64
Build machineGo 1.21 or later for a web app (the gateway is compiled into the SPK with exactly Go 1.26.9)
package lengthAt most 32 characters; over 28 gives PACKAGE_IDENTITY_TOO_LONG because deploy --dev adds .dev
Gateway request body64 MiB
Gateway response-header wait60 s
Gateway connect wait3 s
Unpinned imageReported as a risk; the build sets force_pull: true
Symptom or codeCauseFix
CONTRACT_ADMINPORT_UNMAPPEDadminport is not a published TCP host port.Publish it in ports: or fix the number.
COMPOSE_GATEWAY_PORT_EXPOSEDThe adminport host port is not on loopback.Write 127.0.0.1:<port>:<container port>.
IMAGE_PIN_REQUIREDimages pin --check found an unpinned or stale image.Run synopkgland images pin.
GATEWAY_GO_MISSINGA web app build needs Go 1.21 or later.Install Go and rebuild.
CAPABILITY_UNSUPPORTEDcapabilities is set in a container app.Remove it.
CONTRACT_PORTAL_REQUIRES_EXECUTORintegration.web.portal is set. A Compose app has no executor.Remove it. An administrator can set up a reverse proxy in DSM.
COMPOSE_ENV_MISSINGtest could not prepare the local folders.Run it from the project folder with Docker running.
PORT_ALREADY_IN_USEdeploy found the published port taken on the NAS.Choose another host port.
Browser shows 401 with session_requiredNo DSM session, or the CSRF setting above.Sign in again; see “Opening the app from DSM”.
Browser shows 521The container is stopped.Check the package in Package Center and Container Manager.