Skip to content

Configure an app for Catena

Catena manages an app from labels written in its compose file: it publishes the address, puts the sign-in gate in front, backs up the data, updates the app and watches it on the status page. In this guide, you add an app from a repository, then prepare an app’s own compose file and publish it in a repository of yours. An app that goes through every step is fully managed.

When the software is in Catena’s catalog, the catalog entry is the shorter route: its labels, networks, placement and sign-in client are already in place, and you add it as described in Add an app from a repository.

Administrators add, configure and remove apps in Manage applications, below the tiles of the Apps page. Each app lives in its own folder on the server: its compose file, a settings file and a note of where it came from. The folder is in every backup and runs the app without Catena (see Restore one app on any machine).

An app comes from a git repository on the server’s list. Catena’s catalog is on the list from the start and is trusted. To add an app:

  1. Open Apps and go to Add an application.
  2. Choose the Repository, enter the Application name and press Show versions.
  3. Choose a Version. The list holds the newest ten. A version is a tag the repository published for the app.
  4. Optionally enter Name on this server: lower-case letters, digits and hyphens. Without it, the app keeps its own name. A name that is already a folder or an app on the server is refused.
  5. Press Add.

Catena fetches the app at that version, checks its compose file (see Check an app with the panel), writes the folder and deploys it. A file with an error in the check is refused and not kept; warnings and notes do not stop the deploy. The page then reads “<app> was added.” and the app’s tile appears once Catena has picked the app up, within seconds. A refusal shows the server’s answer under the notice.

Each app has a card under Manage applications:

  • Settings. The values the app lets you change, each with its default. Type the values and press Save settings: the app is deployed again once with all of them. Only the values you changed are written, and a value that another administrator changed since the page opened is refused as out of date. A value that spans several lines is shown but not editable here. When that fails, the page reads “The settings were saved, but <app> did not restart with them. Start it again to apply them.” A stopped app takes the values when you start it. A secret value is never shown again: its field reads “A value is set” or “No value is set”, and you type a new value in its own field and press Save to replace it. Values Catena generates, or keeps set itself, are listed by name and are read-only. A value is stored as plain text; neither Docker nor a shell runs it as a command.
  • Start and Stop. Stopping needs a tick on the confirmation box and keeps the app’s data.
  • Remove. Needs a tick on the confirmation box. Tick Also delete its data (cannot be undone) to delete the app’s data as well; without it, the data stays.

A badge on the card says whether the app is still as Catena deployed it, and the card shows an update waiting for you. An app you changed by hand, or through another container manager, is not updated, and its badge says so. The badges, the Outside Catena list and the optional Portainer and Arcane apps of the catalog are covered in Apps changed outside Catena.

Repositories, under Manage applications, lists the repositories you can add apps from. To add one:

  1. Enter its Repository address: an https:// address with no password, query or fragment.
  2. For a private repository, enter an Access token. It is saved on the server and never shown again; the list only says “Token saved”. To replace it, enter a new one. To delete it, tick Delete the saved token.
  3. Tick Trusted: apply its updates by themselves only for a repository whose author you trust. Updates from a trusted repository apply by themselves in the nightly maintenance; for the others, each update waits for you on the app’s card (see Apps added from a repository).
  4. Press Save repository.

Saving an address already on the list changes its trust and its token. Remove takes a repository off the list, with its token; the apps already added from it stay.

A repository holds one folder per app under blueprints/, as blueprints/<app>/docker-compose.yml with the app’s other files beside it, at most 500 files and 5 MiB, with no link or submodule. It publishes each version as a tag spelled <app>/<version>-<revision>, such as my-app/1.2.3-1, where the version and the revision use letters, digits and the characters ., _, + and -.

Catena reads a repository’s files as data and checks them before it deploys them. Only an unchanged file of Catena’s catalog may give an app the server’s network or the container engine, read stored secrets, or take values that another app provides. Any other file that asks for them is refused.

The x-catena block of an app (see Declare the settings and Declare the backup mode and update steps) can declare what the app needs of the server. Catena fulfils it before each deploy: a certificate for the app’s names and DNS records in the zones the server serves. A name outside the domains the server serves is refused, unless the file is an unchanged file of Catena’s catalog. Labels ask for sign-in (see Sign in with Catena accounts), and the block can take an app’s users from a sign-in group.

An app can also provide a service to other apps, which the apps that use it find by themselves. Once the ClamAV app of the catalog runs, Nextcloud and the mail server are deployed again once with its address and scan through it. Nextcloud sets itself up the same way for the OnlyOffice or Collabora editor of the catalog. The call relay app of the catalog provides the relay that Nextcloud Talk and Element use: adding it deploys them again once with its address and secret, and removing it sets them back. Adding Talk or Element never adds the relay, and the relay cannot be added before the server has its domain, because it needs a name and a certificate. The mail server app has Catena publish its address record, its MTA-STS record and its TLS-RPT record; you publish its MX, SPF, DKIM and DMARC records yourself.

An app is a set of services. One of them, the front-door service, answers the web requests: the web server or the application server. The others (database, cache, background workers) are internal and stay out of reach of the internet and of other apps.

You write labels under each service’s own labels: key, as a list with one "key=value" per line. The status page, restores, migrations and updates read them from there; labels under deploy.labels are not seen by them.

