Azure Production Deployment
このコンテンツはまだ日本語訳がありません。
TomoriBot’s production deployment is a singleton Azure VM managed by Terraform. GitHub Actions authenticates to Azure with an environment-scoped OpenID Connect (OIDC) token and deploys through Azure VM Run Command. The VM exposes no public inbound service; its static public IP exists only as a low-cost outbound SNAT path.
Trust boundary
Section titled “Trust boundary”The protected production GitHub environment accepts only the release branch. The Azure
workflow has contents: read by default, grants id-token: write only to
deploy-with-terraform, and grants contents: write only to the release-creation job. The Azure
federated credential subject must be exactly:
repo:Bredrumb/TomoriBot:environment:productionThe GitHub deployment principal needs Contributor only on tomoribot-rg and
Storage Blob Data Contributor only on the tfstate container. It must not retain subscription
Contributor, Owner, User Access Administrator, or unrelated resource-group roles after the
replacement workflow is proven.
Store these Azure-only values in the production environment, not as repository secrets:
AZURE_CLIENT_ID,AZURE_TENANT_ID, andAZURE_SUBSCRIPTION_IDAZURE_POSTGRES_ADMIN_PASSWORDAZURE_VM_SSH_PUBLIC_KEY(a provisioning-only public key; CI has no private key)TOMORI_SECRETS_JSONGRAFANA_EGRESS_IPas an environment variable
If the first cutover finds that the existing TOMORI_SECRETS_JSON still contains the PostgreSQL
administrator login, create a temporary AZURE_POSTGRES_RUNTIME_PASSWORD environment secret with
a new random value of at least 32 characters. The payload step overlays only POSTGRES_USER and
POSTGRES_PASSWORD, persists the complete corrected JSON back to TOMORI_SECRETS_JSON, and uses
that same bundle for bootstrap and deployment. Delete AZURE_POSTGRES_RUNTIME_PASSWORD after the
successful cutover. This recovery path preserves every unrelated application credential without
making the write-only GitHub secret visible. A preflight checks the source bundle and migration
credential before Terraform planning or apply, so configuration errors cannot mutate Azure first.
The first merge to release starts the deployment workflow immediately, before a manual dispatch
can use the newly merged workflow. For that one cutover only, set the production environment
variable RUN_DATABASE_BOOTSTRAP=true before merging. The merge-triggered job then performs the
idempotent database bootstrap before installing the non-administrator runtime bundle. Delete the
variable immediately after that run succeeds so ordinary release pushes cannot bootstrap schema or
roles. Do not use this flag for recurring deployments.
Docker Hub and release-notification credentials may remain repository-scoped because the retained
AWS/GCP workflows also use them. Delete PAT_TOKEN and AZURE_VM_SSH_PRIVATE_KEY only after the
environment-scoped Run Command deployment succeeds.
Deployment lifecycle
Section titled “Deployment lifecycle”The workflow deliberately separates recurring releases from one-time lifecycle operations.
VM replacement protection and monitoring recovery
Section titled “VM replacement protection and monitoring recovery”Terraform plans that delete or replace the production VM stop before apply. After reviewing the
saved plan, an operator may approve the replacement only through a manual dispatch with
allow_vm_replacement=true; release-branch pushes cannot bypass this guard. This matters because
changes to VM custom_data are replacement operations, not in-place updates.
The Azure Monitor Linux Agent and both DCR associations are Terraform-managed children of the VM.
The DCR definitions, DCE, Log Analytics workspace, and custom tables remain externally managed and
are referenced through the non-sensitive DCR resource IDs in terraform.ci.tfvars. A normal
Terraform apply installs the agent and restores both associations on the current VM. A later
approved VM replacement destroys and recreates these attachments in the same dependency graph, so
guest-memory and cache telemetry recover without a separate portal operation. Update the committed
IDs only when an operator deliberately replaces a DCR.
To inventory the existing DCR IDs before the first adoption apply, authenticate Azure CLI and run:
az resource list \ --resource-type Microsoft.Insights/dataCollectionRules \ --query "[].{name:name,resourceGroup:resourceGroup,id:id}" \ --output tableIf the current VM already has an extension or association with the Terraform names, import that live object instead of deleting it; a recreated VM normally has no such child objects, so the first apply creates them.
Database bootstrap
Section titled “Database bootstrap”For the first non-administrator cutover, set the one-time environment flag described above before
merging. Later operator-requested reruns can be manually dispatched from release with
run_database_bootstrap=true. The idempotent
bootstrap-database.sh operation:
- creates or updates
tomoribot_runtimeasNOINHERIT,NOSUPERUSER,NOCREATEDB,NOCREATEROLE,NOREPLICATION, andNOBYPASSRLS; - runs schema initialization and migrations with the database-only administrator bundle only when the workflow detected that the PostgreSQL server did not exist before Terraform apply;
- grants only database
CONNECT, schemaUSAGE, table DML, sequence access, and function execution, plus matching default privileges; and - verifies the runtime role cannot create databases or objects in
public.
The administrator bundle contains only PostgreSQL connection fields, is staged for the one-shot
container, and is deleted when Run Command exits. It is never installed as /etc/tomoribot/secrets.json.
For an existing PostgreSQL server, bootstrap skips all schema and seed operations and changes only
the runtime role and its privileges — schema and migrations are instead applied on every deploy by the
separate always-on step below. Schema-container failures are retained in the access-controlled
Azure Run Command record without exposing that output in the public Actions log.
Schema and migration application
Section titled “Schema and migration application”Because the runtime role has no DDL privilege, the running bot cannot apply schema changes
(DATABASE_SCHEMA_MANAGEMENT_ENABLED=false). To preserve the pre-hardening guarantee that idempotent
schema and migrations always apply on deploy, every deploy runs
migrate-database.sh as a dedicated step before the bot
is (re)started. It:
- runs the same
initializeClientrypoint the local boot path uses (idempotentschema.sqlplus the trackedNNN_*.sqlmigration runner), in a one-shot container using the database-only administrator bundle — the only identity permitted to create tables or apply migrations in production; - touches no roles or grants: new tables inherit runtime and Grafana privileges automatically from the
ALTER DEFAULT PRIVILEGESrulesbootstrap-database.shinstalls for the administrator role; and - fails the deploy (before the bot restarts) if migration does not report success.
Destructive migrations (DROP, ALTER COLUMN ... TYPE, TRUNCATE, unfiltered DELETE, etc.) are still
blocked upstream by the Destructive migration gate unless the deployer opts into a pre-deploy backup
(a (Checkpoint) commit message on push, or create_db_backup=true on manual dispatch). This is why the
gate matters: routine pushes now genuinely apply migrations, so an unguarded destructive change is caught
before it reaches the database.
Recurring deployment
Section titled “Recurring deployment”Every deploy runs run-command-deploy.sh, which has
only these responsibilities:
- validate and atomically install the runtime JSON, Vertex WIF configuration, and Compose file;
- reject administrator runtime users and non-digest image references;
- authenticate to Docker Hub only long enough to pull the immutable image digests, then log out;
- start the TomoriBot Compose service without an implicit pull; and
- verify UID/GID
1001:1001, a database query over verified TLS to the public Azure PostgreSQL FQDN, root-owned configuration modes, andhttp://localhost:8081/healthz.
The runtime container sets DATABASE_SCHEMA_MANAGEMENT_ENABLED=false, so startup verifies database
connectivity but cannot execute migrations or pg_cron administration. Migrations are applied
out-of-band on every deploy by the privileged step in Schema and migration application,
which runs before this deploy step.
Host lockdown
Section titled “Host lockdown”After a successful deployment, manually dispatch once with run_host_lockdown=true. The idempotent
host-lockdown.sh operation installs an SSH hardening
drop-in, removes the provisioning user from the docker group, enables UFW with deny-by-default
inbound policy, and disables guest SSH. The workflow then fails unless the Azure NSG contains zero
inbound allow rules. Continue using VM Run Command for administration and health checks.
Database and Grafana boundary
Section titled “Database and Grafana boundary”PostgreSQL uses its public Azure FQDN, but its firewall permits only two exact source addresses: the
VM’s Terraform-managed static public IP and GRAFANA_EGRESS_IP. The application and Grafana both
connect with TLS certificate and hostname verification. Do not add 0.0.0.0, enable access from all
Azure services, or widen either firewall rule.
Grafana must use its dedicated grafana login, never tomoribot_runtime or the PostgreSQL
administrator. Configure the PostgreSQL datasource with TLS mode verify-full, the Azure server
hostname (not an IP address), and the operating-system CA trust store. The login is whole-database
read-only: CONNECT, schema USAGE, current/future table SELECT, and
default_transaction_read_only=on.
Before declaring the datasource cutover complete, inspect the saved datasource configuration and
run a read query such as the daily stat_counters aggregation. Confirm a write fails and that a
connection from outside GRAFANA_EGRESS_IP/32 is rejected before password authentication.
Connection-pool hygiene on the public endpoint
Section titled “Connection-pool hygiene on the public endpoint”Because the application now reaches PostgreSQL over the public Azure gateway rather than a private
endpoint, the runtime client sets pool-recycling options (src/utils/db/client.ts). Azure’s public
gateway silently reaps idle TCP connections after roughly four minutes without sending a RST; a
pooled connection reaped this way becomes a black hole, so the next query hangs until an application
timeout fires (~3 minutes). Chat turns exhibited this — but lightweight slash commands, which touch
the pool more opportunistically, largely did not. POSTGRES_IDLE_TIMEOUT_SECONDS (default 30)
recycles idle connections before the gateway can reap them, POSTGRES_MAX_LIFETIME_SECONDS
(default 600) caps total connection age, and POSTGRES_CONNECTION_TIMEOUT_SECONDS (default 10)
turns a dead-path hang into a fast, retryable failure. Defaults are production-safe; tune only during
an incident. This was fixed at the client layer deliberately, so the private endpoint stays removed
and the free-tier cost target holds.
Container and host operations
Section titled “Container and host operations”The Compose service runs as 1001:1001 with a read-only root filesystem, all Linux capabilities
dropped, no-new-privileges, PID/memory limits, explicit writable bind mounts, and size-limited
tmpfs mounts. The health port is bound only to 127.0.0.1. Optional SearXNG is profile-gated,
unpublished, and digest-pinned.
This is a singleton: automatic platform patching or an operator reboot causes a short, expected bot
outage while the VM and restart: unless-stopped container return. Schedule disruptive maintenance
when a brief Discord disconnect is acceptable, then verify /healthz, Discord connectivity,
public-FQDN database access over verified TLS, and Vertex WIF after the reboot.
Docker uses json-file rotation with three 10 MiB files, live restore, and daemon-level
no-new-privileges. Application error JSONL remains on /var/log/tomoribot; Azure Monitor retains
ingested records for 30 days. Backup and application-data mounts remain under
/var/lib/tomoribot and require explicit operator retention decisions.
Release proof and rollback
Section titled “Release proof and rollback”A production hardening rollout is proven only after the protected validate check passes, the PR
is merged without bypass, and the environment-scoped workflow succeeds through OIDC and Run
Command. Smoke-test /healthz, Discord connectivity, public-FQDN PostgreSQL/TLS, Vertex WIF, URL-fetch
IMDS blocking, /stats generate, and one representative slash command.
Application rollback means redeploying a previously reviewed immutable image digest with the same runtime configuration. Do not restore an administrator credential to the runtime file. Database or Terraform-state recovery follows its own runbook; see Azure Terraform State Recovery.