Deploy the DM service
Production runs on one ARM64 host: the DM service, its worker and the website are containers there, behind the load
balancer, and the scripts in deploy/parallel/ upgrade them. This runbook covers that path
first, then what every deployment needs whatever it runs on (configuration, ingress, the website's uploads), the
decisions the Carbon makes, and the checks after a deployment. The Fargate templates in deploy/aws/ are the
alternative (below).
A deployment changes what every Carbon and Silicon uses at once, so each step is a deliberate command run by the Carbon responsible for production, or by someone they asked; nothing here runs from CI by itself.
Production
| Part | Container (command) | Port on the host | Public name |
|---|---|---|---|
| The DM service | dm-accounts-api (dm-api) |
8081 | https://api.dm.teamofsilicons.com |
| Its worker | dm-accounts-worker (dm-worker) |
none | |
| The website | dm-accounts-web (node server.js) |
4316 | https://dm.teamofsilicons.com |
- The host's network. The containers use it directly (
--network host), read-only, without capabilities, with a 64 MB tmpfs at/tmp. The website has another for Next's cache (/app/.next/cache); an upload touches neither. - Memory. 768 MB each for the service and the worker (a spoken message's render holds its audio in memory), and 512 MB for the website, which uploads of any size never fill (it holds none of a file's bytes).
- The load balancer (
silicon-dm-production) ends TLS, sends each public name to its port, speaks HTTP/2 to browsers, sets no body limit, and closes a connection on which nothing moved for 180 seconds, either way. - The database is RDS PostgreSQL
silicon_dm_accounts. The migrator roledm_accounts_migratorowns its objects; the service and the worker run asdm_accounts_runtime. - Settings. Each container's environment is a 0600 file in
/opt/silicon-accounts-apps/dm/config/(api.env,worker.env,migration.env,web.env), which the scripts write from the Secrets Manager secretsilicon-dm/accounts-production/runtime. Never source those files as shell code, print them, or copy a secret out of them. - The pre-Accounts deployment keeps its own gateway on this host (port 4315) and its own service and database elsewhere, unchanged (parallel production).
Upgrading production
Run these as root on the host (through SSM), in this order:
-
Build the images. Dispatch
deployment-images.ymlon the reviewed commit. It builds two linux/arm64 images labelled with the commit and the version: the service's (dm-api,dm-workeranddm-migrate) and the website's (web/Dockerfile.gateway), and exports each as an archive with its metadata andSHA256SUMS. Stage both on the host and check the sums. -
Back up the database with an RDS snapshot.
-
Upgrade the service and the worker.
docker loadthe service's archive, then:python3 deploy/parallel/upgrade_service.py --image <image id> --revision <full commit> \ --grants deploy/runtime-grants.sqlIt checks the image's platform, revision and version, and saves the running containers' configuration and environment files in
/opt/silicon-accounts-apps/dm/best-chat-upgrade-<time>/. Then it stops the service and the worker, so no older process writes beside a newer one, and runsdm-migratewithmigration.env. It appliesdeploy/runtime-grants.sqlas the migrator (a new table is unusable by the runtime role until it is granted). It writesapi.envandworker.env: the running settings, plusDM_DEEPGRAM_API_KEYfrom the secret'sdeepgram_api_keyfield, written only into those two files and never printed. It starts both containers with 768 MB (--memory) and waits for/ready(204) on127.0.0.1:8081and forGET /api/v1/voicesto sayenabled: true. If any of that fails, it puts the old containers and their files back. -
Upgrade the website.
python3 deploy/parallel/upgrade_web.py --stage <folder with gateway.tar, gateway.metadata.json, SHA256SUMS> \ --source <full commit>It checks the sums and the revision, loads the image, stops the running website and keeps it as
dm-accounts-web-before-<time>. Theninstall_web.pywritesweb.env(the website's settings), starts the new container and waits until/answers 200 with the name "Silicon DM". If it does not, the previous container comes back. -
Run the acceptance checks.
Upgrading stops each part for a moment. The CLI's relay retries what it sent meanwhile, and the browser's outbox does a few times and then offers Retry, so nothing written is lost. The website stops the uploads under way when it is told to stop and cancels their reservations at Briefcase, and the page sends each such file again from its first byte; see stopping the website.
Rolling back. The previous containers keep their names with a suffix: dm-accounts-api-before-0.17-<time> and
dm-accounts-worker-before-0.17-<time>, with their environment files in the backup folder, and
dm-accounts-web-before-<time>. Stop and rename the new container, rename the old one back, and start it. 0.16 runs on
the migrated database (0.17's migrations say why for each). Its sync feed then meets
positions it does not know, the ones 0.17's conversation events took, and answers 409 sync_reset_required: each
client resets its sync once and refreshes its lists, and nothing is lost (0.16 writes no conversation events, so it
happens once).
The migrations of 0.17
dm-migrate applies them in order (the journal is public._sqlx_migrations), without the statement timeout the
service uses (DM_DATABASE_STATEMENT_TIMEOUT_SECONDS does not apply to it: a backfill on a large table must not be cut
short), and on failure prints the whole chain of causes. Then run deploy/runtime-grants.sql (upgrade_service.py
does both).
| Migration | What it does | DM 0.16 on the migrated database |
|---|---|---|
| 0043 voices, speech and unread | Voice choices, the speech cache and renders, per-account speech usage, read positions (with a backfill from the receipts already recorded) | Never touches the new tables |
| 0044 conversations of several and large files | Conversations of three or more accounts, attachment sizes up to 5 TiB | Its rows keep fitting |
| 0045 conversation events | Per-account conversation events in the sync stream | Never reads or writes them (its feed resets once: above) |
| 0046 delivery anchors without waiting | Fan-out triggers that no longer wait on another writer's directory refresh | Its writes fire them, with the same results |
| 0047 outbox lookups and retention | Who received each message (message_recipients), keyed trigger lookups, 30 days of sync feed, delivered_through_sequence |
Its writes fire the replaced trigger functions, with the same results through keyed lookups |
| 0048 speech cache scopes | Speech audio cached per conversation, and per account for POST /speech |
Never touches the speech tables |
| 0049 message recipients recorded on send | The database records a message's recipients from its outbox rows, whichever version sends | Its sends record them too, so ticks keep moving |
| 0050 presence activity conversation | The conversation a device's activity is about (client_presence_leases.activity_conversation_id) |
Writes leases without it; nothing breaks |
| 0051 read events | read conversation events with the reader's unread facts |
Never reads or writes conversation events |
All of them are additive: nothing 0.16 reads changes meaning, and no data is removed except the sync feed's events older than 30 days, which no client can still ask for.
The website's settings in production
install_web.py writes these into web.env:
| Variable | Value |
|---|---|
APP_ID |
dm |
APP_SECRET, SESSION_SECRET |
From the secret (changing SESSION_SECRET signs every browser out) |
ACCOUNTS_URL |
https://accounts.teamofsilicons.com |
APP_API_URL |
http://127.0.0.1:8081, the DM service on the same host |
PUBLIC_URL |
https://dm.teamofsilicons.com |
HOSTNAME, PORT |
0.0.0.0, 4316 |
BRIEFCASE_API_URL |
https://api.briefcase.teamofsilicons.com: uploads are on |
NEXT_TELEMETRY_DISABLED |
1 |
TING_BROWSER_ORIGIN |
https://ting.teamofsilicons.com; enable after registering dm.syncChanged and checking the Ting rollout |
There is no UPLOADS_MAX_BYTES, so the website has no limit of its own and Briefcase's apply. There is no
KEEP_ALIVE_TIMEOUT either: the website keeps idle connections longer than the load balancer by itself
(The website).
Stopping the website
The website stops the uploads under way when it is told to stop (docker stop sends SIGTERM). It takes no new one
(503 website_restarting), stops each transfer and each save that was still going after its last byte, and cancels
their reservations at Briefcase with fresh proof tokens before the process exits. Its log says so, with how many it
stopped (dm: stopping; one upload was under way…). Without that, each reservation would stay pending at Briefcase
for up to a day, counted against the Carbon's daily allowance and their 32 unfinished uploads. The page sends each
stopped file again from its first byte once the website is back.
docker stop waits only 10 seconds by default before it kills the process, so the scripts give every part 30:
docker stop --time 30 for the website, the service and the worker, and --stop-timeout 30 on the website's
docker run. The website's cancels then have room even when Briefcase answers slowly, and the service finishes its
requests (a sync long-poll holds up to 30 seconds; DM_SHUTDOWN_TIMEOUT_SECONDS is 30 by default). Stopping a part by
hand, do the same. A deployment during a large upload costs that upload its progress, so check the website's log, or
wait, when that matters.
The DM service's settings
DM's app at Silicon Accounts (dm) needs its webhook at https://api.dm.teamofsilicons.com/webhook/ (every update,
including custodian changes) and, for the command, device_flow and public_client turned on.
| Setting | Migration process | API and worker |
|---|---|---|
DM_ENVIRONMENT |
production |
production |
DM_DATABASE_URL |
The migrator role (dm_accounts_migrator) |
Same database, the runtime role (dm_accounts_runtime) |
DM_BIND_ADDR |
not needed | 0.0.0.0:8081 in production (the load balancer reaches the host's port) |
DM_PUBLIC_BASE_URL |
not needed | https://api.dm.teamofsilicons.com/api/v1/ |
ACCOUNTS_URL |
not needed (dm-migrate link-identities uses it) |
https://accounts.teamofsilicons.com |
ACCOUNTS_API_URL |
optional | Optional server-to-server origin |
DM_APP_ID |
not needed | dm |
DM_APP_SECRET |
not needed | DM's app secret |
DM_ACCOUNTS_WEBHOOK_SECRET |
not needed | The app webhook secret (whsec_…) |
DM_DATA_KEY |
not needed | 32 random bytes, base64; stable and backed up |
DM_PROOF_ISSUERS |
not needed | Apps allowed per proof scope |
DM_TING_URL |
not needed | Ting's address when Ting delivers to Silicons (enable_ting.py set it in production); unset, clients sync with GET /api/v1/sync |
DM_GIPHY_API_KEY |
not needed | Giphy key |
DM_DEEPGRAM_API_KEY |
not needed | Deepgram key for voices, spoken messages, listening and transcription; upgrade_service.py writes it from the secret |
DM_REPORTS_PER_HOUR |
not needed | Bug reports one account may send an hour (default 10) |
Without DM_DEEPGRAM_API_KEY the service runs, says speech.enabled: false in GET /api/v1/accounts, and answers
the speech routes with 503 speech_not_configured; everything else works. Both the API and the worker render speech
(they share the work through the database), so both need the key. The speech limits (DM_SPEECH_RENDERS_PER_MINUTE,
DM_SPEECH_DAILY_CHARACTERS, the cache size) are in configuration.
Database connections. Each process keeps a pool of DM_DATABASE_MAX_CONNECTIONS for its queries, and up to three
more connections of its own, outside that pool, for LISTEN (the wake-ups that let a sync long-poll, the Ting worker
and the speech worker react at once): dm-api opens three, dm-worker two. Count them when sizing the database's
connection limit: with a pool of 4, the service and the worker hold up to 13 together (4 + 3 + 4 + 2). A deployment
with a pool of one connection opens no listener and relies on polling.
Production database URLs must include exactly one sslmode=verify-full and a host matching the certificate; never
disable verification. Other production upstream URLs use HTTPS. .env.example lists pool,
timeout, delivery and provider settings.
Migrations: dm-migrate only applies them; dm-migrate link-identities and draft-identity-mapping re-key
identities from before Silicon Accounts at cutover. The journal is public._sqlx_migrations; a mismatch is an error to
investigate, never permission to rewrite history. Run deploy/runtime-grants.sql after every migration. Back up the
database before upgrading; replacing an image never deletes data.
Ingress for the DM service
| Path | Required handling |
|---|---|
/api/v1/* |
HTTPS requests; preserve Authorization, Idempotency-Key, If-Match, Range and x-dm-contract-version |
/api/v1/sync |
Long-poll: allow responses up to 45 s without buffering |
/api/v1/conversations/*/speech, /api/v1/speech |
Rendering takes 2 to 6 seconds and at most about 3 minutes: allow responses up to 200 s |
/api/v1/speech/transcriptions |
Audio uploads up to DM_TRANSCRIBE_MAX_BYTES: allow the body and up to 10 minutes |
/webhook/ |
POST with the exact original body bytes and the X-Accounts-* headers; keep the trailing slash |
/live, /ready |
Probes; success is 204 |
Do not parse and re-encode webhook JSON at the proxy, cache authenticated responses or strip
Cache-Control: no-store. Redact credentials from logs. Align body limits with DM_MAX_HTTP_BODY_BYTES (128 MiB by
default).
The website
The website is a Next.js server (web/Dockerfile.gateway, node server.js).
Production runs it as dm-accounts-web on port 4316; the Fargate alternative is
deploy/aws/web.yaml (its notes). Browsers talk only to
it. It signs Carbons in, forwards their calls to the DM service through its same-origin proxy (/api/…), and saves
their files and voice notes in Silicon Briefcase. Its settings are in
configuration.
| Path | Required handling |
|---|---|
POST /api/uploads |
The request body is the file itself, of any size up to Briefcase's 5 TiB: no body limit, no request buffering (the website pipes the bytes on to Briefcase as they arrive; a proxy that buffers first holds the whole file and delays every byte), and no deadline for the whole request. An ordinary idle timeout is enough (60 seconds or more): bytes flow the whole time, and the website answers within about 20 seconds of the last one (see below). With nginx: client_max_body_size 0; proxy_request_buffering off; proxy_http_version 1.1; on that location, with proxy_read_timeout and proxy_send_timeout of at least 60 s |
GET, DELETE /api/uploads/saving |
A file the website is still saving into Briefcase: each GET waits up to 25 seconds for the outcome (allow 60), DELETE cancels it |
GET, HEAD /api/uploads/config |
Small JSON; whether uploads are on, and the largest file |
GET, HEAD /api/uploads/file |
Streams the files of messages (photos, audio, video) from Briefcase: keep Range, If-Range and If-None-Match on the way in and 206, Content-Range, Accept-Ranges and ETag on the way out, never buffer or cache them (they are Cache-Control: private), and keep the website's Content-Security-Policy: sandbox and nosniff |
| every page | Keep the website's own headers, in particular Permissions-Policy: camera=(), microphone=(self), …: voice notes and dictation need the microphone, and an edge that sends microphone=() instead turns them off |
/api/… (the proxy) |
As the service's routes above: the sync long-poll holds 45 s, speech answers within 200 s, transcription uploads up to DM_TRANSCRIBE_MAX_BYTES within 10 minutes |
/auth/callback |
Registered at Silicon Accounts as PUBLIC_URL/auth/callback before traffic moves |
The website runs as one process, in one replica. Its session refresh uses an in-process cache, and a file it is still
saving into Briefcase after its 202 lives in that process's memory (the page's GET /api/uploads/saving must reach
it), so do not run it in cluster mode or with several replicas unless a Carbon's requests always reach the same one.
Its own server. The website sets up its server when it starts, before it answers anything (instrumentation.ts,
prepareServer in web/lib/server/briefcase.ts), so these hold from the first request after a start, an upload
included:
- The deadline for a request's body. Node ends a request whose body is still arriving after five minutes. The website raises that to a day for its whole server, which no upload reaches (Briefcase's own deadline is far shorter). Headers must still arrive within 60 seconds.
- Idle connections. Node closes an idle connection after 5 seconds. A load balancer that keeps its own connections
to the website longer then sends a request now and then on one the website is just closing, and answers it 502.
AWS documents this cause, and it can hit an upload at its first byte. So the website keeps idle connections 185
seconds, longer than production's 180. An operator's
KEEP_ALIVE_TIMEOUT(milliseconds, Next's own setting) takes its place. Behind a balancer whose idle timeout is longer than 180 seconds, set it above that timeout.
Uploads and the website's server. An upload holds two connections for as long as its bytes take: the browser's, and the website's own to Briefcase. Nothing is written to the website's disk and its memory stays bounded. Measured on a production build: a 6 GiB upload at 2 GB/s took a fresh server from 107 to 291 MB, three more at once added nothing, and at 100 MB/s it does not move. A request to a page can make it hold at most 1 MB. So the task's size does not limit a file: 512 MB is plenty. The limits that remain are the ingress's and Briefcase's:
- Idle timeouts. Bytes flow the whole time a file is sent. After the last byte, Briefcase checks the file's digest
and stores it before it answers, and for a large file that takes minutes with nothing moving. A load balancer would
cut the browser's request after its idle timeout (production's: 180 seconds) and the file would be lost. So the
website answers the browser within 20 seconds of the last byte instead (
202, with an id) and keeps saving, and the page asksGET /api/uploads/savinguntil it has the link, each ask waiting up to 25 seconds. No timeout in front of the website needs raising. The website's own request to Briefcase does wait in silence, up to 45 minutes. What Briefcase's ingress allows after the last byte (its nginx: 900 seconds on the transfer route) bounds the largest file, with Briefcase's own limits below. The website stops a transfer where nothing moves for 5 minutes. - Briefcase's own limits (the Briefcase side).
Uploads: the Briefcase side
The DM service never handles files. The website's server saves a Carbon's file into the Carbon's own Silicon
Briefcase, under apps/dm/, as DM acting for the Carbon. Silicon Accounts issues it a User verification proof for the
receiving app briefcase with the scopes briefcase.uploads.reserve, briefcase.uploads.commit,
briefcase.uploads.status, briefcase.uploads.cancel and briefcase.link_access.update, and Briefcase checks it.
Production Briefcase accepts those proofs only from the apps its BRIEFCASE_PROOF_ISSUERS lists for each scope, so dm
must be listed for all five:
- Without
briefcase.uploads.reserveorbriefcase.uploads.commit, every upload answers 503briefcase_not_allowedand nothing is saved; the website offers links instead. - Without
briefcase.link_access.update, every file is saved but its link never opens. The Carbon reads "Silicon Briefcase saved the file but does not let DM open links to files yet", with where the copy is. - Without
briefcase.uploads.cancelorbriefcase.uploads.status, a stopped upload is not let go and stays pending for a day, and one whose answer was lost on the way cannot be checked, so it fails.
The website's log names Briefcase's own code for each, never a file's name: upload failed: briefcase_not_allowed (proof_scope_missing) for a refused upload, and upload: Silicon Briefcase did not cancel an unfinished upload (proof_scope_missing)… for a refused cancel. Look for both after turning uploads on.
Briefcase's own limits bound what a Carbon can send. Its operators set them:
- Its upload deadline (
BRIEFCASE_UPLOAD_TIMEOUT_SECONDS, 900 seconds by default) bounds one file from its first byte to its answer. At 100 Mbit/s that carries about 10 GB, so raise it for large files (21600, six hours, is a sensible value). - The daily allowance: 100 GiB of uploads into one Briefcase a UTC day unless its operators gave that Briefcase
more. Unfinished uploads count toward it, so a larger file is refused on any day (429
briefcase_limit). The composer warns when such a file is added. - Its temporary disk holds each file while it is checked (production: about 90 GB free on the Briefcase host today). It bounds the largest single file until it grows.
Set BRIEFCASE_API_URL on the website only once Briefcase lists dm (UPLOADS_MAX_BYTES only if you want a limit
below Briefcase's 5 TiB). Then check an upload, a voice note and a photo in a message end to end, and one large file
(a few gigabytes) to see the timeouts above hold.
Decisions for the Carbon
These change what DM may do for every Carbon and Silicon, and what it costs, so the Carbon responsible for production makes them; nothing in this repository makes them by itself.
- Speech in production. A Deepgram key in DM's production secret (
deepgram_api_key), whichupgrade_service.pypasses to both the API and the worker asDM_DEEPGRAM_API_KEY. Deepgram then renders and transcribes, billed to that key, within the per-account limits in configuration. - Uploads into Briefcase.
dmin production Briefcase'sBRIEFCASE_PROOF_ISSUERSfor the five upload scopes above, andBRIEFCASE_API_URLon the website (install_web.pysets it). DM can then write into every Carbon's Briefcase, only underapps/dm/and only with that Carbon's own sign-in, files of any size up to Briefcase's 5 TiB. - How large a file can be in practice. Briefcase's daily allowance (100 GiB a Briefcase by default), its upload deadline and its temporary disk, above.
The Carbon turned speech and uploads on for DM 0.17 (18 October 2026): speech with a Deepgram key, and uploads with
dm on Briefcase's issuer list. Turning either off again is the Carbon's decision too.
Acceptance after a deployment
/liveand/readythrough the public origin answer 204.GET /api/v1/accountsnames the expected Silicon Accounts and app id, andspeech.enabledis what you configured.- A Carbon signs in on the website or with
dm login; a Silicon withsilicon-accounts login --app dm -q | dm login --slt-stdin; both passdm login status --json. - The Carbon sends to a Silicon it looks after; the Silicon reads it with
dm inboxand marks it read; the Carbon seesread_at. - With speech on:
dm voices listlists the five voices, the Silicon sendsdm messages send c:… --speak 'Testing.' --wait, itsspeech.statusbecomesreadywithin seconds, anddm messages listensaves the audio. POST /v1/apps/dm/webhook/testat Silicon Accounts arrives (dm.accounts_webhook_receipts).- GIF trending and search work with the configured key.
- With uploads on: on the website, attach a photo and record a voice note; both arrive, play and open from the
other Carbon's browser (
GET /api/uploads/configanswersenabled: true,capped: falseunless you setUPLOADS_MAX_BYTES). Then send a file of a few gigabytes: it prepares, shows its speed and time left, arrives, and opens through the website (its download is streamed withRange). A file large enough that Briefcase takes more than 20 seconds to store it (tens of gigabytes) says "Saving… this can take a few minutes" and then arrives. The website's log has noupload failed: briefcase_not_allowedline. - On the host,
curl -sI http://127.0.0.1:4316/robots.txtsaysKeep-Alive: timeout=185. The first answer after a start still says 5, because Node writes it before the website changes the setting. - Record the image digests, the migration version and the observed outcomes (no secrets).
The alternative: Fargate
deploy/aws/fargate.yaml runs private ARM64 API and worker tasks behind a dedicated
Application Load Balancer, with an encrypted RDS PostgreSQL instance; the stack owns its security groups, task roles,
CloudWatch logs and rate-limiting WAF (the Fargate guide). The
EC2 template (its guide) remains available too, and
deploy/aws/web.yaml runs the website. Production does not use them today, and they lag
behind it:
- They pass neither
DM_DEEPGRAM_API_KEYto the tasks norBRIEFCASE_API_URLto the website. Add the secret and the variable to both service task definitions, and the variable to the website's, before relying on them. - They default to the names of the pre-Accounts deployment: check every host name before applying one.
Build and push reviewed ARM64 runtime and bootstrap images to silicon-dm-production in ECR, pass their immutable
digests to a CloudFormation change set, and inspect it before execution. Start with RuntimeDesiredCount=0, run the
bootstrap (migration) task, check it exited 0, then raise the count. Credentials belong in Secrets Manager, never in
image layers, template parameters or command arguments. Fargate allows a 120-second stop timeout; DM's shutdown
deadline is 110 s.
A rolling deployment runs an older task beside a newer one for a while. A 0.16 task then meets sync positions it does
not know (0.17's conversation events take some) and answers 409 sync_reset_required, so each affected client resets
its sync once; nothing is lost. The upgrade on the host has no such overlap: it stops the old service first.
By hand, on a host of your own:
docker build --tag silicon-dm:0.17.1 .
docker run --rm --env-file /secure/silicon-dm/migration.env silicon-dm:0.17.1 dm-migrate
psql 'service=dm_migrator' --set ON_ERROR_STOP=1 \
--set runtime_role=dm_runtime --file deploy/runtime-grants.sql
docker run --detach --name silicon-dm-api --restart unless-stopped \
--env-file /secure/silicon-dm/runtime.env --publish 127.0.0.1:8080:8080 silicon-dm:0.17.1 dm-api
docker run --detach --name silicon-dm-worker --restart unless-stopped \
--env-file /secure/silicon-dm/runtime.env silicon-dm:0.17.1 dm-worker
The image contains dm-api, dm-worker and dm-migrate and runs as UID/GID 10001. Use private --env-files; never
source them as shell code. The move of an earlier deployment to Silicon Accounts (DM 0.15) was a coordinated change,
recorded in the cutover runbook.
Client release
The dm command is distributed only through Silicon Apps: the release workflow builds one archive per target with
apps.yaml, the archives are uploaded with silicon-apps upload, and Silicon Apps installs and updates the command;
DM never replaces itself. silicon-dm-protocol and silicon-dm-client go to crates.io for Rust integrations.
Release DM has every step.