A service that carries vps.route.host has a public address, and its port, who gets in and health check are read from that service alone. Most apps have one address, on the front-door service. An app can have several, such as a public site and an admin page served by another service. With two or more, you mark the main address with vps.route.main=true on its service. The labels that describe the app as a whole (its tile, its title on the status page, its sign-in entry, vps.auth.protected) are read from the main address’s service only, as the running app has them, and are ignored on any other service. With one address, that service is the main address and needs no mark. When an app has two or more addresses and none or several are marked main, the app gets no route.

Access is set per address and is never inherited: an address with no access label admits administrators only, even when the main address is public. Two addresses cannot share a host name: the one deployed first keeps it, and the other gets no route. The update policy and the published ports describe a single service, and each is optional. Which app a service belongs to is the app’s name on the server, and the service is its key in the file; Catena reads both from Docker Swarm itself, so no label names them. The label reference says where each label goes.

A label value can be a ${VARIABLE} from the app’s settings (see Declare the settings): Catena reads the value the deployed service received. A vps.* label that Catena does not recognise has no effect.

The skeleton below has every piece the steps add: a front-door service web and a database db.

services:
web:
image: example/web:1.2.3
deploy:
placement:
constraints:
- node.labels.catena.role==data
volumes:
- web-data:/data
cap_drop:
- NET_RAW
networks:
- catena-network
- default
labels:
- "vps.route.host=my-app.yourdomain.com"
- "vps.route.port=8080"
- "vps.auth.groups=staff"
- "vps.health.path=/health"
db:
image: postgres:1.2.3-alpine
deploy:
placement:
constraints:
- node.labels.catena.role==data
environment:
POSTGRES_USER: my-app
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- db-data:/var/lib/postgresql/data
networks:
- default
volumes:
web-data:
db-data:
networks:
catena-network:
external: true

Give the app one short name made of lower-case letters, digits and hyphens, such as my-app. It is the app’s name on the server, which you give when you add the app (step 7). Restores, migrations and updates find the app by that name, and the proxy reaches the front-door service at my-app_web: the app’s name, an underscore and the service’s key in the file.

Step 2: Make the file ready for Docker Swarm

Section titled “Step 2: Make the file ready for Docker Swarm”

Apps run on Docker Swarm, which refuses some compose keys outright and silently ignores others. The catalog is checked against these rules; when you write a file by hand, you follow them yourself.

  • Restart policy. Swarm restarts a stopped service by default. Write deploy.restart_policy only for a service that must not restart (condition: none) or only after a failure (condition: on-failure), such as a setup job.
  • Placement for data. Every service that mounts a named volume or a host folder has the placement constraint node.labels.catena.role==data. A volume is local to one server: on another, swarm creates it empty, without an error.
  • No depends_on. Swarm has no start ordering and drops the key without a message. Swarm restarts a service that started too early. When a crash and restart does damage (an installer that half-completes), the entrypoint waits for the database itself.
  • No inline configs:. The check refuses a file: or content: entry (rule S4). The service’s entrypoint writes the config file instead. Only external: true configs are accepted.
  • tmpfs in long form (type: tmpfs). The short key is dropped silently. A tmpfs mount is noexec; a path that needs execution is a volume.
  • Published ports in long form with mode: host, which keeps the real client address (the default mode replaces it). See step 4 for the label that opens them in the firewall.
  • Refused keys, which make swarm reject the whole file: mem_limit, memswap_limit, cpus, cpu_shares, volumes_from, profiles, scale. Limits go in deploy.resources.limits, the replica count in deploy.replicas.
  • Dropped keys, with no effect: restart, container_name, expose, links, external_links, build, network_mode, security_opt, shm_size. Images are built and published elsewhere; swarm only pulls them.
  • Server access keys. Swarm also drops privileged, pid and devices, so the app runs without what it asked for. The panel’s App check reports privileged: true, pid: host and devices as errors (X1, X3, X5): the file asks for control of the server. The corrected file keeps the key, so you decide.
  • No raw network access on catena-network. Docker gives every container the right to send raw network packets, which lets a container on catena-network forge traffic to Catena’s own services. Every service that joins catena-network has cap_drop: [NET_RAW]. An app that needs raw packets (a network scanner, for example) adds cap_add: [NET_RAW] instead. The panel’s App check reports a service that keeps it (X11), and the corrected file carries the drop. An app already deployed keeps the access until you publish the corrected file as a new version.
  • Full version tags. Every image carries a full version tag, such as 1.2.3 or 1.2.3-alpine. Only those are updated automatically: latest, stable, 1.2 or 1 are never touched.

The front-door service joins the external network catena-network and keeps the app’s own default network to reach the internal services. No alias is needed. The internal services stay on default only. The file declares catena-network as external: true (see the skeleton above).

The reverse proxy reaches the front-door service through that network, and every other service with an address the same way. A service with an address that is not on catena-network gets no route for that address. An internal service with no catena-network entry is unreachable from the proxy and from other apps.

