Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,7 @@ REDIS_REQUIRED=false
CACHE_TTL_SECONDS=300
THROTTLE_FREE_TIER_DAILY_LIMIT=500
THROTTLE_FREE_TIER_DAILY_TTL_SECONDS=86400

# Production container limits and SSH destination are managed by deployment
# Compose and GitHub production-environment settings, not public app config.
DATABASE_POOL_MAX=4
10 changes: 5 additions & 5 deletions .github/workflows/deploy-production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ jobs:
echo "runtime-image=${IMAGE}@${DIGEST}" >> "$GITHUB_OUTPUT"

deploy:
name: Deploy to syr-prod
name: Deploy to configured production host
needs:
- deploy-policy
- build
Expand Down Expand Up @@ -242,8 +242,8 @@ jobs:
DEPLOY_SSH_PRIVATE_KEY: ${{ secrets.DEPLOY_SSH_PRIVATE_KEY }}
DEPLOY_SSH_KNOWN_HOSTS: ${{ secrets.DEPLOY_SSH_KNOWN_HOSTS }}
run: |
test "$DEPLOY_HOST" = "syr-prod"
test "$DEPLOY_USER" = "mustafa"
[[ "$DEPLOY_HOST" =~ ^[a-zA-Z0-9][a-zA-Z0-9.-]*$ ]]
[[ "$DEPLOY_USER" =~ ^[a-z_][a-z0-9_-]*$ ]]
test "${CONFIGURED_DEPLOY_ROOT:-$DEPLOY_ROOT}" = "$DEPLOY_ROOT"
[[ "$RELEASE_SHA" =~ ^[0-9a-f]{40}$ ]]
test -n "$DEPLOY_SSH_PRIVATE_KEY"
Expand All @@ -256,7 +256,7 @@ jobs:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
audience: ${{ secrets.TS_AUDIENCE }}
tags: tag:ci
ping: syr-prod
ping: ${{ vars.DEPLOY_HOST }}

- name: Install SSH credentials
shell: bash
Expand All @@ -282,7 +282,7 @@ jobs:
-o IdentitiesOnly=yes \
-o StrictHostKeyChecking=yes \
"${DEPLOY_USER}@${DEPLOY_HOST}" \
'sudo -n /usr/bin/install -d -m 0750 -o mustafa -g mustafa /opt/syr/apps/opensyria /opt/syr/apps/opensyria/production /opt/syr/apps/opensyria/production/website /opt/syr/apps/opensyria/production/datasets-api'
'root=/opt/syr/apps/opensyria/production/datasets-api; test -d "$root" && test ! -L "$root" && test -w "$root"'
ssh -i ~/.ssh/opensyria_deploy \
-o BatchMode=yes \
-o IdentitiesOnly=yes \
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -376,3 +376,5 @@ Community contribution is intended primarily for the dataset repositories, where
## License

MIT

`DATABASE_POOL_MAX` bounds each API process and importer to 1�20 PostgreSQL connections (default 4). Connection acquisition times out after five seconds, so pool exhaustion fails promptly instead of accumulating unbounded waits. Production fixes the API pool at four connections.
2 changes: 1 addition & 1 deletion devops/production/.infisical.env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Raw loopback API on syr-prod. The browser-facing endpoint is protected by
# Raw loopback API on the configured production host. The browser-facing endpoint is protected by
# nginx Basic Auth and must not be used by host automation.
INFISICAL_API_URL=http://127.0.0.1:14001
INFISICAL_CLIENT_ID=enter-production-api-client-id
Expand Down
25 changes: 24 additions & 1 deletion devops/production/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ commit SHA. Do not run server-side builds or write runtime secrets into
changes wait for concurrent OpenSyria rollouts, and private verification retries
briefly while old nginx workers drain after a graceful reload.

Every pre-migration backup is validated with `pg_restore --list` and receives
On unmanaged legacy hosts, each pre-migration backup is validated with `pg_restore --list` and receives
checksum and recovery sidecars. Destructive recovery is deliberately separate:

```bash
Expand All @@ -54,3 +54,26 @@ explicit confirmation it resets only `opensyria_datasets_production`, restores
its isolation and PostGIS extension, then restores the archive as the
application role. This removes post-backup objects instead of leaving them
behind beside older Prisma migration history.

## Restricted production host deployment

The production GitHub environment selects `DEPLOY_HOST`, `DEPLOY_USER`, the SSH
key and its pinned known-hosts entry. The host must be provisioned in advance;
CI only verifies the application directory and cannot create directories with
unrestricted sudo. The deployment identity must have only the fixed Docker
operations for this application. Keep automatic deployment paused while moving
data and use `VERIFY_PUBLIC_DEPLOYMENT=false` for the private cutover checks.
Set it back to `true` when the public route points to the prepared destination.

The long-running application has a 1 CPU burst ceiling and 512 MiB memory/swap
ceiling, with Node heap capped at 320 MiB. These limits apply to each blue/green
slot; allow temporary overlap during a rollout.

The API PostgreSQL pool defaults to four connections per process;
`DATABASE_POOL_MAX` accepts 1�20 and production Compose pins it to four.
Connection acquisition is bounded to five seconds. Database readiness uses a
fixed host operation, so its container name need not match the application DNS
alias. Migration jobs are capped at 1 CPU/512 MiB, sync/import at 1 CPU/768 MiB.
On a managed host, the fixed `opensyria-production-backup` sudo hook performs an
encrypted, verified off-host pre-deployment backup. Its absence retains the
legacy local dump path, which is not sufficient by itself for disaster recovery.
6 changes: 6 additions & 0 deletions devops/production/bin/backup-postgres.sh
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ cleanup() {
trap cleanup EXIT

main() {
# Managed hosts supply an encrypted off-host backup with fixed root authority.
# No arguments or credentials are accepted by this hook.
if [[ -x /usr/local/sbin/opensyria-production-backup ]]; then
sudo -n /usr/local/sbin/opensyria-production-backup
return
fi
command -v flock >/dev/null 2>&1 || fail "flock is required"
command -v sha256sum >/dev/null 2>&1 || fail "sha256sum is required"
[[ -f "${DOCKER_WRAPPER}" && -x "${DOCKER_WRAPPER}" && ! -L "${DOCKER_WRAPPER}" ]] \
Expand Down
9 changes: 4 additions & 5 deletions devops/production/bin/deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,9 @@ ACTIVE_RELEASE_FILE="${STATE_DIR}/active-release"
PENDING_FILE="${STATE_DIR}/pending.env"
PREVIOUS_UPSTREAM_FILE="${STATE_DIR}/previous-upstream.conf"
DEPLOY_LOCK_FILE="${ROOT_DIR}/.deploy.lock"
NGINX_DEPLOY_LOCK_FILE="${SERVER_SERVICES_ROOT}/.nginx-deploy.lock"
NGINX_ACTIVE_INCLUDE="${SERVER_SERVICES_ROOT}/infrastructure/nginx/conf.d/includes/opensyria-production-api-active.conf"
NGINX_DEPLOY_LOCK_FILE="${SERVER_SERVICES_ROOT}/infrastructure/nginx/conf.d/includes/opensyria/.deploy.lock"
NGINX_ACTIVE_INCLUDE="${SERVER_SERVICES_ROOT}/infrastructure/nginx/conf.d/includes/opensyria/opensyria-production-api-active.conf"
NGINX_CONTAINER="infra-nginx"
POSTGRES_CONTAINER="infra-postgres"
REDIS_CONTAINER="opensyria-production-redis"
EDGE_NETWORK="syr-staging-edge"
DATA_NETWORK="opensyria-production-data"
Expand Down Expand Up @@ -551,8 +550,8 @@ prepare_release() {
|| fail "External Docker network ${EDGE_NETWORK} is missing"
docker_cmd network-exists "${DATA_NETWORK}" >/dev/null \
|| fail "External Docker network ${DATA_NETWORK} is missing"
container_is_running "${POSTGRES_CONTAINER}" \
|| fail "Shared PostgreSQL container is missing"
docker_cmd opensyria-database-state >/dev/null \
|| fail "OpenSyria PostgreSQL database is unavailable"
container_is_running "${REDIS_CONTAINER}" \
|| fail "Dedicated OpenSyria Redis container is missing"

Expand Down
20 changes: 14 additions & 6 deletions devops/production/docker-compose.app.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ x-api-defaults: &api-defaults
env_file:
- ./env/api.env
cpus: "1.0"
mem_limit: 1g
mem_limit: 512m
memswap_limit: 512m
volumes:
- ./data/releases:/app/data/releases:ro
networks:
Expand All @@ -50,6 +51,8 @@ services:
container_name: opensyria-production-api-blue
environment:
APP_RELEASE: ${API_BLUE_RELEASE:?API_BLUE_RELEASE is required}
NODE_OPTIONS: --max-old-space-size=320
DATABASE_POOL_MAX: "4"
HOME: /tmp
XDG_CACHE_HOME: /tmp/.cache
networks:
Expand All @@ -66,6 +69,8 @@ services:
container_name: opensyria-production-api-green
environment:
APP_RELEASE: ${API_GREEN_RELEASE:?API_GREEN_RELEASE is required}
NODE_OPTIONS: --max-old-space-size=320
DATABASE_POOL_MAX: "4"
HOME: /tmp
XDG_CACHE_HOME: /tmp/.cache
networks:
Expand All @@ -83,7 +88,8 @@ services:
- ./env/migrate.env
restart: "no"
cpus: "1.0"
mem_limit: 1g
mem_limit: 512m
memswap_limit: 512m
networks:
- data
environment:
Expand All @@ -101,8 +107,9 @@ services:
<<: *service-defaults
image: ${OPERATIONS_IMAGE:?OPERATIONS_IMAGE is required}
restart: "no"
cpus: "2.0"
mem_limit: 1536m
cpus: "1.0"
mem_limit: 768m
memswap_limit: 768m
pids_limit: 384
env_file:
- ./env/datasets.env
Expand All @@ -119,8 +126,9 @@ services:
<<: *service-defaults
image: ${OPERATIONS_IMAGE:?OPERATIONS_IMAGE is required}
restart: "no"
cpus: "2.0"
mem_limit: 1536m
cpus: "1.0"
mem_limit: 768m
memswap_limit: 768m
pids_limit: 384
env_file:
- ./env/import.env
Expand Down
38 changes: 31 additions & 7 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

`datasets-api` is deployed directly to production at `api.opensyria.org`. The
application bundle is separate from the website bundle, while both use the
shared host platform on `syr-prod`.
shared production host selected by the GitHub environment.

## Architecture

Expand Down Expand Up @@ -64,7 +64,7 @@ read-only, path-scoped Universal Auth identity stored at:
/opt/syr/apps/opensyria/production/datasets-api/.infisical.env
```

