# 01 — Infrastructure: Docker, Asterisk base, networking ## 1. Containers | Service | Image | Network | Why | |---|---|---|---| | `postgres` | `postgres:18-alpine` | bridge, port published on `127.0.0.1:${POSTGRES_PORT:-5433}` | Port 5433 avoids clashing with a PostgreSQL the host may already run on 5432 | | `asterisk` | built from `asterisk/Dockerfile` | **host** | SIP and 10,001 RTP ports. Docker port-publishing a 10k UDP range is slow and breaks NAT handling. Host networking is the standard fix. | | `api` | built from `apps/api/Dockerfile` | **host** | Needs AMI on `127.0.0.1:5038` and serves on `127.0.0.1:${API_PORT:-8080}` | | `caddy` (optional, compose profile `caddy`) | `caddy:2` | host | TLS on 80/443 for people without a reverse proxy | ### 1.1 `docker-compose.yml` (write exactly this, then adjust only if a test fails) ```yaml name: selfphoneng x-shared-volumes: &shared-volumes - ./data/asterisk-generated:/etc/asterisk/selfphoneng - ./data/voicemail:/var/spool/asterisk/voicemail - ./data/recordings:/var/spool/asterisk/monitor - ./data/sounds-custom:/var/lib/asterisk/sounds/custom - ./data/moh:/var/lib/asterisk/moh services: postgres: image: postgres:18-alpine restart: unless-stopped environment: POSTGRES_DB: selfphoneng POSTGRES_USER: selfphoneng POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set in .env} volumes: - ./data/postgres:/var/lib/postgresql # PG 18 images keep data in /var/lib/postgresql/18/docker; mount the parent, NOT .../data ports: - "127.0.0.1:${POSTGRES_PORT:-5433}:5432" healthcheck: test: ["CMD-SHELL", "pg_isready -U selfphoneng -d selfphoneng"] interval: 5s retries: 20 asterisk: build: context: ./asterisk args: ASTERISK_UID: ${APP_UID:-1000} ASTERISK_GID: ${APP_GID:-1000} restart: unless-stopped network_mode: host environment: AMI_SECRET: ${AMI_SECRET:?set in .env} TZ: ${TZ:-America/Chicago} volumes: - ./data/asterisk-generated:/etc/asterisk/selfphoneng - ./data/voicemail:/var/spool/asterisk/voicemail - ./data/recordings:/var/spool/asterisk/monitor - ./data/sounds-custom:/var/lib/asterisk/sounds/custom - ./data/moh:/var/lib/asterisk/moh - ./data/astdb:/var/lib/asterisk/db - ./data/asterisk-logs:/var/log/asterisk api: build: context: . dockerfile: apps/api/Dockerfile restart: unless-stopped network_mode: host user: "${APP_UID:-1000}:${APP_GID:-1000}" depends_on: postgres: { condition: service_healthy } asterisk: { condition: service_started } env_file: .env environment: DATABASE_URL: postgres://selfphoneng:${POSTGRES_PASSWORD}@127.0.0.1:${POSTGRES_PORT:-5433}/selfphoneng AMI_HOST: 127.0.0.1 AMI_PORT: "5038" AMI_USERNAME: selfphoneng volumes: - ./data/asterisk-generated:/etc/asterisk/selfphoneng - ./data/voicemail:/var/spool/asterisk/voicemail - ./data/recordings:/var/spool/asterisk/monitor - ./data/sounds-custom:/var/lib/asterisk/sounds/custom - ./data/moh:/var/lib/asterisk/moh - ./data/backups:/var/lib/selfphoneng/backups - ./data/tls:/var/lib/selfphoneng/tls caddy: image: caddy:2 profiles: ["caddy"] restart: unless-stopped network_mode: host environment: PUBLIC_HOSTNAME: ${PUBLIC_HOSTNAME:?set in .env} volumes: - ./deploy/caddy/Caddyfile:/etc/caddy/Caddyfile:ro - ./data/caddy:/data ``` (The `x-shared-volumes` anchor documents the shared set. Services list volumes explicitly so each one is easy to read.) **File ownership:** Asterisk and the api both read and write the shared folders. Both run as the same UID/GID (`APP_UID`/`APP_GID`, default 1000). `scripts/install.sh` creates `./data/*` owned by that UID. ### 1.2 `.env.example` ```dotenv # --- required --- PUBLIC_HOSTNAME=pbx.example.com # DNS name browsers and phones use POSTGRES_PASSWORD=change-me # install.sh generates a random one AMI_SECRET=change-me # install.sh generates a random one APP_SECRET=change-me # 32 random bytes, base64. Encrypts secrets in the DB. NEVER change after first run. INITIAL_ADMIN_EMAIL=admin@example.com INITIAL_ADMIN_PASSWORD=change-me # only used if no admin exists yet # --- optional --- TZ=America/Chicago API_PORT=8080 POSTGRES_PORT=5433 APP_UID=1000 APP_GID=1000 LOG_LEVEL=info TRUST_PROXY=true # true when behind Caddy/nginx ``` `apps/api/src/config.ts` validates these with zod at startup. If one is missing or invalid, it logs a clear message and exits with code 1. ### 1.3 `scripts/install.sh` 1. Check `docker` and `docker compose` exist. Otherwise print how to install them and exit. 2. If `.env` is missing, copy `.env.example` and fill `POSTGRES_PASSWORD` and `AMI_SECRET` with `openssl rand -hex 32` (hex, because the Postgres password is embedded in `DATABASE_URL` and base64 can contain `/`), and `APP_SECRET` with `openssl rand -base64 32`. Prompt for `PUBLIC_HOSTNAME` and the admin email and password. 3. `mkdir -p` every `./data/*` folder and `chown` it to `APP_UID:APP_GID`. 4. Ask "Use bundled Caddy for HTTPS? [y/N]". If yes, run `docker compose --profile caddy up -d --build`. Otherwise run `docker compose up -d --build` and print the path of `deploy/nginx/selfphoneng.conf.example`. 5. Print the URL to open. ## 2. Asterisk image (`asterisk/Dockerfile`) Build Asterisk **24 LTS from source**. Pin an exact version: the newest 24.x release on the day you write it, recorded here in the same commit. **Pinned: `24.0.0-rc2` (2026-10-17).** The file in the repo is the source of truth; this copy is kept in sync. > **Asterisk 24 status (checked 2026-10-17):** Asterisk 24 is the LTS line, but > only release candidates are published so far (24.0.0-rc2). Until 24.0.0 is final, > develop against the newest 24.0.0-rcN. Release candidates may sit under a > different download folder than `releases/`, so check the directory listing. Switch to 24.0.0 (or newer > 24.x) as soon as it is final, and don't ship v1.0 on a release candidate. If a module, option > or dialplan function in this spec turns out to have changed in 24, follow the Asterisk 24 > docs and update the spec (AGENTS rule 10). ```dockerfile # SPDX-License-Identifier: AGPL-3.0-or-later FROM debian:trixie-slim AS build ARG ASTERISK_VERSION=24.0.0-rc2 ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential ca-certificates curl wget pkg-config autoconf automake libtool \ libedit-dev libjansson-dev libxml2-dev uuid-dev libsqlite3-dev libssl-dev \ libsrtp2-dev libcurl4-openssl-dev libspeexdsp-dev libogg-dev libvorbis-dev \ libopus-dev subversion bzip2 patch python3 xmlstarlet WORKDIR /usr/src RUN curl -fsSLO https://downloads.asterisk.org/pub/telephony/asterisk/releases/asterisk-${ASTERISK_VERSION}.tar.gz \ && curl -fsSLO https://downloads.asterisk.org/pub/telephony/asterisk/releases/asterisk-${ASTERISK_VERSION}.sha256 \ && sha256sum -c asterisk-${ASTERISK_VERSION}.sha256 \ && tar xzf asterisk-${ASTERISK_VERSION}.tar.gz WORKDIR /usr/src/asterisk-${ASTERISK_VERSION} # The mp3 decoder source (LGPL) must be fetched before configure so menuselect can enable format_mp3. RUN contrib/scripts/get_mp3_source.sh || echo "mp3 source unavailable, format_mp3 skipped" RUN ./configure --with-pjproject-bundled --with-jansson-bundled \ && make menuselect.makeopts \ && menuselect/menuselect \ --disable BUILD_NATIVE \ --enable format_mp3 \ --enable CORE-SOUNDS-EN-WAV --enable CORE-SOUNDS-EN-G722 \ --enable EXTRA-SOUNDS-EN-WAV --enable EXTRA-SOUNDS-EN-G722 \ --enable MOH-OPSOUND-WAV \ menuselect.makeopts \ && make -j"$(nproc)" && make install DESTDIR=/out \ && mkdir -p /out/usr/share/selfphoneng \ && mv /out/var/lib/asterisk/moh /out/usr/share/selfphoneng/moh-opsound # Debian's /var/run is a symlink to /run; a real directory here breaks the COPY below. RUN rm -rf /out/var/run \ && mkdir -p /out/etc/asterisk \ && cp configs/samples/indications.conf.sample /out/etc/asterisk/indications.conf FROM debian:trixie-slim ARG ASTERISK_UID=1000 ARG ASTERISK_GID=1000 ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update && apt-get install -y --no-install-recommends \ libedit2 libjansson4 libxml2 libuuid1 libsqlite3-0 libssl3t64 libsrtp2-1 \ libcurl4t64 libspeexdsp1 libogg0 libvorbis0a libvorbisenc2 libvorbisfile3 libopus0 \ gettext-base ca-certificates tzdata \ && rm -rf /var/lib/apt/lists/* \ && groupadd -g ${ASTERISK_GID} asterisk && useradd -u ${ASTERISK_UID} -g asterisk -d /var/lib/asterisk -s /usr/sbin/nologin asterisk COPY --from=build /out/ / COPY conf/ /etc/asterisk/ COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh && mkdir -p /etc/asterisk/selfphoneng /var/lib/asterisk/db /var/lib/asterisk/moh /var/run/asterisk \ && chown -R asterisk:asterisk /etc/asterisk /var/lib/asterisk /var/spool/asterisk /var/log/asterisk /var/run/asterisk ENTRYPOINT ["/entrypoint.sh"] ``` Notes for the implementer: - Debian package names change between releases. If an `apt-get install` fails on `trixie`, find the right name with `apt-cache search`. Do not switch base image. - `format_mp3` downloads mp3 decoder source (LGPL) with `get_mp3_source.sh`, which must run before `configure`. That is allowed. If it fails, the build continues without it. Uploaded MP3s are converted by ffmpeg in the api anyway. - **Do not** enable `codec_opus` or `codec_g729` from menuselect. Those are closed-source binary downloads. Browsers and phones get G.722 / µ-law instead. - The `.sha256` file name pattern on downloads.asterisk.org may differ. Check the directory listing and use whatever checksum file is published. ### 2.1 `asterisk/entrypoint.sh` ```bash #!/bin/sh # SPDX-License-Identifier: AGPL-3.0-or-later set -eu # 1. Render templates that need secrets from env. envsubst '${AMI_SECRET}' < /etc/asterisk/manager.conf.template > /etc/asterisk/manager.conf chown asterisk:asterisk /etc/asterisk/manager.conf chmod 600 /etc/asterisk/manager.conf # 2. Make sure every generated include file exists so Asterisk can boot # before the api has generated anything. for f in pjsip_selfphoneng.conf extensions_selfphoneng.conf extensions_globals_selfphoneng.conf voicemail_selfphoneng.conf \ queues_selfphoneng.conf musiconhold_selfphoneng.conf rtp_selfphoneng.conf \ confbridge_selfphoneng.conf; do [ -f "/etc/asterisk/selfphoneng/$f" ] || : > "/etc/asterisk/selfphoneng/$f" done # 3. First start: seed the default music-on-hold class with the stock opsound files (CC-BY-SA). if [ -z "$(ls -A /var/lib/asterisk/moh/default 2>/dev/null)" ]; then mkdir -p /var/lib/asterisk/moh/default cp /usr/share/selfphoneng/moh-opsound/*.wav /var/lib/asterisk/moh/default/ fi # Bind-mounted volumes arrive owned by the host; Asterisk runs as the asterisk user. for d in /etc/asterisk/selfphoneng /var/lib/asterisk/db /var/lib/asterisk/moh /var/log/asterisk \ /var/spool/asterisk/voicemail /var/spool/asterisk/monitor /var/lib/asterisk/sounds/custom; do mkdir -p "$d" && chown -R asterisk:asterisk "$d" done # 4. Run in foreground as the asterisk user. exec /usr/sbin/asterisk -f -U asterisk -G asterisk -vvv ``` ## 3. Static Asterisk config files (`asterisk/conf/`) These are written once by hand and copied into the image. Anything that depends on DB data goes in a generated file under `/etc/asterisk/selfphoneng/` that the static file `#include`s. ### 3.1 `asterisk.conf` ```ini [directories](!) astetcdir => /etc/asterisk astmoddir => /usr/lib/asterisk/modules astvarlibdir => /var/lib/asterisk astdbdir => /var/lib/asterisk/db astkeydir => /var/lib/asterisk astdatadir => /var/lib/asterisk astagidir => /var/lib/asterisk/agi-bin astspooldir => /var/spool/asterisk astrundir => /var/run/asterisk astlogdir => /var/log/asterisk astsbindir => /usr/sbin [options] verbose = 3 documentation_language = en_US runuser = asterisk rungroup = asterisk defaultlanguage = en ``` ### 3.2 `modules.conf` ```ini [modules] autoload = yes ; channel drivers we do not use noload = chan_iax2.so noload = chan_dahdi.so noload = chan_mgcp.so noload = chan_skinny.so noload = chan_unistim.so noload = chan_ooh323.so noload = chan_motif.so noload = res_xmpp.so noload = res_pjsip_phoneprov.so ; we do our own provisioning noload = cdr_csv.so noload = cdr_custom.so noload = cdr_sqlite3_custom.so noload = cel_sqlite3_custom.so noload = cel_custom.so noload = res_hep.so noload = res_hep_pjsip.so noload = res_hep_rtcp.so noload = pbx_dundi.so noload = pbx_lua.so noload = pbx_ael.so noload = res_config_ldap.so noload = res_snmp.so ; unused features that only log errors about missing config files noload = res_config_sqlite3.so noload = res_statsd.so noload = app_statsd.so noload = res_pjsip_config_wizard.so noload = res_pjsip_notify.so noload = res_phoneprov.so noload = res_pjsip_phoneprov_provider.so noload = app_agent_pool.so noload = res_calendar.so noload = res_calendar_caldav.so noload = res_calendar_ews.so noload = res_calendar_exchange.so noload = res_calendar_icalendar.so noload = res_http_media_cache.so noload = app_amd.so noload = res_clialiases.so noload = res_prometheus.so noload = app_alarmreceiver.so noload = app_festival.so noload = app_followme.so noload = app_minivm.so noload = cel_manager.so noload = res_stun_monitor.so noload = chan_websocket.so noload = res_adsi.so noload = app_getcpeid.so noload = app_adsiprog.so ``` Besides the files below, `asterisk/conf/` holds near-empty `cel.conf` (CEL off), `acl.conf`, `udptl.conf`, `pjproject.conf`, `ari.conf` (ARI off), `websocket_client.conf` and `aeap.conf`. Without them core modules log `ERROR` about missing files. The image also installs Asterisk's stock `indications.conf` (needed for `tone_zone = us`). ### 3.3 `logger.conf` ```ini [general] dateformat = %F %T.%3q [logfiles] console => notice,warning,error,verbose messages.log => notice,warning,error security.log => security ; for fail2ban/CrowdSec ``` ### 3.4 `manager.conf.template` ```ini [general] enabled = yes port = 5038 bindaddr = 127.0.0.1 displayconnects = no timestampevents = yes [selfphoneng] secret = ${AMI_SECRET} deny = 0.0.0.0/0.0.0.0 permit = 127.0.0.1/255.255.255.255 read = all write = all writetimeout = 5000 ``` ### 3.5 `http.conf` (WebSocket for the browser softphone) ```ini [general] enabled = yes bindaddr = 127.0.0.1 ; only the reverse proxy talks to it bindport = 8088 enablestatic = no ``` Asterisk serves SIP-over-WebSocket at `ws://127.0.0.1:8088/ws`. The reverse proxy exposes it as `wss://PUBLIC_HOSTNAME/ws`. TLS is handled by the proxy, not by Asterisk. ### 3.6 `rtp.conf` ```ini [general] rtpstart = 10000 rtpend = 20000 #include selfphoneng/rtp_selfphoneng.conf ``` (The generator writes `icesupport`, `stunaddr` and `[ice_host_candidates]` into `rtp_selfphoneng.conf`, see `03-config-generator.md` §4.) ### 3.7 `pjsip.conf` ```ini ; Everything is generated. Do not add sections here. #include selfphoneng/pjsip_selfphoneng.conf ``` ### 3.8 `extensions.conf` ```ini [general] static = yes writeprotect = yes clearglobalvars = yes [globals] #include selfphoneng/extensions_globals_selfphoneng.conf ; Static helper contexts (sp-sub-*) are generated too (03 §5) so that all ; dialplan lives in one place in code. #include selfphoneng/extensions_selfphoneng.conf ``` ### 3.9 `voicemail.conf` ```ini #include selfphoneng/voicemail_selfphoneng.conf ``` ### 3.10 `queues.conf` ```ini [general] persistentmembers = no ; members come from config; pause state from AstDB/API autofill = yes monitor-type = MixMonitor shared_lastcall = yes log_membername_as_agent = yes #include selfphoneng/queues_selfphoneng.conf ``` ### 3.11 `musiconhold.conf` ```ini [default] mode = files directory = /var/lib/asterisk/moh/default sort = random #include selfphoneng/musiconhold_selfphoneng.conf ``` The Asterisk image keeps the stock opsound MOH files (CC-BY-SA, open) in `/usr/share/selfphoneng/moh-opsound/`, and `entrypoint.sh` copies them into `/var/lib/asterisk/moh/default/` (`./data/moh/default/`) when that folder is empty. (Earlier drafts had `install.sh` do this; the entrypoint also covers installs that skip the script.) ### 3.12 `features.conf` ```ini [general] transferdigittimeout = 3 xfersound = beep xferfailsound = beeperr pickupexten = *8 featuredigittimeout = 1000 [featuremap] blindxfer = ## atxfer = *2 automixmon = *1 ; on-demand recording toggle (needs Dial option X) ``` ### 3.13 `res_parking.conf` ```ini [general] parkeddynamic = no [default] parkext = 700 parkpos = 701-720 context = sp-parkedcalls parkingtime = 120 comebacktoorigin = yes comebackdialtime = 30 parkedmusicclass = default findslot = next parkext_exclusive = no parkinghints = yes ``` ### 3.14 `confbridge.conf` ```ini [general] [default_bridge] type = bridge max_members = 20 mixing_interval = 20 internal_sample_rate = auto record_conference = no [default_user] type = user music_on_hold_when_empty = yes announce_user_count = yes announce_join_leave = no dsp_drop_silence = yes [default_menu] type = menu * = playback_and_continue(conf-usermenu) 1 = toggle_mute #include selfphoneng/confbridge_selfphoneng.conf ``` ### 3.15 `cdr.conf` and `cdr_manager.conf` ```ini ; cdr.conf [general] enable = yes unanswered = yes congestion = yes endbeforehexten = yes ``` ```ini ; cdr_manager.conf [general] enabled = yes [mappings] SP_DID => sp_did SP_DIRECTION => sp_direction SP_REC => sp_recording ``` The api turns `Cdr` AMI events into rows in `cdr` (see `04-backend-api.md` §5.4). ## 4. Networking and NAT ### 4.1 Ports | Port | Proto | Who connects | Expose to internet? | |---|---|---|---| | 443 | TCP | Browsers (UI + WSS softphone), phones (provisioning) | Yes | | 80 | TCP | Let's Encrypt HTTP challenge (Caddy only) | Only with Caddy | | 5060 | UDP+TCP | voip.ms, remote desk phones / softphones | Yes, ideally limited to voip.ms IPs + known remote IPs | | 5061 | TCP | TLS SIP (optional, remote phones, voip.ms encrypted) | Optional | | 10000–20000 | UDP | RTP audio from voip.ms, phones, browsers | Yes | | 5038 | TCP | AMI | **Never** (bound to 127.0.0.1) | | 8088 | TCP | Asterisk HTTP/WS | **Never** (bound to 127.0.0.1) | | 8080 | TCP | api | **Never** (bound to 127.0.0.1, behind proxy) | | 5433 | TCP | PostgreSQL | **Never** (bound to 127.0.0.1) | ### 4.2 NAT rules (most audio problems come from here) The settings page "Network" (stored in `settings`, generated into PJSIP transports) has: - `publicIp`: the WAN IP. The UI has a "Detect" button (the api asks `https://api.ipify.org`, and the user can override it). - `localNets`: list of CIDRs that are "inside" (default `192.168.0.0/16, 10.0.0.0/8, 172.16.0.0/12`). The admin guide (M11) must tell the user to: 1. **Turn off SIP ALG** on every router between the server and the internet. It rewrites SIP packets and breaks calls. 2. Port-forward UDP 5060 and UDP 10000–20000 to the server. With double NAT (ISP router in front of their own router), do it on both routers, or use DMZ on the outer one. 3. Set `publicIp`. With a dynamic IP, also enable the "Re-check public IP every 5 minutes" option (job `publicIpWatch`). When the IP changes, the job updates settings and triggers a regenerate + Asterisk restart (§4.3). ### 4.3 Changes that need an Asterisk restart PJSIP transports cannot be safely reloaded. If the generator sees that the transport sections changed, it writes the files, does NOT reload, and sets `settings.restartRequired = true`. The UI then shows a banner: "Network settings changed. Restart telephony to apply (active calls will drop)." with a button. The button calls `POST /api/system/asterisk/restart`, which sends AMI `Command: core restart gracefully` (waits for calls to end) or, with `?now=true`, `core restart now`. ## 5. Reverse proxy ### 5.1 `deploy/caddy/Caddyfile` ``` {$PUBLIC_HOSTNAME} { encode zstd gzip @sipws path /ws handle @sipws { reverse_proxy 127.0.0.1:8088 } handle { reverse_proxy 127.0.0.1:8080 } } ``` ### 5.2 `deploy/nginx/selfphoneng.conf.example` ```nginx map $http_upgrade $connection_upgrade { default upgrade; '' close; } server { listen 443 ssl; http2 on; server_name pbx.example.com; ssl_certificate /etc/letsencrypt/live/pbx.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/pbx.example.com/privkey.pem; client_max_body_size 50m; # audio uploads # SIP over WebSocket -> Asterisk location = /ws { proxy_pass http://127.0.0.1:8088/ws; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_read_timeout 3600s; proxy_send_timeout 3600s; } # Everything else (UI, API, live WebSocket /api/live, provisioning) -> api location / { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_read_timeout 3600s; } } ``` Add a comment block to the example: if 443 is handled by an nginx `stream {}` block that forwards with `proxy_protocol`, this server must instead `listen 127.0.0.1: ssl proxy_protocol;` and set `set_real_ip_from 127.0.0.1; real_ip_header proxy_protocol;`, or the api will see every client as 127.0.0.1, which breaks login rate limiting and audit logs. ## 6. api image (`apps/api/Dockerfile`) ```dockerfile FROM node:24-bookworm-slim AS build RUN corepack enable WORKDIR /src COPY . . RUN pnpm install --frozen-lockfile && pnpm --filter @selfphoneng/shared build \ && pnpm --filter @selfphoneng/web build && pnpm --filter @selfphoneng/api build FROM node:24-bookworm-slim # pg_dump must be the same major version as the server (18). Debian's own # postgresql-client is older, so use the official PostgreSQL apt repo (PGDG). RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg tini ca-certificates curl gnupg \ && install -d /usr/share/postgresql-common/pgdg \ && curl -fsSL -o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc https://www.postgresql.org/media/keys/ACCC4CF8.asc \ && . /etc/os-release \ && echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt ${VERSION_CODENAME}-pgdg main" > /etc/apt/sources.list.d/pgdg.list \ && apt-get update && apt-get install -y --no-install-recommends postgresql-client-18 \ && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY --from=build /src/apps/api/dist ./dist COPY --from=build /src/apps/api/node_modules ./node_modules COPY --from=build /src/apps/api/src/db/migrations ./migrations COPY --from=build /src/apps/api/src/provisioning/templates ./provisioning-templates COPY --from=build /src/apps/web/dist ./public ENV NODE_ENV=production ENTRYPOINT ["/usr/bin/tini","--"] CMD ["node","dist/index.js"] ``` (If pnpm's symlinked `node_modules` doesn't survive `COPY`, use `pnpm deploy --filter @selfphoneng/api --prod /out` and copy `/out` instead.) On start, `index.ts` does, in order: validate env → connect DB → run migrations → create the initial admin if none exists → start Fastify → connect AMI → sync AstDB → `generator.apply({ force: true })` → start jobs.