Write these labels on the front-door service:

  1. vps.route.host with the public address, such as my-app.yourdomain.com. Without it the app has no route, no tile and no status-page card.
  2. vps.route.port with the port the app listens on inside its container, when it is not 80.
  3. Who gets in:
    • vps.auth.mode=public for an app open to everyone. The app’s own login, if it has one, stays in charge.
    • vps.auth.groups with the Keycloak groups allowed in, for a private app. A person in any one of them gets in.
    • Neither label: only administrators get in.
  4. vps.health.path with an address of the app that answers 200 when the app works, such as /health. Without it, the status page checks the app’s front page: a public app is up when the page answers without an error, and an app behind sign-in when it sends visitors to sign in.
  5. Optionally, the tile’s name, icon and description on the Apps tab, and the title of the status-page card.

Administrators always get in. Groups are not created by a label: they already exist in Keycloak, managed in its admin console at auth.yourdomain.com or in the People panel with Catena Pro (see Sign-in and people). The gate decides who reaches the app, not what a person can do inside it: roles and permissions inside the app are the app’s own settings.

A service that publishes a port for a protocol other than web traffic (mail, a TURN relay) also carries vps.expose.tcp or vps.expose.udp with those ports, which opens them in the server’s firewall. A label never opens a port the server itself declares (SSH, or a port kept to the server or to your private network): Catena ignores that entry and logs it.

services:
relay:
image: example/relay:1.2.3
ports:
- target: 3478
published: 3478
protocol: udp
mode: host
labels:
- "vps.expose.udp=3478"

When another service of the app answers on an address of its own, such as an admin page, give that service its labels too:

  1. On the front-door service, add vps.route.main=true. It marks the address that the tile opens and that carries the app-wide labels.
  2. On the other service, add vps.route.host with its own address, vps.route.port when it is not 80, and its access: vps.auth.mode=public, or vps.auth.groups with the groups allowed in. With neither, only administrators get in, whatever the main address allows.
  3. Add vps.health.path on it when its front page is not a good check. The status page then shows one card for each address.
  4. Join that service to catena-network too (step 3).

Leave the tile, the status-page title, vps.auth.protected and the sign-in labels on the front-door service. In the file below, web is public and admin admits the staff group:

services:
web:
image: example/web:1.2.3
cap_drop:
- NET_RAW
networks:
- catena-network
- default
labels:
- "vps.route.host=my-app.yourdomain.com"
- "vps.route.main=true"
- "vps.auth.mode=public"
- "vps.health.path=/health"
- "vps.display-name=My app"
admin:
image: example/admin:1.2.3
cap_drop:
- NET_RAW
networks:
- catena-network
- default
labels:
- "vps.route.host=my-app-admin.yourdomain.com"
- "vps.route.port=8080"
- "vps.auth.groups=staff"
networks:
catena-network:
external: true

By default an image moves to newer patch releases only. A service that should move further, or not at all, carries vps.auto-update (see Updates).

Nothing else is labelled per service. A service whose image is a database or cache (PostgreSQL, PostGIS, TimescaleDB, MariaDB, MySQL, MongoDB, Redis, Valkey, KeyDB, Memcached, Elasticsearch, OpenSearch) is recognised from its image and keeps running when a migration pauses the rest of the app.

  • Named volumes live in the Docker volumes folder and are in every backup. Each volume is declared by name: anonymous volumes are backed up too, but a single-app restore cannot reach them.
  • Bind mounts inside the apps area are included. A bind mount to any other absolute path makes the backup fail, unless you list that folder under Settings > Backup repository > Other folders to back up (separated by commas). See Backups and S3 storage.
  • Databases are dumped automatically before each backup. The image name decides, not a label:
    • a running container whose image name contains postgres is dumped with pg_dumpall, as the user in POSTGRES_USER;
    • a container whose image name contains mariadb or mysql is dumped, and needs MARIADB_ROOT_PASSWORD or MYSQL_ROOT_PASSWORD in its environment;
    • a dump that fails fails the backup.
  • Other engines (MongoDB, SQLite and similar) get no automatic dump. You need the app’s own export, or a volume copy that is consistent on its own.
  • Large file sets belong in S3-backed storage rather than in a local volume, which keeps the backup small.

A backup does not pause an app added by hand unless the file declares a backup mode (see Declare the backup mode and update steps): otherwise nothing is put in maintenance mode around the snapshot. A catalog entry can ask for it instead: Nextcloud, for one, is put in maintenance mode while the nightly maintenance takes its backup, and taken out of it afterwards, even when the backup fails.

  1. In a git repository you can reach over https://, put the compose file at blueprints/<app>/docker-compose.yml, where <app> is the name from step 1, with the app’s other files beside it.
  2. Tag the version <app>/<version>-<revision>, such as my-app/1.2.3-1.
  3. Under Repositories on the Apps page, save the repository’s address, with an access token when it is private (see Repositories).
  4. Under Add an application, choose the repository, enter <app>, choose the version and press Add (see Add an app from a repository).
  5. Enter passwords and keys as the app’s settings (see Declare the settings). They stay out of the file, so you can rotate them without publishing a new version.

To change the app later, publish a new tag; the app moves to it as described in Apps added from a repository.

  1. Wait a few seconds: Catena picks up the deploy by itself, and checks again every hour.
  2. Open the address. A private app shows the sign-in page first; a public app opens directly.
  3. The app’s tile is on the Apps tab, and its card on the status page turns green.

When a check fails, the troubleshooting table lists the usual causes. To catch most mistakes before you deploy, check the file with the panel.

