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.
Set it up
Section titled “Set it up”Start from the headless example, which has no web port, one Shared Folder and one DSM user:
synopkgland init -t compose my-appTo 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:
synopkgland init -t compose --from ./compose.yaml my-apppackage 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:
synopkgland images pinThis pair is a web app with an install setting. It passes check and build:
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"schema: 1package: hello.whoamiversion: 0.1.0displayname: Hello Whoamidescription: A small web service behind the DSM gateway.maintainer: Example Developeros_min_ver: "7.2.1-69057"adminport: 18080adminurl: /install: GREETING: type: string default: hellointegration: web: launch: dsm-window access: dsm-usersBuild and test:
synopkgland checksynopkgland build| Command | What it does |
|---|---|
check | Validates synology-app.yaml and compose.yaml. It prints the services, published ports and data folders it found. |
build | Writes 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. |
dev | Validates, runs docker compose up -d, waits until the services are healthy (or running, if there is no healthcheck) and stops them on Ctrl+C. |
test | Validates, prepares the ${SYNOPKG_PKGVAR} and secrets folders under .synopkgland/, runs up and waits for ready. It leaves the containers running. |
deploy | Builds 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.
The web entry
Section titled “The web entry”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) andX-Syno-Admin(trueorfalse). - It drops
Cookie,Remote-*,Forwarded,X-Syno-*,X-Forwarded-*,X-Remote-*,X-Auth*andX-Real-*from the request. Theintegration.web.cookiesfield therefore has no effect behind the gateway. - It removes
Set-Cookiefrom the response, forcesCache-Control: privateand addsVary: Cookie. - It rewrites
Locationheaders under the gateway path. - WebSocket and Server-Sent Events pass through. Your app sees the browser’s
Hoston a WebSocket request.
Launch mode and access
Section titled “Launch mode and access”| Setting | Values | Result |
|---|---|---|
integration.web.launch | dsm-window (default), new-page | dsm-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.access | dsm-users (default) | Any signed-in DSM user. |
dsm-admins | Members of the administrators group. | |
dsm-groups | Members of a group in integration.web.groups. | |
public | Everyone, 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.
Requests your app must send
Section titled “Requests your app must send”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.
Gateway errors
Section titled “Gateway errors”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-Error | Status sent | Intended | Cause |
|---|---|---|---|
session_required | 401 | 401 | No session, or DSM rejected it. |
forbidden | 401 | 403 | Signed in, but the access rule refuses the user. |
token_required | 401 | 403 | State-changing request without a usable X-SYNO-TOKEN. |
cross_site_blocked | 401 | 403 | State-changing request or WebSocket handshake that is not same-origin. |
not_found | 410 | 404 | The request is not under the gateway path. |
bad_path | 400 | 400 | The path inside the container is not allowed. |
redirect | 308 | 308 | Adds a trailing slash. |
body_too_large | 413 | 413 | Request body over 64 MiB. |
upgrade_unsupported | 501 | 501 | Upgrade to something other than WebSocket. |
upstream_unavailable | 521 | 503 | Container stopped or refusing connections. Sends Retry-After: 5. |
upstream_timeout | 524 | 504 | No response headers within 60 s, or no connection within 3 s. |
bad_gateway | 520 | 502 | Invalid reply from the container. |
auth_unavailable | 520 | 500 | DSM could not verify the session or group membership. |
Opening the app from DSM
Section titled “Opening the app from DSM”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.
Install settings
Section titled “Install settings”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.
type | Wizard field | Notes |
|---|---|---|
string | Text field, not blank | default up to 256 characters, no control characters, no ${. |
boolean | Checkbox | deploy --param NAME=true or false. |
shared_folder | Shared Folder picker | Use as a bind source, see below. |
dsm_user | User picker | At most one. The package runs every service as that user. |
posix_all_shares | None | Mounts all volumes. Must be a whole bind source with no suffix. deploy fills it in from the administrator’s Shared Folder list. |
secret, homes | check warns, deploy refuses them. |
Shared Folders
Section titled “Shared Folders”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_userservices: 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 -.
Not allowed in Compose
Section titled “Not allowed in Compose”check rejects these in compose.yaml. The privilege block in synology-app.yaml cannot unlock any of them.
| Compose feature | Error code |
|---|---|
build: | COMPOSE_BUILD_FORBIDDEN |
privileged: true | COMPOSE_PRIVILEGED_FORBIDDEN |
network_mode: host | COMPOSE_HOST_NETWORK_FORBIDDEN |
pid: host | COMPOSE_HOST_PID_FORBIDDEN |
ipc: host | COMPOSE_HOST_IPC_FORBIDDEN |
devices: (non-empty) | COMPOSE_DEVICES_FORBIDDEN |
cap_add: (non-empty) | COMPOSE_CAP_ADD_FORBIDDEN |
Mounting docker.sock or the Container Manager socket | COMPOSE_SOCKET_MOUNT_FORBIDDEN |
A host path such as /volume1/data, ~/data or ../data | COMPOSE_HOST_BIND_FORBIDDEN |
type: bind with a source that is not an allowed one | COMPOSE_BIND_SOURCE_FORBIDDEN |
${X} bind source that is not declared in install | COMPOSE_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 declared | COMPOSE_USER_CONFLICTS_DSM_USER |
user: 0, 0:0 or root with a Shared Folder | COMPOSE_USER_ROOT_FORBIDDEN |
Numeric user: such as 1000:1000 | COMPOSE_USER_NUMERIC_FORBIDDEN |
Pure-number item in a command or entrypoint list | COMPOSE_NUMERIC_COMMAND_ARG_FORBIDDEN |
Top-level secrets: or configs:, or an undeclared ${NAME} | COMPOSE_DYNAMIC_ROUTE_UNSUPPORTED |
depends_on naming an unknown service | COMPOSE_SERVICE_NOT_FOUND |
capabilities: in synology-app.yaml | CAPABILITY_UNSUPPORTED |
COMPOSE_USER_ROOT_FORBIDDEN is a warning when you build for yourself and an error for a Community listing. See Errors.
Limits
Section titled “Limits”| Item | Value |
|---|---|
| Minimum DSM | 7.2.1-69057 (init --min-dsm and build --min-dsm enforce it; check does not) |
| Architectures | x86_64, aarch64 |
| Build machine | Go 1.21 or later for a web app (the gateway is compiled into the SPK with exactly Go 1.26.9) |
package length | At most 32 characters; over 28 gives PACKAGE_IDENTITY_TOO_LONG because deploy --dev adds .dev |
| Gateway request body | 64 MiB |
| Gateway response-header wait | 60 s |
| Gateway connect wait | 3 s |
| Unpinned image | Reported as a risk; the build sets force_pull: true |
Troubleshooting
Section titled “Troubleshooting”| Symptom or code | Cause | Fix |
|---|---|---|
CONTRACT_ADMINPORT_UNMAPPED | adminport is not a published TCP host port. | Publish it in ports: or fix the number. |
COMPOSE_GATEWAY_PORT_EXPOSED | The adminport host port is not on loopback. | Write 127.0.0.1:<port>:<container port>. |
IMAGE_PIN_REQUIRED | images pin --check found an unpinned or stale image. | Run synopkgland images pin. |
GATEWAY_GO_MISSING | A web app build needs Go 1.21 or later. | Install Go and rebuild. |
CAPABILITY_UNSUPPORTED | capabilities is set in a container app. | Remove it. |
CONTRACT_PORTAL_REQUIRES_EXECUTOR | integration.web.portal is set. A Compose app has no executor. | Remove it. An administrator can set up a reverse proxy in DSM. |
COMPOSE_ENV_MISSING | test could not prepare the local folders. | Run it from the project folder with Docker running. |
PORT_ALREADY_IN_USE | deploy found the published port taken on the NAS. | Choose another host port. |
Browser shows 401 with session_required | No DSM session, or the CSRF setting above. | Sign in again; see “Opening the app from DSM”. |
Browser shows 521 | The container is stopped. | Check the package in Package Center and Container Manager. |