Skip to content

Deployment

ServiceLabs runs as its own Docker Compose project on an existing host, next to other stacks. It shares the host's Postgres and Redis servers (with its own database and Redis DB index) and the host's nginx (with its own vhost file) — and nothing else.

Operator-only

This page is for whoever runs the platform. Callers don't need any of it — see the Quickstart.

Topology

Cloudflare  (proxied DNS, SSL mode Full (strict))
   │  servicelabs.dev (+/docs) · app.servicelabs.dev · api.servicelabs.dev

host nginx  ── sites-enabled/developer-utils.conf   (Cloudflare origin certificate)
   │  127.0.0.1:8097

developer-utils-api-1        Hono: /v1 · /console/api · console SPA · /docs      network_mode: host
developer-utils-worker-1/2   BullMQ: async jobs · callbacks · rollups · janitor   network_mode: host
   │                    │                             │
   ▼                    ▼                             ▼
Postgres (loopback)   Redis (loopback)             AWS: invoke the utility functions
 db developer_utils    DB index 5, prefix "du"          read their S3 outputs → write to R2
  • One image, two roles. infra/Dockerfile builds the console and docs, and runs the api or the worker from TypeScript via tsx, picked by the compose command. It runs as a non-root user.
  • Host networking, because the datastores listen on loopback only. The api binds 127.0.0.1:8097; nothing listens on a public interface.
  • Two worker replicas. BullMQ locks each job to one replica; if a replica dies mid-job, the job is re-delivered to the other after about a minute.
  • Health checks: the api container probes /health/ready (Postgres + Redis); workers write a heartbeat file every 15 s.

One-time setup

Do these once, in order.

1. DNS & TLS

  1. In Cloudflare, add a proxied record for utils pointing at the host.
  2. Create a Cloudflare Origin Certificate for *.servicelabs.dev, servicelabs.dev and install it on the host next to the other origin certs (/etc/ssl/cloudflare/servicelabs-dev.pem + .key, key chmod 600).
  3. Set the SSL/TLS mode to Full (strict) for this hostname.

2. R2 bucket

  1. Create a bucket (e.g. developer-utils).
  2. Bind a custom domain such as files.servicelabs.dev — this becomes R2_PUBLIC_BASE_URL, so output links are plain public URLs. Leave it unset to hand out 7-day presigned links instead (re-signed whenever a job is read).
  3. Add an object lifecycle rule, e.g. delete after 30 days. Outputs are laid out as <utility>/<YYYY-MM-DD>/<job id>/<file>, so per-utility rules are easy.
  4. Create an R2 API token with Object Read & Write on that bucket. Note the access key id, secret, and the S3 endpoint https://<account id>.r2.cloudflarestorage.com.

3. Cloud credentials for the gateway

Create a dedicated IAM user (or role) for the gateway with exactly:

PermissionOn
lambda:InvokeFunctionthe five utility functions
s3:GetObjectthe five output prefixes the functions write to (html-pdf/, zip-converter/, unzip-converter/, split-pdf/, xls-converter/)
secretsmanager:GetSecretValuethe one shared signing secret, if a utility reads its key from Secrets Manager
s3:DeleteObject (optional)the same prefixes — only with SOURCE_DELETE_AFTER_COPY=true

The repository ships this as infra/aws/gateway-iam-policy.json. Instead of deletes, an S3 lifecycle rule expiring those prefixes after a day works just as well — R2 holds the copy callers get.

4. Utility signing keys

The utility functions still validate an HS256 token themselves, and their keys aren't all the same. For each utility, set either its raw key or the name of the secret holding it:

UtilityVariable
HTML → PDFUTILITY_HTML_PDF_JWT_SECRET
Zip filesUTILITY_ZIP_FILES_JWT_SECRET
Unzip archiveUTILITY_UNZIP_FILES_JWT_SECRET
Split PDFUTILITY_SPLIT_PDF_JWT_SECRET_NAME (Secrets Manager name)
XLS → XLSXUTILITY_XLS_TO_XLSX_JWT_SECRET