An app that supports OpenID Connect can let people sign in with their Catena account instead of a separate password. Labels make Catena create the app’s own sign-in entry in Keycloak and hand the app its id, secret and issuer.

  1. Add these labels to the front-door service:
    • vps.auth.oidc=true.
    • vps.auth.oidc.redirect_uris with the app’s callback address (the app’s documentation names it), such as https://my-app.yourdomain.com/auth/callback. Separate several addresses with commas.
    • vps.auth.oidc.public=true only for an app that cannot keep a secret, such as a phone app. The entry then has no secret and requires PKCE.
  2. In the compose file, pass the three values to the app under the names it expects, with an empty default so that the first deploy works: ${OIDC_CLIENT_ID:-}, ${OIDC_CLIENT_SECRET:-} (absent for a public app) and ${OIDC_ISSUER_URL:-}.
  3. Add the app. Within a few minutes, Catena creates the entry, writes the three values into the app’s settings and deploys the app again when they change. Keycloak generates the secret; Catena copies it.

Each return address is https:// on a domain this server serves, or a phone app’s own scheme such as app.immich:///oauth-callback. A *, plain http:// or a # is refused. When a label is wrong, Catena makes and changes nothing for that app.

Who can sign in through the app’s own button follows the app’s groups: only administrators for an app only they can open, staff and administrators when the groups go beyond admin, and every account when client is among them. A public app with no vps.auth.groups admits staff and administrators.

Catena only changes entries it made. An entry with the same name that you made by hand is never taken over. Removing the labels, or the app, deletes the entry Catena made.

To check that it worked:

  • On the Apps tab, an administrator sees on the app’s tile sign-in ready, sign-in waiting or sign-in failed, with the reason, in place of the OIDC badge. Staff see the OIDC badge.
  • Waiting means Catena tries again on its own. Failed means something needs fixing, and the tile says what. The Log tab also names the app and the reason.

See Single sign-on for how sign-in works across apps.

The values an app’s file reads as ${VARIABLE} come from its settings file. Declare each one in the x-catena block, with schema: 2, as one of:

  • a setting you change on the app’s card, with a default and a label in English and in French;
  • a generated secret that Catena makes on the first deploy, with its length and its alphabet (alnum or hex).
x-catena:
schema: 2
env:
APP_HOSTNAME:
setting:
default: my-app.yourdomain.com
label: { en: "Address", fr: "Adresse" }
DB_PASSWORD:
generated: { length: 32, alphabet: alnum }

A variable that is neither declared nor written by Catena, as the sign-in values are, reads as empty.

Declare the backup mode and update steps (optional)

Section titled “Declare the backup mode and update steps (optional)”

An app can carry its own backup mode and update steps in an x-catena block at the top of its compose file. The app’s folder keeps the file as Catena deployed it, so the block travels with the app: backups and restores bring it back, and the server reads it from that file, not from the catalog.

x-catena:
schema: 1
quiesce:
service: web
pre:
- ["pkill", "-STOP", "-x", "node"]
post:
- ["pkill", "-CONT", "-x", "node"]
timeout_seconds: 10
lifecycle:
service: web
migrate:
- ["yarn", "db:migrate"]
versioned_volumes: ["web-data"]
timeout_seconds: 900

Start the block with schema: 1. Each command is a list of words (["yarn", "db:migrate"]), run with docker exec inside the named service of the app, with no shell. Each section sets service and timeout_seconds; user is optional and names the account the commands run as.

  • quiesce is the backup mode. The nightly maintenance runs the pre commands before the backup, such as putting the app in maintenance mode, and the post commands after it, even when the backup fails. The nightly maintenance needs Catena Pro or Catena Business (see Schedules). post must succeed when pre never ran. Each command may take up to 60 seconds. A command still running when its time is up gets as long again, up to two minutes, to finish, and is stopped after that, before the next command runs; a stopped command is noted in the Log tab.
  • lifecycle holds the update steps, each command taking up to 3600 seconds:
    • before_update runs in the running container just before an update deploys the new image, and after_update once the update is settled, whether kept or rolled back.
    • migrate runs when an update moves the image of service, and after a restore of the whole server once the databases are put back. When it fails after an update, the update is rolled back, image and database.
    • versioned_volumes lists the app’s own named volumes that the new version upgrades in place and the previous version cannot read. The server copies each one before an update that moves service and puts the copy back when the update rolls back. A volume that a database or a one-shot service mounts cannot be listed.

The updates themselves run in the nightly maintenance, which needs Catena Pro or Catena Business (see Updates).

The server refuses the app’s updates when the block does not hold for its file. The rules:

  • A service names a service the file declares.
  • No value holds a $: Docker reads it as a variable when it deploys the file. Write the commands without one.
  • No value names an image of the file, because an update rewrites those.
  • A timeout stays within its limit.
  • A format newer than the server reads is refused until the server is updated.

Check the file with the panel before you deploy: rule K17 reports each of these and lists the commands the block has the server run.

Every label goes under the service’s own labels: key.

On every service that carries vps.route.host: the front-door service and any other address. Each address reads its own.

