deploy: docker compose stack, api image, install script, proxy configs

Postgres and AMI secrets are hex (the password goes into DATABASE_URL).

Task: M0-T6
This commit is contained in:
iamdoubz committed 2026-10-17 14:32:57 -05:00
1 parent 909ac2d62d
commit d0de0c9039
9 files changed
+269 -10

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
**/node_modules
**/dist
data
.env
.git
asterisk
+16
View File
@@ -0,0 +1,16 @@
# --- required ---
PUBLIC_HOSTNAME=pbx.example.com # DNS name browsers and phones use
POSTGRES_PASSWORD=change-me # install.sh generates a random one (hex: it goes into a URL)
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
+30
View File
@@ -0,0 +1,30 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
FROM node:24-trixie-slim AS build
RUN npm install -g corepack@latest && 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 \
&& pnpm --filter @selfphoneng/api deploy --legacy --prod /out
FROM node:24-trixie-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 /out/package.json ./package.json
COPY --from=build /out/node_modules ./node_modules
COPY --from=build /src/apps/api/dist ./dist
COPY --from=build /src/apps/web/dist ./public
# ponytail: migrations (M1) and provisioning templates (M10) get their COPY lines when they exist.
ENV NODE_ENV=production
ENTRYPOINT ["/usr/bin/tini","--"]
CMD ["node","dist/index.js"]
+10
View File
@@ -0,0 +1,10 @@
{$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
}
}
+47
View File
@@ -0,0 +1,47 @@
# SelfPhoneNG behind your own nginx. Replace pbx.example.com and the certificate paths.
#
# If port 443 is handled by an nginx `stream {}` block that forwards with
# `proxy_protocol`, this server must instead:
# listen 127.0.0.1:<port> ssl proxy_protocol;
# set_real_ip_from 127.0.0.1;
# real_ip_header proxy_protocol;
# Otherwise the api sees every client as 127.0.0.1, which breaks login rate
# limiting and audit logs.
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;
add_header Strict-Transport-Security "max-age=31536000" always;
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;
}
}
+82
View File
@@ -0,0 +1,82 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
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
+13 -9
View File
@@ -613,14 +613,18 @@ will see every client as 127.0.0.1, which breaks login rate limiting and audit l
## 6. api image (`apps/api/Dockerfile`)
```dockerfile
FROM node:24-bookworm-slim AS build
RUN corepack enable
# SPDX-License-Identifier: AGPL-3.0-or-later
FROM node:24-trixie-slim AS build
RUN npm install -g corepack@latest && 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
RUN pnpm install --frozen-lockfile \
&& pnpm --filter @selfphoneng/shared build \
&& pnpm --filter @selfphoneng/web build \
&& pnpm --filter @selfphoneng/api build \
&& pnpm --filter @selfphoneng/api deploy --legacy --prod /out
FROM node:24-bookworm-slim
FROM node:24-trixie-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 \
@@ -631,16 +635,16 @@ RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg tini ca-
&& apt-get update && apt-get install -y --no-install-recommends postgresql-client-18 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=build /out/package.json ./package.json
COPY --from=build /out/node_modules ./node_modules
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
# ponytail: migrations (M1) and provisioning templates (M10) get their COPY lines when they exist.
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.)
`pnpm deploy --legacy` copies the api with real (non-symlinked) production `node_modules`; `@selfphoneng/shared` ships its built `dist/` (`"files": ["dist"]`). Debian trixie matches the Asterisk image.
On start, `index.ts` does, in order: validate env → connect DB → run
migrations → create the initial admin if none exists → start Fastify → connect AMI →
+4 -1
View File
@@ -14,5 +14,8 @@
"scripts": {
"build": "tsc -p tsconfig.build.json",
"typecheck": "tsc --noEmit"
}
},
"files": [
"dist"
]
}
+61
View File
@@ -0,0 +1,61 @@
#!/bin/sh
# SPDX-License-Identifier: AGPL-3.0-or-later
# One-command install (spec 01 §1.3). Run from the repo root: ./scripts/install.sh
set -eu
cd "$(dirname "$0")/.."
# 1. Docker.
if ! command -v docker >/dev/null 2>&1 || ! docker compose version >/dev/null 2>&1; then
echo "Docker with the compose plugin is required: https://docs.docker.com/engine/install/" >&2
exit 1
fi
# Replace KEY=... in .env (values are safe for sed: hex, base64 and user input with | escaped).
set_env() {
v=$(printf '%s' "$2" | sed 's/[|&\\]/\\&/g')
sed -i "s|^$1=.*|$1=$v|" .env
}
# 2. .env with generated secrets.
if [ ! -f .env ]; then
cp .env.example .env
set_env POSTGRES_PASSWORD "$(openssl rand -hex 32)" # hex: it is embedded in DATABASE_URL
set_env AMI_SECRET "$(openssl rand -hex 32)"
set_env APP_SECRET "$(openssl rand -base64 32)"
printf 'Public hostname (e.g. pbx.example.com): '; read -r host
printf 'Admin email: '; read -r email
stty -echo 2>/dev/null || true
printf 'Admin password (min 10 chars): '; read -r pw; echo
stty echo 2>/dev/null || true
set_env PUBLIC_HOSTNAME "$host"
set_env INITIAL_ADMIN_EMAIL "$email"
set_env INITIAL_ADMIN_PASSWORD "$pw"
chmod 600 .env
echo "Wrote .env"
fi
# 3. Data folders owned by the app user.
uid=$(sed -n 's/^APP_UID=\([0-9]*\).*/\1/p' .env); uid=${uid:-1000}
gid=$(sed -n 's/^APP_GID=\([0-9]*\).*/\1/p' .env); gid=${gid:-1000}
for d in postgres asterisk-generated voicemail recordings sounds-custom moh astdb asterisk-logs tls backups caddy; do
mkdir -p "data/$d"
done
# postgres manages its own folder's ownership; everything else is shared by asterisk and the api.
chown -R "$uid:$gid" data/asterisk-generated data/voicemail data/recordings data/sounds-custom data/moh \
data/astdb data/asterisk-logs data/tls data/backups 2>/dev/null \
|| sudo chown -R "$uid:$gid" data/asterisk-generated data/voicemail data/recordings data/sounds-custom \
data/moh data/astdb data/asterisk-logs data/tls data/backups
# 4. Start.
printf 'Use bundled Caddy for HTTPS? [y/N] '; read -r caddy
case "$caddy" in
[yY]*) docker compose --profile caddy up -d --build ;;
*)
docker compose up -d --build
echo "Configure your reverse proxy from deploy/nginx/selfphoneng.conf.example"
;;
esac
# 5. Done.
host=$(sed -n 's/^PUBLIC_HOSTNAME=\([^ #]*\).*/\1/p' .env)
echo "SelfPhoneNG is starting. Open https://$host/ (health: curl http://127.0.0.1:8080/healthz)"