The file must be owned by `mustafa`, mode `0600`, and contain only Infisical
The file must be owned by the configured deployment account, mode `0600`, and contain only Infisical
connection/identity settings. `bin/deploy.sh` obtains a short-lived token from
the loopback Infisical API and exports `/datasets-api` to a mode-`0600` runtime
env file. It parses `.infisical.env` with an exact key allowlist instead of
Expand All @@ -84,8 +84,8 @@ DEPLOY_SSH_KNOWN_HOSTS
Required GitHub environment variables:

```text
DEPLOY_HOST=syr-prod
DEPLOY_USER=mustafa
DEPLOY_HOST=<production-tailnet-host>
DEPLOY_USER=<dedicated-deployment-user>
DEPLOY_ROOT=/opt/syr/apps/opensyria/production/datasets-api
```

Expand Down Expand Up @@ -165,11 +165,12 @@ Do not grant the application role superuser or extension-creation privileges.

The workflow and `devops/production/bin/deploy.sh` perform these steps:

1. Validate the exact host, user, path, protected Infisical file, networks, and
1. Validate the configured host/user, fixed path, protected Infisical file, networks, and
infrastructure containers.
2. Pull the immutable image digest with short-lived GHCR authentication.
3. Take a custom-format pre-migration dump in
`/opt/syr/backups/production/opensyria/postgres`.
3. Run the fixed managed-host backup hook and require verified encrypted off-host
recovery artifacts before any migration. The legacy unmanaged-host fallback
writes a custom-format dump in `/opt/syr/backups/production/opensyria/postgres`.
4. Run `prisma migrate deploy` from the runtime image.
5. Sync every exact pin in `dataset-releases.json`; the GitHub token is scoped
to this job only.
Expand Down Expand Up @@ -251,3 +252,26 @@ Tunnel. Nginx supplies production security headers, preserves the client IP
contract, and marks the API host `noindex`. API documentation and OpenAPI routes
remain public by design. Do not cache health, API JSON, documentation, or
OpenAPI responses at Cloudflare; cache immutable website assets separately.

## Restricted production host deployment

The production GitHub environment selects `DEPLOY_HOST`, `DEPLOY_USER`, the SSH
key and its pinned known-hosts entry. The host must be provisioned in advance;
CI only verifies the application directory and cannot create directories with
unrestricted sudo. The deployment identity must have only the fixed Docker
operations for this application. Keep automatic deployment paused while moving
data and use `VERIFY_PUBLIC_DEPLOYMENT=false` for the private cutover checks.
Set it back to `true` when the public route points to the prepared destination.

The long-running application has a 1 CPU burst ceiling and 512 MiB memory/swap
ceiling, with Node heap capped at 320 MiB. These limits apply to each blue/green
slot; allow temporary overlap during a rollout.

The API PostgreSQL pool defaults to four connections per process;
`DATABASE_POOL_MAX` accepts 1�20 and production Compose pins it to four.
Connection acquisition is bounded to five seconds. Database readiness uses a
fixed host operation, so its container name need not match the application DNS
alias. Migration jobs are capped at 1 CPU/512 MiB, sync/import at 1 CPU/768 MiB.
On a managed host, the fixed `opensyria-production-backup` sudo hook performs an
encrypted, verified off-host pre-deployment backup. Its absence retains the
legacy local dump path, which is not sufficient by itself for disaster recovery.
2 changes: 2 additions & 0 deletions docs/read-model-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,3 +130,5 @@ For already-built Docker/runtime environments, use the `:prod` scripts so the co
pnpm run datasets:sync:prod
DATABASE_ENABLED=true pnpm run read-model:import:geography:prod
```

`DATABASE_POOL_MAX` bounds each API process and importer to 1�20 PostgreSQL connections (default 4). Connection acquisition times out after five seconds, so pool exhaustion fails promptly instead of accumulating unbounded waits. Production fixes the API pool at four connections.
Loading