Label Values Default Description
vps.route.host An address under yourdomain.com none Public address. The service that carries it has an address of the app. Without it on any service, the app has no route, no tile and no status-page card. Two addresses cannot share a host name: the one deployed first keeps it.
vps.route.port A port number 80 Port the service listens on inside its container.
vps.auth.mode public, private, admin-only none public: no gate. private: the groups in vps.auth.groups, or admin, client and staff when none are listed. admin-only: administrators only.
vps.auth.groups Keycloak group names, comma-separated none Groups allowed in; a person in any one of them gets in. Ignored with public and admin-only. The group visitor makes the address public.
vps.health.path A URL path none Address the status page checks every 2 minutes; the address is up when it answers 200. Without it, the check reads the front page: a public address is up when it answers without an error, an address behind sign-in when it sends visitors to sign in.
vps.health.expect Text none Text the reply must contain. Used only with vps.health.path.

With neither vps.auth.mode nor vps.auth.groups, only administrators get in. This holds for each address on its own: an address does not take the access of the main address. Administrators always get into a private address.

On the main address’s service only. On any other service these labels are ignored.

Label Values Default Description
vps.route.main true, false false Marks the main address. Needed only when the app has two or more addresses, on exactly one service. With none or several marked, the app has no route.
vps.auth.protected true, false false true marks an app that must not be public: when its labels make it public anyway, its tile on Apps carries the badge “should require sign-in”, shown to administrators only.
vps.auth.oidc true, false false Catena creates the app’s own sign-in entry in Keycloak and puts OIDC_CLIENT_ID, OIDC_CLIENT_SECRET and OIDC_ISSUER_URL in the app’s settings. Staff see an OIDC badge on the tile; administrators see the state of the sign-in.
vps.auth.oidc.redirect_uris Addresses, comma-separated none Return addresses of the sign-in entry. Required with vps.auth.oidc. See Sign in with Catena accounts.
vps.auth.oidc.public true, false false true for an app that sends no secret: the entry has no secret and requires PKCE.
vps.display-name Text the main address Title of the main address’s card on the status page. The other addresses’ cards are titled by their address.
vps.homepage.name Text the app’s name on the server Title of the tile on the Apps tab.
vps.homepage.icon An icon name, or an image address mdi-application Tile icon. An address starting with http is shown as an image.
vps.homepage.description Text none Tile subtitle.
vps.homepage.hidden true, false false Hides the tile from everyone except administrators. The app stays reachable.

On any service, optional, and read from whichever service carries it.

Label Values Default Description
vps.auto-update off, patch, minor, patch+minor, major patch How far the image’s version may move. patch keeps the major and minor numbers (1.2.3 to 1.2.9). minor and patch+minor keep the major number (1.2.3 to 1.9.0). major takes any newer release (1.2.3 to 2.0.0). off never moves it. An invalid value counts as patch.

Only images with a full version tag are updated. Services that run the same image move together, under the most restrictive of their labels; a service without the label counts as patch. The updates run in the nightly maintenance, which needs Catena Pro or Catena Business (editions); see Updates.

On the service that publishes the ports.

Label Values Default Description
vps.expose.tcp Ports or ranges, comma-separated (25,465, 7000-7010) none Opens these TCP ports to the internet in the server’s firewall. The service publishes them itself, in long form with mode: host. A catalog app that is granted the host network publishes nothing: its labels open the ports it listens on directly on the server. An entry that overlaps a port the server declares itself, or an invalid entry, is ignored.
vps.expose.udp Ports or ranges, comma-separated none The same for UDP ports.

The catalog entry for Nextcloud is the recommended way to run it: it carries more precautions than the simplified file below, and its sign-in client is already made. Add the catalog entry and enter its storage settings: that is the whole procedure, and Catena registers Keycloak inside Nextcloud itself. The file below shows the same design with the pieces a hand-written app needs, added under the name my-nextcloud; the proxy reaches app at my-nextcloud_app. Image tags are placeholders.

