๐ ๏ธ Platform¶
Platform is the service that hosts the shared platform checkout and runs RefApp, the platform's golden reference application: a deliberately business-free web app that exercises every shared platform capability end to end โ login with optional TOTP 2FA, emailed activation and password-reset links, the users administration page, the error envelope, the cache tiers, the security headers, local-first notes and their images.
Introduction¶
RefApp lives inside the platform repository (the home of the shared
platform-* crates, @platform/* packages and the behavioral contracts) and
deploys as the platform service. It has three jobs: every platform change
lands here first, the conformance suite checks it before any production app
rebuilds, and new own projects start as a copy of it.
The service is named platform, not refapp, on purpose: the service
directory is the platform checkout that every other own project's container
build reads (--build-context platform=/media/ssd/podman/platform), and
podmanctl, the deploy lock, the log and the sudoers rule all follow the
service name. RefApp is simply the application that checkout runs, from its
refapp/ subdirectory โ its quadlets, Dockerfile and vhost use absolute
paths, so the extra directory level changes nothing.
This page covers installing and running the service โ the platform checkout plus RefApp โ as a self-contained web service. How the platform itself is changed and rolled out across the fleet is on Release & rollout, and how every app works offline on Offline & sync.
Info
RefApp is open-source and lives in the platform repository under
refapp/ (Rust backend + React/TypeScript frontend, with the Podman
packaging in refapp/build/, refapp/quadlet/ and refapp/nginx/).
The shared crates and packages it consumes live beside it in crates/
and packages/; the behavioral contracts in contracts/.
Features¶
- ๐ Invite-only accounts with emailed activation links, forgot-password link and optional TOTP 2FA โ admins invite, send links, toggle the admin role and delete users, and any account can delete itself from its profile (the platform's stock
CloseAccountCard), the last admin excepted - ๐ด Local-first PWA: notes and their images kept on the device and synced both ways with live updates over SSE โ images dropped offline (JPEG, PNG, WebP, AVIF, JPEG XL) show at once and upload on reconnect; the fleet's shared sync and file layers, exercised end to end (offline & sync)
- ๐ข The account's avatar opens the user panel (Users, Profile, Sign out) and wears the platform's two status dots: green/red for the backend on the top bar's avatar, green/orange/red for sync on the panel's, which a click opens into the sync popover with Sync now (offline & sync)
- ๐ผ๏ธ A profile picture, the platform's opt-in feature: pick one โ JPEG XL too, decoded in the browser โ crop it in a circle, save a 512 px JPEG; kept on the device for an offline start, changed or removed online only
- ๐งช Zero business features on purpose โ what it demonstrates IS the platform
- โ
First target of the conformance suites (
conformance/in the same repository): the HTTP contract checks and the browser scenarios (offline reloads, hanging networks, offline uploads) - ๐ฑ The seed for new own projects (
/new-appin shared-core)
How it works¶
The backend binary serves both the API and the built React SPA. Users,
sessions, activation tokens, notes and the images' metadata live in PostgreSQL;
the image files themselves are stored under data/files, one folder per kind
with UUID names, so the database stays small. Unlike every other own project, its container
builds take the platform repository itself as a second build context (they
are one and the same checkout here), which is exactly the wiring the other apps
use against /media/ssd/podman/platform.
๐ฅ Installation¶
This section describes how to install and run Podman services using systemd Quadlet, enabling containers to restart automatically. It allows the service to run in its own isolated rootless environment with a dedicated Linux user.
๐ Requirements¶
โ๏ธ Configuration¶
๐ Service Setup¶
Info
Please follow the following installation steps:
Define Service Variables
# define the service name โ the platform repository IS the service dir
SERVICE_NAME=platform
Initialize Service Environment
# built-in bash safety and SERVICE_NAME validation
set -euo pipefail; [ -n "${SERVICE_NAME:-}" ] || { echo "SERVICE_NAME is empty"; exit 1; }
# automatically generate variables
SERVICE_HOME="/media/ssd/podman-users/${SERVICE_NAME}"
SERVICE_DIR="/media/ssd/podman/${SERVICE_NAME}"
SERVICE_USER="${SERVICE_NAME}_svc"
SERVICE_ADMIN="${SERVICE_NAME}_admins"
๐ Service Isolation¶
Configure Rootless Podman UID/GID Namespace Mappings
# allocate rootless Podman UID/GID mappings if missing
SUBID_SIZE=65536
allocate_subids_if_missing() {
local user="$1"
local start
if ! grep -q "^${user}:" /etc/subuid; then
start="$(awk -F: 'BEGIN { max = 100000 } { end = $2 + $3; if (end > max) max = end } END { print max }' /etc/subuid)"
sudo usermod --add-subuids "${start}-$((start + SUBID_SIZE - 1))" "$user"
fi
if ! grep -q "^${user}:" /etc/subgid; then
start="$(awk -F: 'BEGIN { max = 100000 } { end = $2 + $3; if (end > max) max = end } END { print max }' /etc/subgid)"
sudo usermod --add-subgids "${start}-$((start + SUBID_SIZE - 1))" "$user"
fi
}
allocate_subids_if_missing "${SERVICE_USER}"
# verify rootless Podman UID/GID namespace mappings exist
grep -q "^${SERVICE_USER}:" /etc/subuid || { echo "ERROR: missing subuid mappings for ${SERVICE_USER}"; exit 1; }
grep -q "^${SERVICE_USER}:" /etc/subgid || { echo "ERROR: missing subgid mappings for ${SERVICE_USER}"; exit 1; }
# validate that all subordinate ID ranges in a file are non-overlapping
validate_subid_ranges() {
local file="$1"
awk -F: '
{
start = $2
end = $2 + $3 - 1
for (i = 1; i <= count; i++) {
if (start <= ends[i] && end >= starts[i]) {
printf "ERROR: overlapping ranges in %s\n", FILENAME
printf " %s:%s:%s overlaps %s:%s:%s\n",
$1, $2, $3,
names[i], starts[i], sizes[i]
exit 1
}
}
count++
names[count] = $1
starts[count] = start
ends[count] = end
sizes[count] = $3
}
' "$file"
}
validate_subid_ranges /etc/subuid
validate_subid_ranges /etc/subgid
# configure the rootless storage driver BEFORE Podman first initializes its storage.
# fuse-overlayfs is a reliable rootless overlay backend and avoids kernel-version
# quirks of native rootless overlay on ARM/Armbian. It is optional with the default
# userns used here, but recommended; set it before the first storage init so no
# 'podman system reset' is needed later.
sudo -u "${SERVICE_USER}" mkdir -p "${SERVICE_HOME}/.config/containers"
sudo -u "${SERVICE_USER}" tee "${SERVICE_HOME}/.config/containers/storage.conf" >/dev/null <<'EOF'
[storage]
driver = "overlay"
[storage.options.overlay]
mount_program = "/usr/bin/fuse-overlayfs"
EOF
# rebuild rootless Podman state to ensure existing containers/storage use current namespace mappings
# use a login shell (-i) so HOME points at the service user's home
# (storage now initializes with fuse-overlayfs; no 'podman system reset' needed)
sudo -iu "${SERVICE_USER}" podman system migrate
Configure Secure Service Directory Permissions
# Apply the admin/service ACL policy to one directory tree, deterministically.
# $1 = tree to treat
# $2 = (optional) a sub-path to skip entirely (e.g. a container data subtree)
#
# Why setfacl, not chmod: on an ACL'd tree, chmod edits only the mask, never group::,
# so stale execute bits survive. setfacl sets every entry explicitly (mask included),
# lowercase โ the result is reproducible and no execute bit can resurface.
apply_acl_tree() {
local tree="$1"
local skip="${2:-}"
local prune=()
[ -n "$skip" ] && prune=( -path "$skip" -prune -o ) # skip this subtree if a 2nd arg is given
sudo find "$tree" "${prune[@]}" -exec chown "${SERVICE_USER}:${SERVICE_ADMIN}" {} + # service user owns; admin group is the owning group
# directories โ access ACL (perms on the existing dirs)
sudo find "$tree" "${prune[@]}" -type d -exec setfacl -m g:${SERVICE_ADMIN}:rwx {} + # admin group: access existing dirs
sudo find "$tree" "${prune[@]}" -type d -exec setfacl -m u:${SERVICE_USER}:rwx {} + # service user: access existing dirs
sudo find "$tree" "${prune[@]}" -type d -exec setfacl -m u::rwx,g::rwx,o::-,m::rwx {} + # owner/group rwx, deny others, pin mask rwx
# directories โ default ACL (inherited by NEW files/dirs created later)
sudo find "$tree" "${prune[@]}" -type d -exec setfacl -d -m g:${SERVICE_ADMIN}:rwx {} + # admin group: inherit access on new items
sudo find "$tree" "${prune[@]}" -type d -exec setfacl -d -m u:${SERVICE_USER}:rwx {} + # service user: inherit access on new items
sudo find "$tree" "${prune[@]}" -type d -exec setfacl -d -m u::rwx,g::rwx,o::- {} + # default owner/group/other for new items
# files โ access ACL (rw only, never executable)
sudo find "$tree" "${prune[@]}" -type f -exec setfacl -m g:${SERVICE_ADMIN}:rw {} + # admin group: access existing files
sudo find "$tree" "${prune[@]}" -type f -exec setfacl -m u:${SERVICE_USER}:rw {} + # service user: access existing files
sudo find "$tree" "${prune[@]}" -type f -exec setfacl -m u::rw,g::rw,o::-,m::rw {} + # owner/group rw, deny others, pin mask rw
sudo find "$tree" "${prune[@]}" -type d -exec chmod g+s {} + # setgid: new items inherit the group (a MODE bit; ACLs can't set it)
}
# --- SERVICE_HOME: Podman's private runtime home ------------------------------
# Own the home + set setgid, then ACL ONLY the .config subtree (containers.conf,
# storage.conf, and the *.network / *.container quadlets you hand-edit) so admins
# can edit them sudo-less.
#
# Leave ~/.local (storage) and ~/.cache to Podman: ACLs on the overlay store break
# the runtime ("OCI permission denied" on the `merged` mount). Admin reaches them via sudo.
sudo chown "${SERVICE_USER}:${SERVICE_ADMIN}" "${SERVICE_HOME}" # service user owns; admin group is the owning group
sudo chmod u=rwx,g=rwx,o=,g+s "${SERVICE_HOME}" # owner/admin access; deny others; inherit group on new items
apply_acl_tree "${SERVICE_HOME}/.config" # .config subtree: full sudo-less admin ACL treatment
# --- bind-mount data dirs: created + owned BEFORE the SERVICE_DIR ACL pass ---------
# Each container writes as a mapped subuid, so its data dir must be owned by that subuid
# (chown as root; only root crosses the id range). We create + own them FIRST so they
# neither inherit SERVICE_DIR's default ACL (inheritance happens at creation) nor get
# walked by apply_acl_tree (which prunes data/).
#
# No ACLs here: Postgres refuses a group-accessible PGDATA. Admin reaches them via sudo;
# back Postgres up with pg_dumpall, not a raw file copy.
SUBUID_BASE="$(awk -F: -v u="${SERVICE_USER}" '$1==u {print $2}' /etc/subuid)"
SUBGID_BASE="$(awk -F: -v u="${SERVICE_USER}" '$1==u {print $2}' /etc/subgid)"
# data subdir : in-container uid/gid it must be owned by
data_dirs=(
"postgres-db:70" # /var/lib/postgresql โ postgres user (pinned User=70:70)
"files:1000" # /data/files โ note images, UUID names (image USER 1000)
)
mkdir -p "${SERVICE_DIR}/data" # the data/ parent: plain, no ACL
sudo chown "${SERVICE_USER}:${SERVICE_ADMIN}" "${SERVICE_DIR}/data" # owner/admin
sudo chmod 0750 "${SERVICE_DIR}/data" # children are set individually below
for entry in "${data_dirs[@]}"; do
sub="${entry%%:*}" # subdir name
ids="${entry#*:}" # "uid" or "uid:gid"
uid="${ids%%:*}" # uid
gid="${ids#*:}"; [ "$gid" = "$ids" ] && gid="$uid" # gid (defaults to uid)
dir="${SERVICE_DIR}/data/${sub}"
sudo mkdir -p "$dir"
sudo chown -R "$((SUBUID_BASE + uid - 1)):$((SUBGID_BASE + gid - 1))" "$dir" # map both to host subids
sudo chmod 0700 "$dir"
done
# --- SERVICE_DIR: application data, env files, configuration -------------------
# Full treatment so the admin group gets sudo-less access AND the service user keeps
# access even on admin-created files. data/ is EXCLUDED: those dirs are owned by the
# containers' subuids and need engine-specific modes (Postgres rejects a group/other-
# accessible PGDATA), so they are handled separately just below.
apply_acl_tree "${SERVICE_DIR}" "${SERVICE_DIR}/data" # treat everything EXCEPT data/
Platform-specific addition โ read access for the other apps' builds
Every OTHER own project's container build copies crates/ and packages/
from this checkout as its own service user (--build-context
platform=/media/ssd/podman/platform), and the default ACL above closes
every file to "others". Re-open exactly what those builds read โ traverse
on the checkout root, read on crates/, packages/ and
PLATFORM_VERSION โ and nothing else: platform.env / postgres.env
carry the SMTP and database secrets, and a group/other-readable PGDATA
stops Postgres from starting.
sudo chmod o+x "${SERVICE_DIR}" # traverse only: no listing, env files stay closed
sudo chmod -R a+rX "${SERVICE_DIR}/crates" "${SERVICE_DIR}/packages" # what the builds COPY
sudo chmod a+r "${SERVICE_DIR}/PLATFORM_VERSION"
Never chmod -R a+rX "${SERVICE_DIR}". podmanctl --deploy-run re-applies
these three lines after every pull, before any build โ
ownership and modes must never change after a build, since Podman keys
COPY layers on the copied files' tar headers (see Podman).
NGINX static assets permissions โ not needed here
The other pages grant www-data read access to a ${SERVICE_DIR}/nginx
assets tree. RefApp serves everything itself (the SPA, its icons and the
manifest come from the container), and the only file NGINX reads from this
checkout is the vhost: /etc/nginx/sites-enabled/platform.fum-server.fr is
a symlink to refapp/nginx/platform.fum-server.fr, read by the root master
process โ skip that step. A push that changes the vhost reaches the link
at once, but NGINX reads it only on sudo nginx -t && sudo service nginx reload.
๐ก๏ธ Rootless Podman Defaults¶
Configure rootless Podman per-user configuration directory
# configure rootless Podman defaults for this service user
# - store per-user Podman configuration under ~/.config/containers
sudo -u "${SERVICE_USER}" mkdir -p "${SERVICE_HOME}/.config/containers/systemd"
Configure rootless Podman defaults
# configure rootless Podman defaults for this service user
# - use k8s-file logging for easier log rotation and inspection
sudo -u "${SERVICE_USER}" tee "${SERVICE_HOME}/.config/containers/containers.conf" >/dev/null <<EOF
[containers]
log_driver = "k8s-file"
[engine]
healthcheck_events=false
EOF
๐ง Service Configuration¶
Setup Platform Parameters
Before deploying, you need to define a few environment variables that will be used throughout the setup process.
BASE_URL: public URL where the web service is accessibleHOST_PORT: external port used by NGINX to route traffic to the serviceADMIN_EMAIL: the default admin account (email only โ there is no admin password in the env)- SMTP settings: required so the admin and invited users receive their activation links (startup aborts if the relay is unreachable)
###################################################################################
# NGINX Proxy Configuration
###################################################################################
HOST_PORT=10085
###################################################################################
# Postgres Configuration
###################################################################################
PG_VERSION=18-alpine
PG_DB=platform
PG_USER=platform
PG_PASSWORD="$(openssl rand -hex 32)"
###################################################################################
# RefApp Configuration
###################################################################################
BASE_URL=https://platform.domain.fr
ADMIN_EMAIL=admin@domain.fr
###################################################################################
# SMTP Configuration
###################################################################################
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_SECURITY=starttls
SMTP_USER=platform@domain.fr
SMTP_PASSWORD=your-smtp-password
SMTP_FROM=platform@domain.fr
Getting Your Gmail SMTP Password
To send emails via Gmail SMTP, you'll need to generate an App Password.
A special password used for third-party applications.
Visit myaccount.google.com/apppasswords to create one.
Use the generated password in the SMTP_PASSWORD field of your configuration.
Create the Environment file
sudo -u "${SERVICE_USER}" tee "${SERVICE_DIR}/postgres.env" >/dev/null <<EOF
###################################################################################
# Postgres Database Configuration
###################################################################################
POSTGRES_VERSION=${PG_VERSION}
POSTGRES_DB=${PG_DB}
POSTGRES_USER=${PG_USER}
POSTGRES_PASSWORD=${PG_PASSWORD}
EOF
sudo -u "${SERVICE_USER}" tee "${SERVICE_DIR}/platform.env" >/dev/null <<EOF
###################################################################################
# NGINX Proxy Configuration
###################################################################################
HOST_PORT=${HOST_PORT}
###################################################################################
# Base image versions (build)
###################################################################################
RUST_VERSION=1.98.0
NODE_VERSION=24
DEBIAN_TAG=trixie-slim
###################################################################################
# RefApp Configuration
###################################################################################
PLATFORM_BASE_URL=${BASE_URL}
PLATFORM_COOKIE_SECURE=true
###################################################################################
# Default admin (seeded 'pending' on first start)
###################################################################################
PLATFORM_ADMIN_EMAIL=${ADMIN_EMAIL}
###################################################################################
# SMTP (required for new-user validation emails)
###################################################################################
SMTP_HOST=${SMTP_HOST}
SMTP_PORT=${SMTP_PORT}
SMTP_SECURITY=${SMTP_SECURITY}
SMTP_USER=${SMTP_USER}
SMTP_PASSWORD=${SMTP_PASSWORD}
SMTP_FROM=${SMTP_FROM}
EOF
Keep the .env files
All the secret informations will be stored in the .env files.
Optional tunables (defaults shown in
platform.prod.env.example, at the clone root):PLATFORM_SESSION_TTL_HOURS=720,PLATFORM_ACTIVATION_TTL_HOURS=72,PLATFORM_TRUSTED_PROXIES=127.0.0.1,::1.
๐งฉ Quadlet Service¶
Install the Quadlet units
# the units live in the repository under refapp/quadlet/ โ every .build unit already
# carries the platform build context:
# PodmanArgs=--build-context platform=/media/ssd/podman/platform
# link them into the service user's systemd directory (the generator and
# podmanctl read them there). Re-runnable: every existing link into refapp/quadlet/
# is dropped first โ stale or dangling ones included โ then the current set
# is linked, replacing a plain file of the same name if one is left over.
# find runs from / : GNU find returns to its starting directory when done, and a
# directory the service user cannot enter (the admin's home, mode 700) fails it โ AFTER
# the delete, so a `set -e` shell would stop before relinking
(cd / && sudo -u "${SERVICE_USER}" find "${SERVICE_HOME}/.config/containers/systemd" -maxdepth 1 -type l -lname "${SERVICE_DIR}/refapp/quadlet/*" -delete)
sudo -u "${SERVICE_USER}" ln -sf "${SERVICE_DIR}"/refapp/quadlet/* "${SERVICE_HOME}/.config/containers/systemd/"
Symlinks, so a pull that edits a unit is live at the next
systemctl --user daemon-reload(podmanctl --updatereloads after its pull). A pull that adds, renames or removes a unit needs the two lines again, then the reload โ quadlet changes are rare and deliberate.
Enable and Start Quadlet Services
# open an interactive shell as the service user
sudo -iu "${SERVICE_USER}"
# reload systemd user units
systemctl --user daemon-reload
# build the refapp image (first install; podmanctl --update rebuilds afterwards)
systemctl --user start platform-build.service
# start Podman Quadlet services
systemctl --user start platform-server.service
# verify service status
systemctl --user status platform-server.service
To follow the service logs in real time, run
sudo journalctl _UID=$(id -u ${SERVICE_USER}) -ffrom the Debian account.
๐ Deploy Platform¶
Install NGINX
NGINX needs to be installed, follow the NGINX section.
Configure NGINX
NGINX needs to be configured using a file in /etc/nginx/sites-enabled directory.
This configuration file is the repository's refapp/nginx/platform.fum-server.fr:
# Per-client rate-limit bucket for the auth endpoints (login is Argon2-costly;
# activate consumes single-use tokens). Must live in http{} context, so it sits
# outside the server{} block. Keys on $limit_key, the shared key from nginx.conf.
limit_req_zone $limit_key zone=platform_auth:10m rate=10r/m;
# Per-client bucket for the liveness probe. Its own zone, NEVER a share of the
# auth one: while offline the app probes /api/health every 15 s per tab, and a
# production conformance run probes it too โ at the auth zone's 10r/m that
# traffic spent the bucket a sign-in needs, and nginx refused the next login
# with a 429 (platform/INBOX.md, 2026-09-25). Generous on purpose (the app
# probes only while it believes it is offline): a ceiling, not a brake.
limit_req_zone $limit_key zone=platform_health:10m rate=60r/m;
# Per-client connection buckets for the two SSE streams โ long-lived by design
# (one per open tab), so they are capped per source IP rather than rate-limited.
# The app caps the total itself; these only stop one client from holding every
# worker connection.
limit_conn_zone $limit_key zone=platform_events:10m;
limit_conn_zone $limit_key zone=platform_sync_events:10m;
# File uploads (contracts/files.md): PUTs are rate-limited per source IP so a
# client cannot churn the staging area; GETs pass freely โ a device fills its
# caches with every thumbnail at once. limit_req skips an empty key.
map $request_method $platform_upload_key {
default $limit_key;
GET "";
HEAD "";
}
limit_req_zone $platform_upload_key zone=platform_uploads:10m rate=120r/m;
# The app container, published on the loopback by its Quadlet unit. The
# upstream block exists for `keepalive`: without it nginx opens โ and closes โ
# a brand-new TCP connection per proxied request, each crossing rootless
# Podman's userspace port forwarder.
upstream platform_app {
server 127.0.0.1:10085;
keepalive 32;
keepalive_requests 1000;
keepalive_timeout 60s;
}
server {
server_name platform.domain.fr;
# Security headers; `always` also covers the NGINX-served error pages. No
# Content-Security-Policy here: the app serves its own on every response โ
# the app's CSP is the single source of truth.
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# The app sends these four itself (platform-http's security headers, HSTS
# once its cookies are Secure): hidden here, so each reaches the browser once,
# with the values above. A browser keeps only the FIRST HSTS header it gets,
# and the app's (one year) came first.
proxy_hide_header Strict-Transport-Security;
proxy_hide_header X-Content-Type-Options;
proxy_hide_header X-Frame-Options;
proxy_hide_header Referrer-Policy;
# setup 404 error_page
error_page 404 /404.html;
include snippets/error-404.conf;
# show maintenance page when backend is down
error_page 502 503 504 /maintenance.html;
include snippets/error-maintenance.conf;
# The backend answers unknown /api/* paths with a JSON 404; keep that body
# instead of swapping it for the HTML error page.
proxy_intercept_errors off;
# Default ceiling for the whole vhost. Every route posts JSON, so 1 MB is
# generous โ except the sync batch and the file uploads, which get their own
# ceilings below.
client_max_body_size 1m;
# Every unauthenticated auth endpoint, rate-limited before hitting the app
# (contracts/auth.md): a public route the router gains is added here.
# `activation/<token>` is included and is why the pattern has no trailing `$`.
# Keep the port in sync with HOST_PORT in platform.env.
location ~ ^/api/auth/(login|activate|forgot-password|confirm-email|activation/) {
limit_req zone=platform_auth burst=5 nodelay;
limit_req_status 429;
proxy_pass http://platform_app;
proxy_http_version 1.1;
include proxy_params;
# Empty Connection header: keeps the pooled upstream connection alive.
proxy_set_header Connection "";
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_redirect off;
}
# SSE liveness stream โ long-lived by design, so it is capped per source IP
# and must not be buffered (buffering would swallow the pings and the app
# would call itself offline). 16: a normal browser's tab count with room for
# a reload overlapping the stream it replaces; under HTTP/2 nginx counts each
# concurrent request as a connection.
location = /api/events {
limit_conn platform_events 16;
limit_conn_status 429;
proxy_pass http://platform_app;
proxy_http_version 1.1;
include proxy_params;
# Empty Connection header: keeps the pooled upstream connection alive.
proxy_set_header Connection "";
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_redirect off;
proxy_buffering off;
proxy_read_timeout 1h;
}
# SSE change stream (contracts/sync.md) โ the same long-lived, unbuffered
# treatment as the liveness stream (buffering would hold the `sync` events
# until the connection closed, which is never), but authenticated and on its
# own connection bucket. An exact-match location: it would otherwise fall
# through to `location /` and be buffered there.
location = /api/events/sync {
limit_conn platform_sync_events 8;
limit_conn_status 429;
proxy_pass http://platform_app;
proxy_http_version 1.1;
include proxy_params;
# Empty Connection header: keeps the pooled upstream connection alive.
proxy_set_header Connection "";
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_redirect off;
proxy_buffering off;
proxy_read_timeout 1h;
}
# The sync round trip (contracts/sync.md): up to 500 changes per collection
# in one JSON body, so the vhost's 1 MB ceiling is not enough here. A 413
# would count as a failed round on the phone and keep its outbox waiting.
location = /api/sync {
client_max_body_size 8m;
proxy_pass http://platform_app;
proxy_http_version 1.1;
include proxy_params;
# Empty Connection header: keeps the pooled upstream connection alive.
proxy_set_header Connection "";
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_redirect off;
}
# Note images' bytes (contracts/files.md): each image uploads three files
# (thumb, display, original). Above the app's largest slot (32 MB), and the
# body is streamed to the app instead of spooled to the proxy's disk โ the
# app stages it itself. A stalled body still ends on client_body_timeout.
location ~ ^/api/notes/images/[^/]+/(thumb|display|original)$ {
limit_req zone=platform_uploads burst=60 nodelay;
limit_req_status 429;
client_max_body_size 40m;
proxy_request_buffering off;
proxy_pass http://platform_app;
proxy_http_version 1.1;
include proxy_params;
# Empty Connection header: keeps the pooled upstream connection alive.
proxy_set_header Connection "";
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_redirect off;
}
# Liveness probe โ public, but every hit runs a DB query against a
# 5-connection pool, so it is rate-limited, in its own zone (see that zone's
# comment: sharing the auth one let the probes spend the sign-in budget).
location = /api/health {
limit_req zone=platform_health burst=20 nodelay;
limit_req_status 429;
proxy_pass http://platform_app;
proxy_http_version 1.1;
include proxy_params;
# Empty Connection header: keeps the pooled upstream connection alive.
proxy_set_header Connection "";
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_redirect off;
}
# reverse proxy
location / {
proxy_pass http://platform_app;
# keep it HTTP/1.1
proxy_http_version 1.1;
# forwarded headers; overwrite X-Forwarded-For with the real peer so the
# app can never be handed a forged hop.
include proxy_params;
# Empty Connection header: keeps the pooled upstream connection alive.
proxy_set_header Connection "";
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_redirect off;
}
}
# restart nginx
sudo nginx -t && sudo service nginx restart
Replace
platform.domain.frby the name of your website, and10085byHOST_PORT. The two/api/events*locations must stay exact-match and unbuffered: an SSE stream that falls intolocation /is buffered and cut at 60 s, and the app would call itself offline. The image location must keep its own 40 MB ceiling andproxy_request_buffering off: under the vhost's 1 MB an original upload is refused with a 413.
Activate HTTPS
To activate HTTPS protocol, follow the Let's Encrypt section.
First login โ activate the admin account
The admin account is created pending on first start (from PLATFORM_ADMIN_EMAIL).
There is no admin password in platform.env. To activate it:
- On first start, the server e-mails an activation link to
PLATFORM_ADMIN_EMAIL(SMTP must be reachable โ startup aborts otherwise). - Open the activation link from the e-mail, choose a password, and you are signed in.
- The link is single-use and expires after
PLATFORM_ACTIVATION_TTL_HOURS(72 h by default; password-reset links live 1 h).
โฌ๏ธ Updating Platform¶
RefApp deploys from a dev machine: refapp/deploy.ps1 builds the production image for the
server's architecture, publishes it to the fleet registry, then pushes the
commit: that order is the whole invariant. A push redeploys RefApp only, and this checkout's
commit is half of every app's image tag โ an app deploy is refused until the platform push it
names has landed here.
That push fires the bare repo's hook, which calls podmanctl --deploy platform <short-sha>:
it acknowledges the push, then deploys detached โ pull, re-own, re-open
this checkout's scoped read access for the other apps' builds, then no build: it pulls
<registry>/refapp:<app sha>.<platform sha> and starts it, or refuses before stopping
anything when nothing is published. Follow a deploy with sudo tail -f /var/log/platform-deploy.log.
๐ Deploying a change
From refapp/ inside the platform clone on a dev machine:
git add -u && git commit -m "..."
./deploy.ps1
deploy.ps1 does everything in order: build โ push the image โ git push, and that last
push is what fires podmanctl --deploy platform through the hook, exactly as it does for
every other app. Never push first โ the hook would fire before the image exists and the
deploy would be refused.
Unlike an app's script there is no sibling clone to check: RefApp lives inside the
platform repo, so the two commits in its tag come from the same checkout. It refuses early
rather than wasting a build when refapp/ or the platform build inputs (crates/,
packages/, PLATFORM_VERSION) are dirty โ INBOX.md is modified by design and ignored.
Like every app's copy, it also refuses a branch that has diverged from the server; with
nothing left to push it asks /api/version what production runs (exit 3 when it runs HEAD,
marked unconfirmed when it cannot say; exit 4 after publishing the missing image when it
runs another commit), and after a push it waits until the new commit runs.
๐งฐ Dev machine setup
Docker Desktop's build-cache policy and the shared cargo build folder are on Build & deploy.
๐ช One-time setup โ hook and clone wiring
The deploy entry point is central and installed once for the whole fleet โ
see Podman โ Push deploys for
podmanctl --deploy and its single sudoers rule. It re-opens this checkout's
scoped read access on every push, because it finds a PLATFORM_VERSION file
here. Only the two steps below are per-repository.
# install into the bare repo's hooks/ โ fires on every push
# ${SERVICE_NAME} expands now; the rest stays literal (\$โฆ) for git to expand
sudo tee /media/ssd/git/podman/${SERVICE_NAME}.git/hooks/post-receive >/dev/null <<EOF
#!/bin/sh
set -eu
DEPLOY_BRANCH=refs/heads/master
while read -r oldrev newrev refname; do
[ "\$refname" = "\$DEPLOY_BRANCH" ] || continue # only the deploy branch
sudo /usr/local/bin/podmanctl --deploy ${SERVICE_NAME} "\$(git rev-parse --short "\$newrev")" </dev/null
done
EOF
# normalize the hook ownership
sudo chown www-data:debian /media/ssd/git/podman/${SERVICE_NAME}.git/hooks/post-receive
sudo chmod 0750 /media/ssd/git/podman/${SERVICE_NAME}.git/hooks/post-receive
# git seeded hooks/ with executable *.sample files โ inert, but strip the bit
sudo chmod -x /media/ssd/git/podman/${SERVICE_NAME}.git/hooks/*.sample
The commit comes from the just-pushed ref (the bare repo already holds it) and only names the deploy in its log. Nothing else is baked in:
podmanctlderives the service directory, user, admin group, env file and log path from the name it is given.
# deploys pull non-interactively as root: pull from the co-located bare repo over a
# LOCAL path (the HTTPS origin needs a credential prompt + TTY, which a hook lacks)
git -C ${SERVICE_DIR} remote set-url origin /media/ssd/git/podman/${SERVICE_NAME}.git
# trust both repos for root so the root-run `git pull` doesn't refuse with
# "detected dubious ownership"; --system applies whatever HOME the hook runs with
sudo git config --system --add safe.directory ${SERVICE_DIR}
sudo git config --system --add safe.directory /media/ssd/git/podman/${SERVICE_NAME}.git
Without the local origin the pull dies with could not read Username for 'https://โฆ'; without
safe.directoryit dies with detected dubious ownership. Either way the worker aborts beforepodmanctl --updateand leaves the service running on its previous build.
๐งฑ Upgrading the base images
Every image the build starts FROM is pinned in platform.env โ
rust:${RUST_VERSION}-slim-trixie, node:${NODE_VERSION}-alpine, debian:${DEBIAN_TAG},
alongside the stack's postgres:${POSTGRES_VERSION}. The .build units pass them as
--build-arg and refapp/build/Dockerfile carries matching ARG defaults, so nothing moves under
the fleet on its own. RefApp builds on a dev machine, so the bases are pulled there;
here --upgrade still refreshes postgres:${POSTGRES_VERSION}. Refreshing is a
deliberate step:
podmanctl --list --check # UPDATE column shows when an upstream image moved
podmanctl --upgrade platform # pull bases + registry images, then rebuild + restart
The env files are gitignored: add a new key on the server before the push that needs it, or a quadlet expanding
${RUST_VERSION}against a missing key buildsrust:-slim-trixieand fails. A bumpedRUST_VERSIONinvalidates the whole Rust layer chain โ toolchain, every dependency crate (a fresh cargo cache) โ so that rebuild runs 15 min+ on the ARM host: schedule it, don't treat it as a routine deploy. Edit and push a pin first (refapp/build/Dockerfile,platform.env,postgres.env), then--upgrade.