A wrong key shows up as a failed job with upstream_error and a message naming the variable to fix. See Configuration.

5. Database & Redis

Create a dedicated role and database on the shared Postgres (the repo's infra/setup-db.sql does it):

bash
psql -h 127.0.0.1 -U postgres -v pw="'$(openssl rand -hex 24)'" -f infra/setup-db.sql

For Redis, reuse the instance with its own DB index (/5) and QUEUE_PREFIX=du, so its queues can never collide with another stack's.

6. Google sign-in (optional)

Create an OAuth client (type Web application) with:

  • Authorised JavaScript origin: https://app.servicelabs.dev
  • Authorised redirect URI: https://app.servicelabs.dev/console/api/auth/google/callback

Then set GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET and GOOGLE_ALLOWED_DOMAINS=finnoto.com. With GOOGLE_AUTO_PROVISION=true, verified accounts on those domains get a member account on first sign-in; everyone else needs an invite.

7. The host .env and nginx

Create the app directory (e.g. /opt/applications/developer-utils), copy .env.example there as .env and fill it in — at minimum APP_SECRET (openssl rand -hex 32), DATABASE_URL, REDIS_URL, PUBLIC_URL, the R2 and AWS credentials, and the utility keys. Every variable is documented in Configuration.

Install the vhost from infra/nginx/developer-utils.conf into sites-enabled/, then nginx -t && systemctl reload nginx. It proxies everything to 127.0.0.1:8097 with a 100 s read timeout (matching the edge limit) and buffering off.

8. Deploy and create the first admin

From a checkout, with .deploy-target.local filled in (SSH target, DEPLOY_DIR, PROJECT=developer-utils, COMPOSE_FILES="-f infra/docker-compose.prod.yml", health checks):

bash
infra/deploy.sh      # confirm → backup → sync committed ref → build → recreate → health checks

Then create the first admin (prints a temporary password unless you pass --password):

bash
docker compose -p developer-utils -f infra/docker-compose.prod.yml \
  exec api node_modules/.bin/tsx apps/server/src/cli.ts \
  create-user --email [email protected] --name "You" --role admin

Sign in, change the password under Settings → Security, turn on two-factor, and invite the team.

Everyday operations

bash
infra/deploy.sh                # deploy main
infra/deploy.sh --ref <sha>    # deploy a specific commit
infra/deploy.sh verify         # just run the health checks
infra/deploy.sh rollback       # restore the previous code and rebuild

The deploy script syncs committed code only (via git archive), never overwrites the host .env, and namespaces its backups by project so it can't collide with another stack's deploys on the same host.

TaskHow
Logsdocker logs -f developer-utils-api-1 | jq — one JSON object per line.
Healthcurl 127.0.0.1:8097/health/ready{ ok, db, redis }.
Rebuild usage rollups (e.g. after a restore)cli.ts rollup --days 30
Reset a password (also clears 2FA and sessions)cli.ts reset-password --email [email protected]
Apply migrations by handcli.ts migrate

Migrations run automatically when the api and workers boot, serialised by a Postgres advisory lock. Keep them additive: the old containers keep serving while the new image builds.

Graceful restarts. On shutdown the api gives in-flight sync jobs 60 s to finish, then hands any still running to the worker queue so they complete there. Workers finish their current job if they can; otherwise it's re-delivered to the other replica.

Cutover from the old function URLs

Callers move over one at a time — nothing breaks, because the old endpoints keep working until you remove them.

  1. Create one API key per calling service (Console → API keys), scoped to the utilities it uses.
  2. Switch the caller: new base URL, Authorization: Bearer du_live_…, the { "input": … } envelope and the normalised result.files[].url. The per-utility mapping is in Migrating from the Lambda URLs.
  3. Watch Usage → by API key until each service shows traffic, and the old endpoints show none.
  4. Retire the old HTTP endpoints through the infrastructure-as-code that owns them (not by hand, or the stacks drift). The functions stay; only the gateway can invoke them.

ServiceLabs · a Finnoto company