services:
app:
image: nextcloud:1.2.3-apache
deploy:
placement:
constraints:
- node.labels.catena.role==data
entrypoint:
- /bin/sh
- -ec
- |
cat > /docker-entrypoint-hooks.d/before-starting/zz-catena.sh <<'HOOK'
#!/bin/sh
set -e
php occ config:system:set --type int \
--value "$${NEXTCLOUD_MAX_CHUNK_SIZE}" files.chunked_upload.max_size
HOOK
chmod 0755 /docker-entrypoint-hooks.d/before-starting/zz-catena.sh
wait_for_db() {
php -r 'try { new PDO(
"pgsql:host=".getenv("POSTGRES_HOST").";dbname=".getenv("POSTGRES_DB"),
getenv("POSTGRES_USER"), getenv("POSTGRES_PASSWORD")
); } catch (Throwable $$e) { exit(1); } exit(0);' 2>/dev/null
}
waited=0
until wait_for_db; do
waited=$$((waited + 2))
if [ "$$waited" -ge 300 ]; then
echo "database did not answer in $${waited}s" >&2
exit 1
fi
sleep 2
done
exec /entrypoint.sh "$$@"
- --
command: ["apache2-foreground"]
environment:
NEXTCLOUD_TRUSTED_DOMAINS: ${NEXTCLOUD_HOSTNAME}
NEXTCLOUD_ADMIN_USER: ${NEXTCLOUD_ADMIN_USER}
NEXTCLOUD_ADMIN_PASSWORD: ${NEXTCLOUD_ADMIN_PASSWORD}
NEXTCLOUD_MAX_CHUNK_SIZE: ${NEXTCLOUD_MAX_CHUNK_SIZE:-83886080}
OVERWRITEPROTOCOL: https
OVERWRITEHOST: ${NEXTCLOUD_HOSTNAME}
OVERWRITECLIURL: https://${NEXTCLOUD_HOSTNAME}
TRUSTED_PROXIES: 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16
POSTGRES_HOST: db
POSTGRES_DB: nextcloud
POSTGRES_USER: nextcloud
POSTGRES_PASSWORD: ${DB_PASSWORD}
REDIS_HOST: redis
OBJECTSTORE_S3_BUCKET: ${S3_BUCKET}
OBJECTSTORE_S3_REGION: ${S3_REGION}
OBJECTSTORE_S3_HOST: ${S3_HOST}
OBJECTSTORE_S3_PORT: ${S3_PORT:-443}
OBJECTSTORE_S3_SSL: ${S3_SSL:-true}
OBJECTSTORE_S3_USEPATH_STYLE: "true"
OBJECTSTORE_S3_AUTOCREATE: "false"
OBJECTSTORE_S3_KEY: ${S3_ACCESS_KEY}
OBJECTSTORE_S3_SECRET: ${S3_SECRET_KEY}
NEXTCLOUD_OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
NEXTCLOUD_OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
NEXTCLOUD_OIDC_ISSUER_URL: ${OIDC_ISSUER_URL:-}
volumes:
- nc-html:/var/www/html
- nc-config:/var/www/html/config
- nc-apps:/var/www/html/custom_apps
- nc-data:/var/www/html/data
labels:
- "vps.route.host=nextcloud.yourdomain.com"
- "vps.route.port=80"
- "vps.auth.mode=public"
- "vps.auth.oidc=true"
- "vps.auth.oidc.redirect_uris=https://nextcloud.yourdomain.com/apps/user_oidc/code"
- "vps.health.path=/status.php"
- "vps.auto-update=patch"
cap_drop:
- NET_RAW
networks:
- catena-network
- default
db:
image: postgres:1.2.3-alpine
deploy:
placement:
constraints:
- node.labels.catena.role==data
environment:
POSTGRES_DB: nextcloud
POSTGRES_USER: nextcloud
POSTGRES_PASSWORD: ${DB_PASSWORD}
PGDATA: /var/lib/postgresql/data/pgdata
volumes:
- db-data:/var/lib/postgresql/data
labels:
- "vps.auto-update=patch"
networks:
- default
redis:
image: redis:1.2.3-alpine
labels:
- "vps.auto-update=patch"
networks:
- default
cron:
image: nextcloud:1.2.3-apache
deploy:
placement:
constraints:
- node.labels.catena.role==data
entrypoint:
- /bin/sh
- -ec
- |
wait_for_db() {
php -r 'try { new PDO(
"pgsql:host=".getenv("POSTGRES_HOST").";dbname=".getenv("POSTGRES_DB"),
getenv("POSTGRES_USER"), getenv("POSTGRES_PASSWORD")
); } catch (Throwable $$e) { exit(1); } exit(0);' 2>/dev/null
}
waited=0
until wait_for_db; do
waited=$$((waited + 2))
if [ "$$waited" -ge 300 ]; then
echo "database did not answer in $${waited}s" >&2
exit 1
fi
sleep 2
done
exec /cron.sh
environment:
POSTGRES_HOST: db
POSTGRES_DB: nextcloud
POSTGRES_USER: nextcloud
POSTGRES_PASSWORD: ${DB_PASSWORD}
REDIS_HOST: redis
OBJECTSTORE_S3_BUCKET: ${S3_BUCKET}
OBJECTSTORE_S3_REGION: ${S3_REGION}
OBJECTSTORE_S3_HOST: ${S3_HOST}
OBJECTSTORE_S3_PORT: ${S3_PORT:-443}
OBJECTSTORE_S3_SSL: ${S3_SSL:-true}
OBJECTSTORE_S3_USEPATH_STYLE: "true"
OBJECTSTORE_S3_KEY: ${S3_ACCESS_KEY}
OBJECTSTORE_S3_SECRET: ${S3_SECRET_KEY}
volumes:
- nc-html:/var/www/html
- nc-config:/var/www/html/config
- nc-apps:/var/www/html/custom_apps
- nc-data:/var/www/html/data
networks:
- default
volumes:
nc-html:
nc-config:
nc-apps:
nc-data:
db-data:
networks:
catena-network:
external: true

You declare the variables (NEXTCLOUD_HOSTNAME, NEXTCLOUD_ADMIN_USER, NEXTCLOUD_ADMIN_PASSWORD, DB_PASSWORD and the S3_* values) as settings (see Declare the settings) and enter them on the app’s card. NEXTCLOUD_HOSTNAME is nextcloud.yourdomain.com. Catena fills the OIDC_* values itself (see below).

  • Storage. User files go to an S3 bucket through the OBJECTSTORE_S3_* variables. The backup then holds only the database and the small local volumes, and the bucket is covered by its own provider-side durability. nc-data still holds logs and application data, so it is a named volume. nc-html holds the code tree: with it persisted, a recreated container is recognized as an existing install and upgraded, instead of being installed again over live data.
  • Placement. app, db and cron mount volumes, so all three are pinned to the data node. redis keeps nothing and needs no pin: a missing cache is a cache miss.
  • Database wait instead of depends_on. A crash-and-restart loop until the database answers is not safe for Nextcloud: the installer gives up after ten tries, but by then the version file is already written, so the next start takes the upgrade path and the instance stays “not installed” until the volume is wiped. The entrypoint therefore opens a real database connection (not just a TCP check, because Postgres accepts connections while still starting) and only then hands over to the image’s own entrypoint. After 300 seconds it exits so swarm restarts the task.
  • Hook for configuration. The image fills its config folder only when that folder is empty, so a config file written before startup breaks the first run. The entrypoint writes a before-starting hook that the image runs after populating the folder. $$ is compose’s escape for a literal $.
  • Cron service. The same image runs /cron.sh for background jobs. It shares the four volumes with app, so it lands on the same node and waits for the database in the same way.
  • Trusted domains and proxy. TLS ends at the edge. OVERWRITEPROTOCOL=https, OVERWRITEHOST and OVERWRITECLIURL make Nextcloud write https addresses, TRUSTED_PROXIES covers the private ranges that Catena’s proxy uses, and NEXTCLOUD_TRUSTED_DOMAINS is the public hostname.
  • Chunk size. Cloudflare caps a request body at 100 MB on its Free and Pro plans. Nextcloud’s default upload chunk is 100 MiB, which fails at the edge and is retried without end. 80 MiB (83886080) leaves headroom. Setting it to 0 disables chunking and breaks every upload over 100 MB.
  • Access. vps.auth.mode=public leaves Nextcloud’s own login in charge, which keeps anonymous share links (/s/...) working for people with no account. Sign-in through Keycloak is added inside the app (below).
  • Health. vps.health.path=/status.php checks the page that says Nextcloud itself is up, beyond its front page.
  • Backups. The db service image contains postgres and has POSTGRES_USER set, so it is dumped before each backup.

In the catalog app, Catena makes the client in Keycloak and registers the provider inside Nextcloud itself.

For the hand-written file, the two vps.auth.oidc labels on app make Catena create the sign-in entry and write OIDC_CLIENT_ID, OIDC_CLIENT_SECRET and OIDC_ISSUER_URL into the app’s settings (see Sign in with Catena accounts). The labels’ return address is Nextcloud’s callback, https://nextcloud.yourdomain.com/apps/user_oidc/code. Once the app’s tile shows sign-in ready, you read the three values in the .env file of the app’s folder (see Restore one app on any machine), and register the provider inside Nextcloud with occ, run in the app container from the server’s shell, with the id and secret in place of the two placeholders:

Terminal window
CT=$(docker ps -q -f label=com.docker.swarm.service.name=my-nextcloud_app | head -1)
docker exec --user 33 "$CT" php /var/www/html/occ app:enable user_oidc
docker exec --user 33 "$CT" php /var/www/html/occ user_oidc:provider keycloak \
--no-interaction \
--clientid="<client-id>" \
--clientsecret="<client-secret>" \
--discoveryuri="https://auth.yourdomain.com/realms/vps/.well-known/openid-configuration" \
--scope="openid email profile groups" \
--mapping-uid=preferred_username \
--mapping-display-name=name \
--mapping-email=email \
--mapping-groups=groups \
--group-provisioning=1

The command creates the provider or updates it, so you can repeat it. Nextcloud’s login page then shows Log in with keycloak. The local admin account keeps working as a fallback.

The App check tab reads a compose file the way Docker Swarm and your server will, before you deploy it. Only administrators see the tab; staff do not.

  1. Open App check.
  2. In App name, enter the name you will give the app (step 1). Its own address and ports then do not count as taken by another app.
  3. Paste the compose file in Compose file and select Check.
  4. Read the findings. Each one carries its level (error, warning or note), its rule, the service it concerns and what to change. What this means opens the rule in the lists below.
  5. When automatic corrections apply, the page shows the Corrected file and the changes from your file. Copy it into the app’s file in your repository and publish a new version. A finding marked corrected below is already fixed in that file; any other is a choice you make in your file.

Nothing is deployed from this tab. The check compares your file with what your server holds: its other apps’ addresses and ports, the domains it serves, the groups you sign in with and the folders added to the backup. A part the server cannot read is named under the counts, and the checks that depend on it are left out.

Your server also checks the file of each app on the Apps page every hour and after each deploy. On the Apps tab, an administrator sees on a tile with at least one error or warning a link such as App check: errors 1, warnings 2. Notes are not counted. The link opens App check with that app’s findings. The server does not show the deployed file: paste the app’s compose file to see the corrected file. The tile has no link when nothing was found.

A finding is an error when the deploy fails, data is lost, the app cannot be reached or it gets control of the server. It is a warning when a setting does not do what the file says. A note is worth knowing.

Rule Level What it reports
S0 error The file is not valid YAML, is not a compose file, has no services, or has a service that is not a set of keys.
S1 error A key that makes Swarm refuse the whole file: mem_limit, memswap_limit, cpus, cpu_shares, volumes_from, profiles, scale. Limits move to deploy.resources.limits and the replica count to deploy.replicas.
S2 warning A key that Swarm ignores, such as depends_on, restart, container_name, expose, links, external_links, build, network_mode, the short tmpfs or a few others with no Swarm equivalent.
S3 error A port published through the routing mesh, which replaces each visitor’s address with an internal one. Use the long form with mode: host.
S4 error A configs entry that takes its content from a file or inline text. Only external: true configs are accepted.
Rule Level What it reports
K1 error A service with an address is not on catena-network, or the file does not declare that network with external: true: the proxy cannot reach it.
K2 error A service that stores data in a volume is not pinned to the data node. Applies from two nodes.
K3 error Several addresses and not exactly one vps.route.main=true, or two services with the same address.
K4 warning An app-wide label on a service other than the main address, or an address label on a service with no address: it is ignored.
K5 warning A vps.* label under deploy.labels, where Catena does not read it.
K6 note A public address with no vps.health.path.
K7 warning An access label that has no effect: an unknown vps.auth.mode, vps.auth.groups on a public or admin-only address, visitor beside other groups, or a group that does not exist in Keycloak.
K8 note An image tag with no full version, or a vps.auto-update value that is not valid.
K9 error A bind mount outside the apps area that is not listed under Other folders to back up: the nightly backup fails.
K10 warning The file matches a catalog app but is deployed under another name: catalog actions and restores do not find it.
K11 note A vps.* label Catena does not read, such as one that names the app or the service.
K12 warning A published port that no vps.expose.tcp or vps.expose.udp label declares (open to the internet), or a declared port the service does not publish with mode: host. A catalog app granted the host network publishes nothing and draws no warning for its declared ports.
K13 note A database that is not exported before each backup: its volume is copied while it runs.
K14 error An address another app already holds.
K15 error An address that is not under a domain your server serves.
K16 error A port that another app already opens on your server.
K17 error, warning An x-catena block that does not read or does not hold for its file (see Declare the backup mode and update steps): the server refuses the app’s updates. A warning lists the commands the block has the server run in the app’s containers.
Rule Level What it reports
X1 error privileged: true.
X2 error The server’s own network (network_mode: host, or the external host network). A file deployed unchanged from Catena’s catalog whose block declares it passes.
X3 error pid: host, which shows every process of the server.
X4 error ipc: host, which reaches the shared memory of other processes.
X5 error devices, which reaches the server’s hardware.
X6 error The Docker socket mounted in the service, which controls every container. A file deployed unchanged from Catena’s catalog whose block declares it passes.
X7 warning cap_add: added capabilities. Keep each only if the app needs it.
X8 warning A server folder mounted outside the apps area: the app reads the server’s files.
X9 warning A port published on the server, reachable from the internet without signing in.
X10 error Traefik labels, which route nothing. Use vps.route.host and vps.auth.mode or vps.auth.groups.
X11 warning A service on catena-network that keeps raw network access, with which it could forge traffic to Catena’s own services. The corrected file adds cap_drop: [NET_RAW]; an app that needs the access adds cap_add: [NET_RAW] (X7 then reports it).
Symptom Cause and fix
502 or 503 on the address The proxy cannot reach the app. The front-door service is on catena-network, and vps.route.port is the port it listens on. Read the logs of the service with docker service logs <app>_<service> on the server.
403 after signing in The person is in none of the allowed groups. The group in vps.auth.groups exists in Keycloak, matches exactly (letter case included), and the person is a member (People or Keycloak).
Only administrators get in Neither vps.auth.mode nor vps.auth.groups is set, or the mode is not public, private or admin-only and no groups are listed.
No route, or the wrong address or access No vps.route.host, several addresses with none or two marked vps.route.main=true, or a front-door service that is not on catena-network. An app-wide label on a service other than the main address’s is ignored. The route sync runs seconds after a deploy and every hour.
One address does not answer Another address on this server already uses its host name, or its service is not on catena-network. The tile tells an administrator which address and why. Give the address a host name of its own, or join its service to catena-network, then publish a new version and move the app to it.
Red card on the status page A public app whose front page answers an error, an app behind sign-in that does not send visitors to sign in, a vps.health.path that does not answer 200, or a vps.health.expect text that is absent from the reply.
The tile says sign-in failed or sign-in waiting Read the reason on the tile. A return address in vps.auth.oidc.redirect_uris must be https:// on a domain this server serves, with no * and no #. Fix the label in your repository, publish a new version and move the app to it.
Backup failed on a bind mount The mount is outside the apps area. Move the data to a named volume, or list the folder under Settings > Backup repository > Other folders to back up (separated by commas).
Backup failed on a database The dump failed. A MariaDB or MySQL container needs MARIADB_ROOT_PASSWORD or MYSQL_ROOT_PASSWORD; a Postgres container needs a valid POSTGRES_USER.
Update skipped The image tag is not a full version (latest, 1.2), vps.auto-update is off, or the move exceeds the label (a major release under patch). The newest release may also be held back by the vulnerability or waiting-period checks, or the app is held because it was changed outside Catena (see Apps changed outside Catena).
Adding an app is refused The name is already a folder or an app on the server, the version is not a tag of that app in the repository, the repository is not on the list, or the compose file has an error in the check (see Check an app with the panel).
Service restarts in a loop at first deploy Nothing waits for the database. The service’s entrypoint waits for it, as in the Nextcloud example.
Data missing after a node change A service with a volume lacked node.labels.catena.role==data and started on another node with an empty volume.