---
title: "Deploying on Amazon ECS Fargate"
description: "Procedure for deploying Bulk Signer as a single instance on Amazon ECS Fargate with RDS for SQL Server, an S3 bucket and a signing key in AWS KMS, with the configuration parameters, interface contracts and error codes involved."
canonical: "https://docs.lacunasoftware.com/en-us/articles/bulk-signer/aws"
locale: "en"
product: "Bulk Signer"
last_updated: "2026-11-11"
---

# Deploying on Amazon ECS Fargate

This page specifies a single-instance deployment of Bulk Signer on Amazon Web Services. The deployment
consists of:

- the Bulk Signer container image, running as **one** Amazon ECS task on AWS Fargate behind an
  Application Load Balancer;
- the operational store in **Amazon RDS for SQL Server**;
- the work share and one watched input folder in **one Amazon S3 bucket**;
- the signing key in **AWS KMS**.

This is not a separate installation type. It is the [Docker target](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/installation#docker--compose)
run by a managed orchestrator, and every setting on this page is a standard configuration key. The
procedure was validated end to end in a homologation environment: a PDF was uploaded over HTTPS, signed in
PAdES under the ICP-Brasil AD-RB policy with an e-CNPJ certificate whose key was held in AWS KMS, and
retrieved from S3.

> **Info: Minimum version: 2.20.0**
>
> The [S3 storage provider](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/configuration#the-work-share-in-an-s3-bucket--storageprovider--s3), the
> [`AwsKms` certificate source](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/certificates#source--awskms) and the
> [`Auth:SessionKeyRing:Placement`](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/configuration#authsessionkeyring--where-the-session-key-ring-is-kept)
> key were introduced in 2.20.0. Earlier images refuse the configuration on this page at boot.

> **Danger: Single-instance constraint**
>
> [Cluster mode](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/high-availability) is supported on Azure App Service only, and an S3 work share is
> refused when `Cluster:Enabled` is set. S3 provides no lease, so a second active instance cannot be
> distinguished from one that has stopped responding. The ECS service in step 8 is configured so that **two
> tasks never run concurrently, including during a deployment**. Deployments that require more than one
> instance must use [Azure App Service (cluster mode)](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/azure).

## Architecture

![Bulk Signer on Amazon ECS Fargate: one task behind an Application Load Balancer, with RDS for SQL Server, S3, AWS KMS, Secrets Manager and CloudWatch Logs](https://docs.lacunasoftware.com/images/bulk-signer/aws-single-instance-architecture.svg)

### IAM roles

| Role | Assumed by | Purpose |
|---|---|---|
| Execution role (`$NAME-exec`) | Amazon ECS | Pulls the image, writes the log stream and reads the secrets injected into the container environment. |
| Task role (`$NAME-task`) | Bulk Signer | The identity under which the application runs. It is the `InstanceRole` credential referenced by every S3 and KMS setting on this page. |

Neither role holds a long-lived access key. The only credential subject to manual rotation is the SQL
login password in the connection string, because RDS for SQL Server does not support IAM database
authentication.

### Component selection

| Component | Selection | Rationale |
|---|---|---|
| Compute | **ECS Fargate**, one task, 1 vCPU / 2 GB | The image runs unmodified. A Fargate task has no disk that persists beyond the task, which determines the next four rows. |
| Operational store | **RDS for SQL Server 2022** | SQLite requires a durable disk. Express edition is sufficient for a homologation environment and limits the database to 10 GB; production environments require Web or Standard edition. |
| Work share and inputs | **One S3 bucket** (`Storage:Provider = S3`) | No persistent disk is available. The bucket holds `processing/`, `output/` and `error/` under one prefix and the watched folder under another. |
| Session key ring | **Operational store** (`Auth:SessionKeyRing:Placement = OperationalStore`) | With any other placement the ring is written to the task's disk, and every deployment ends all sessions. |
| Signing key | **AWS KMS** (`Source = AwsKms`) | The key never leaves KMS, and every signature is one `kms:Sign` call recorded by AWS CloudTrail. |
| Certificate (`.cer`) | **An S3 object** in the same bucket | No disk path is available for a certificate file. The object is read once, at boot. |
| Logs | **stdout → CloudWatch Logs** | The file log is still written, but to a disk that is discarded with the task. |
| Database backup | **RDS automated backups** | `Backup:Enabled` is refused with `Database:Provider = SqlServer`. |

## Prerequisites

| Requirement | Specification |
|---|---|
| Tools | **AWS CLI v2**, authenticated (`aws sts get-caller-identity` must succeed), and **Docker** to copy the image. For the key import in step 2: **PowerShell 7** and **OpenSSL 3.x** (included with Git for Windows). |
| IAM permissions | Permission to create resources in ECR, ECS, IAM (`iam:CreateRole`, `iam:PutRolePolicy`, `iam:PassRole`), KMS, RDS, S3, Secrets Manager, Elastic Load Balancing, ACM, EC2 security groups and CloudWatch Logs. The `PowerUserAccess` policy alone is insufficient: it is denied `iam:CreateRole`. |
| Registry credentials | The credentials Lacuna issued for the container image. See [Obtaining the product](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/installation#obtaining-the-product). |
| Signing material | The **PFX** file whose key will sign, or a KMS key together with the certificate a certificate authority (CA) issued for it. |
| Licences and keys | The **PKI SDK licence** (required). The Lacuna Signer API key and the Web PKI licence, if those integrations are used. |
| DNS | A **hostname**, and write access to the DNS zone that is authoritative for it. |

> **Warning: Identify the authoritative DNS zone**
>
> A domain can have a Route 53 hosted zone in the account while being delegated to another provider (Azure
> DNS, Cloudflare, a registrar). Records written to a non-authoritative zone are never resolved, and the
> ACM certificate requested in step 7 remains in *Pending validation* indefinitely. Confirm the delegation
> with a public resolver before starting:
>
> ```bash
> dig +short NS example.com
> ```

### Shell variables

All commands on this page run in one shell session, initialised with the following variables:

```bash
REGION=sa-east-1
ACCOUNT=$(aws sts get-caller-identity --query Account --output text)
NAME=bulksigner                       # prefix for every resource
BUCKET=$NAME-$ACCOUNT                 # bucket names are global
HOST=bulksigner.example.com           # the public hostname
VPC=$(aws ec2 describe-vpcs --region $REGION --filters Name=is-default,Values=true --query 'Vpcs[0].VpcId' --output text)
SUBNETS=$(aws ec2 describe-subnets --region $REGION --filters Name=vpc-id,Values=$VPC --query 'Subnets[].SubnetId' --output text | tr '\t' ',')
VPC_CIDR=$(aws ec2 describe-vpcs --region $REGION --vpc-ids $VPC --query 'Vpcs[0].CidrBlock' --output text)
```

| Variable | Content |
|---|---|
| `REGION` | The AWS Region of every resource. |
| `ACCOUNT` | The AWS account ID of the authenticated identity. |
| `NAME` | The prefix of every resource name. |
| `BUCKET` | The bucket name. It includes the account ID because S3 bucket names are globally unique. |
| `HOST` | The public hostname of the deployment. |
| `VPC`, `SUBNETS`, `VPC_CIDR` | The default VPC, its subnet IDs (comma-separated) and its CIDR block. |

### Network placement

The procedure uses the default VPC and its public subnets. The task receives a public IP address for its
outbound calls (ECR, KMS, S3, Secrets Manager, Lacuna Signer), and its security group admits inbound
traffic from the load balancer only; no NAT gateway is required. For production, place the task and the
database in private subnets behind a NAT gateway or VPC endpoints. No other setting on this page changes.

## 1. The bucket

Create the bucket, block all public access, and abort incomplete multipart uploads after one day:

```bash
aws s3api create-bucket --bucket $BUCKET --region $REGION --create-bucket-configuration LocationConstraint=$REGION
aws s3api put-public-access-block --bucket $BUCKET --public-access-block-configuration \
  BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true
aws s3api put-bucket-lifecycle-configuration --bucket $BUCKET --lifecycle-configuration \
  '{"Rules":[{"ID":"abort-incomplete-multipart","Status":"Enabled","Filter":{"Prefix":""},"AbortIncompleteMultipartUpload":{"DaysAfterInitiation":1}}]}'
```

Create the watched folder. Bulk Signer never creates a watched folder, so it must exist before the first
boot. On S3, a folder is a zero-length marker object:

```bash
aws s3api put-object --bucket $BUCKET --key inputs/default/
```

The bucket uses three prefixes:

| Prefix | Content | Created by |
|---|---|---|
| `work/` | The work share: `processing/`, `output/` and `error/` | Bulk Signer, at boot |
| `inputs/default/` | The watched folder | The operator, before the first boot (marker object above) |
| `signing-material/` | The signing certificate (`.cer`) | The import script, in step 2 |

The prefixes must not nest. A watched folder inside the work share's prefix is refused at boot.

**Versioning and Object Lock must remain disabled.** With versioning enabled, every deleted input and
every staged copy is retained as a previous version, including payment files. The remaining behavioural
differences of an S3 work share (no lease, a move implemented as a copy followed by a delete, and a
fingerprint check before every signature) are documented in
[Configuration](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/configuration#the-work-share-in-an-s3-bucket--storageprovider--s3) and
[Installation](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/installation#azure-blob-storage-and-s3-storage-optional).

## 2. The signing key

For an existing PFX file, [`Import-PfxToKms.ps1`](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/samples#powershell-7--import-pfxtokmsps1) performs
the complete import:

1. Creates an RSA signing key in KMS with imported key material.
2. Wraps the private key locally in the format KMS requires.
3. Imports the wrapped key material.
4. Compares the KMS public key with the certificate, using the same check Bulk Signer performs at boot.
5. Uploads the `.cer` file to S3.

The script prompts for the PFX password.

```bash
pwsh ./Import-PfxToKms.ps1 -PfxPath ./signer.pfx -Region $REGION -Alias $NAME \
  -S3Bucket $BUCKET -S3Key signing-material/$NAME.cer \
  -SignerPrincipalArn arn:aws:iam::$ACCOUNT:role/$NAME-task
```

| Parameter | Value | Description |
|---|---|---|
| `-PfxPath` | `./signer.pfx` | The PFX file holding the key and the certificate. |
| `-Region` | `$REGION` | The AWS Region in which the key is created. |
| `-Alias` | `$NAME` | The KMS alias. Step 8 references it as `alias/<name>` in `Signing:Certificate:AwsKms:KeyId`. The script stops if the alias already exists. |
| `-S3Bucket` | `$BUCKET` | The bucket that receives the certificate. |
| `-S3Key` | `signing-material/$NAME.cer` | The object key of the certificate. |
| `-SignerPrincipalArn` | The task role ARN | The principal exempted from the key policy's `kms:Sign` deny. The role is created in step 6; an ARN can be referenced before the role exists. |

**Key policy.** The key is created with the default KMS key policy plus an explicit **deny of `kms:Sign`
to every other principal**. An account administrator therefore cannot sign as the certificate holder
without first changing the key policy, and CloudTrail records that change. To perform a test signature
from the command line, add the corresponding IAM user to the exemption list.

> **Warning: The key is only as secure as every copy of the PFX**
>
> KMS never returns the private key, but the key existed outside an HSM before the import. Retain exactly
> **one** copy of the PFX in offline backup, which is the only means of importing the key again, and delete
> all other copies. For an ICP-Brasil **A3** certificate, whose key is required to remain in certified
> hardware, resolve the compliance question before proceeding.

**Key generated in KMS.** A key created inside KMS instead requires a CSR signed by KMS and a certificate
issued for it. That procedure, and the checks Bulk Signer performs when it opens the key, are documented
in [Certificates](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/certificates#aws-setup).

**Key requirements**, regardless of origin:

| Property | Required value |
|---|---|
| Key type | RSA 2048, 3072 or 4096 |
| Key usage | **Sign and verify** |

## 3. The image

Copy the tag Lacuna specified for installation into an ECR repository in the account:

```bash
aws ecr create-repository --repository-name bulksigner --region $REGION \
  --image-tag-mutability IMMUTABLE --image-scanning-configuration scanOnPush=true
IMAGE=$ACCOUNT.dkr.ecr.$REGION.amazonaws.com/bulksigner:<version>

docker login <lacuna-registry> --username <registry-username>     # prompts for the token
docker pull --platform linux/amd64 <lacuna-registry>/bulksigner:<version>
docker tag <lacuna-registry>/bulksigner:<version> $IMAGE

aws ecr get-login-password --region $REGION \
  | docker login --username AWS --password-stdin $ACCOUNT.dkr.ecr.$REGION.amazonaws.com
docker push $IMAGE
```

| Requirement | Reason |
|---|---|
| Copy an explicit version, not `latest`. | The tag pushed here is the tag the task definition pins, and an upgrade is a scheduled deployment (step 11). |
| Use a repository in the same account. | ECS pulls the image every time a task starts. A local repository ensures that a restart does not depend on the availability of an external registry. |
| Platform `linux/amd64`. | The task definition specifies the `X86_64` architecture (step 8). |

> **Note: Docker Desktop on Windows: "The stub received bad data"**
>
> The ECR login token exceeds the length Windows Credential Manager accepts, so `docker login` to ECR can
> fail with `error storing credentials … The stub received bad data`. Run the login and push with a
> temporary Docker configuration directory (`docker --config <empty folder> login …` and
> `docker --config <same folder> push …`), then delete the directory.

## 4. Security groups

Create three security groups, each admitting inbound traffic from the preceding one only:

| Group | Inbound rule | Source |
|---|---|---|
| `$NAME-alb` | TCP 443 and TCP 80 | `0.0.0.0/0` |
| `$NAME-task` | TCP 8080 | `$NAME-alb` |
| `$NAME-db` | TCP 1433 | `$NAME-task` |

```bash
SG_ALB=$(aws ec2 create-security-group --region $REGION --vpc-id $VPC --group-name $NAME-alb \
  --description "Public HTTPS into the load balancer" --query GroupId --output text)
SG_TASK=$(aws ec2 create-security-group --region $REGION --vpc-id $VPC --group-name $NAME-task \
  --description "Bulk Signer, reachable only from the load balancer" --query GroupId --output text)
SG_DB=$(aws ec2 create-security-group --region $REGION --vpc-id $VPC --group-name $NAME-db \
  --description "RDS, reachable only from Bulk Signer" --query GroupId --output text)

aws ec2 authorize-security-group-ingress --region $REGION --group-id $SG_ALB --ip-permissions \
  'IpProtocol=tcp,FromPort=443,ToPort=443,IpRanges=[{CidrIp=0.0.0.0/0}]' \
  'IpProtocol=tcp,FromPort=80,ToPort=80,IpRanges=[{CidrIp=0.0.0.0/0}]'
aws ec2 authorize-security-group-ingress --region $REGION --group-id $SG_TASK \
  --ip-permissions "IpProtocol=tcp,FromPort=8080,ToPort=8080,UserIdGroupPairs=[{GroupId=$SG_ALB}]"
aws ec2 authorize-security-group-ingress --region $REGION --group-id $SG_DB \
  --ip-permissions "IpProtocol=tcp,FromPort=1433,ToPort=1433,UserIdGroupPairs=[{GroupId=$SG_TASK}]"
```

Because `$NAME-task` admits the load balancer only, no other host can reach Bulk Signer's port to forge
forwarded headers. This is the condition that allows step 8 to trust forwarded headers from the entire VPC
range.

## 5. The database

### 5.1 Instance

```bash
aws rds describe-db-engine-versions --region $REGION --engine sqlserver-ex \
  --query "DBEngineVersions[?starts_with(EngineVersion,'16.')].EngineVersion" --output text

aws rds create-db-instance --region $REGION --db-instance-identifier $NAME \
  --engine sqlserver-ex --engine-version <a 16.00 version> --license-model license-included \
  --db-instance-class db.t3.small --storage-type gp3 --allocated-storage 20 \
  --master-username bsadmin --manage-master-user-password \
  --vpc-security-group-ids $SG_DB --no-publicly-accessible \
  --backup-retention-period 7 --storage-encrypted
aws rds wait db-instance-available --region $REGION --db-instance-identifier $NAME
DB_HOST=$(aws rds describe-db-instances --region $REGION --db-instance-identifier $NAME \
  --query 'DBInstances[0].Endpoint.Address' --output text)
```

| Parameter | Value | Notes |
|---|---|---|
| `--engine` | `sqlserver-ex` | Express edition. For production, use `sqlserver-web` or `sqlserver-se`, and evaluate Multi-AZ. |
| `--engine-version` | A `16.00` version | Engine versions `16.00` are SQL Server 2022. The first command lists those available in the Region. |
| `--db-instance-class` | `db.t3.small` | |
| `--storage-type`, `--allocated-storage` | `gp3`, `20` | |
| `--master-username` | `bsadmin` | Used only by the setup task in 5.3. |
| `--manage-master-user-password` | — | Stores the master password in a Secrets Manager secret that RDS owns and rotates. |
| `--no-publicly-accessible` | — | The instance is reachable only from inside the VPC. |
| `--backup-retention-period` | `7` | Days of automated backups. |
| `--storage-encrypted` | — | Encrypts the storage at rest. |

### 5.2 Application login secret

Generate the application login's password directly into a secret that also holds the complete
connection string. `ConnectionStrings:Default` requires the full string, and ECS can inject one key of a
secret but cannot concatenate two:

```bash
APP_PW=$(aws secretsmanager get-random-password --region $REGION --password-length 40 \
  --exclude-punctuation --require-each-included-type --query RandomPassword --output text)
CS="Server=tcp:$DB_HOST,1433;Initial Catalog=BulkSigner;User ID=bulksigner;Password=$APP_PW;Encrypt=True;TrustServerCertificate=True;"
aws secretsmanager create-secret --region $REGION --name $NAME/sql-app \
  --secret-string "$(jq -n --arg p "$APP_PW" --arg c "$CS" '{username:"bulksigner",password:$p,connectionString:$c}')"
unset APP_PW CS
```

The secret `$NAME/sql-app` has the following structure:

| Key | Content | Consumer |
|---|---|---|
| `username` | `bulksigner` | — |
| `password` | The generated password (40 characters, no punctuation) | The setup task in 5.3 (`APP_PASSWORD`) |
| `connectionString` | The connection string below | The service (`ConnectionStrings__Default`) |

| Connection string property | Value |
|---|---|
| `Server` | `tcp:<RDS endpoint>,1433` |
| `Initial Catalog` | `BulkSigner` |
| `User ID` | `bulksigner` |
| `Password` | The generated password |
| `Encrypt` | `True` |
| `TrustServerCertificate` | `True` (see the warning below) |

> **Warning: TrustServerCertificate=True is a known limitation**
>
> The Bulk Signer image does not trust the Amazon RDS certificate authority. Without this property, the
> first login fails with *The certificate chain was issued by an authority that is not trusted*. Within a
> VPC whose database security group admits only the task, interception is a lesser risk than on a shared
> network; nevertheless, the database server certificate is not verified.

### 5.3 Database creation

Bulk Signer creates its tables but never its database
([Choosing where the operational store lives](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/installation#choosing-where-the-operational-store-lives)),
and the instance is private. Run the setup as a one-off Fargate task in the same VPC, using `sqlcmd` from
the `mcr.microsoft.com/mssql-tools` image, with the following script:

```bash
#!/bin/sh
set -e
SQL=/opt/mssql-tools/bin/sqlcmd
run() { "$SQL" -S "tcp:$DB_HOST,1433" -U "$MASTER_USER" -P "$MASTER_PASSWORD" -N -C -b "$@"; }
run -d master -Q "IF DB_ID('BulkSigner') IS NULL CREATE DATABASE [BulkSigner];"
run -d master -Q "ALTER DATABASE [BulkSigner] SET READ_COMMITTED_SNAPSHOT ON WITH ROLLBACK IMMEDIATE;"
run -d master -Q "IF SUSER_ID('bulksigner') IS NULL CREATE LOGIN [bulksigner] WITH PASSWORD = '$APP_PASSWORD', CHECK_POLICY = ON;"
run -d BulkSigner -Q "IF USER_ID('bulksigner') IS NULL CREATE USER [bulksigner] FOR LOGIN [bulksigner]; ALTER ROLE db_datareader ADD MEMBER [bulksigner]; ALTER ROLE db_datawriter ADD MEMBER [bulksigner]; ALTER ROLE db_ddladmin ADD MEMBER [bulksigner];"
```

The setup task definition requires:

| Item | Type | Value |
|---|---|---|
| Image | — | `mcr.microsoft.com/mssql-tools` |
| `DB_HOST` | Environment variable | The RDS endpoint (`$DB_HOST`) |
| `MASTER_USER` | Environment variable | `bsadmin` |
| `MASTER_PASSWORD` | Secret | `"<RDS-managed secret ARN>:password::"` |
| `APP_PASSWORD` | Secret | `"<$NAME/sql-app ARN>:password::"` |
| Execution role | — | `$NAME-exec` (step 6) |
| Logging | — | The service's `awslogs` group, `/ecs/$NAME` (step 8) |
| Network | — | The task's subnets and the `$NAME-task` security group |

Create the execution role (step 6) and the log group (step 8) before running the setup task. Run it once
with `aws ecs run-task` and read its log. **Exit code 0** confirms that:

- the `BulkSigner` database exists with `READ_COMMITTED_SNAPSHOT` enabled, which RDS, like any SQL Server
  outside Azure, does not enable by default;
- the `bulksigner` login exists and holds `db_datareader`, `db_datawriter` and `db_ddladmin`, and **not**
  `db_owner`.

## 6. Secrets and roles

### 6.1 Secrets

Generate `Auth:ApiKey`, which is the REST API's `X-API-Key` and, unless Microsoft Entra ID sign-in is
enabled (step 10), the dashboard's sign-in key:

```bash
aws secretsmanager create-secret --region $REGION --name $NAME/auth-api-key --secret-string \
  "$(aws secretsmanager get-random-password --region $REGION --password-length 48 --exclude-punctuation --query RandomPassword --output text)"
```

Enter the values issued by Lacuna manually, either in the console (**Other type of secret → Plaintext**,
containing the bare value only) or from a prompt that does not echo input. Do not record them in shell
history.

The complete secret inventory of the deployment:

| Secret | Content | Created in | Injected as |
|---|---|---|---|
| `$NAME/sql-app` | JSON with `username`, `password` and `connectionString` | Step 5.2 | `ConnectionStrings__Default` (key `connectionString`) |
| `$NAME/auth-api-key` | A random 48-character key | Step 6.1 | `Auth__ApiKey` |
| `$NAME/pki-sdk-license` | The PKI SDK licence | Step 6.1, manually | `Signing__PkiSdkLicense` |
| `$NAME/signer-api-key` | The Lacuna Signer API key, in the format `<application>\|<secret>`; the application part may contain spaces | Step 6.1, manually | `Signer__ApiKey` |
| `$NAME/webpki-license` | The Web PKI licence | Step 6.1, manually | `WebPki__License` |
| `$NAME/entra-client-secret` | The Entra ID client secret | Step 10 (optional) | `Auth__EntraId__ClientSecret` |
| RDS-managed master secret | The `bsadmin` password | Step 5.1, by RDS | `MASTER_PASSWORD` (setup task only) |

### 6.2 Roles

Both roles use the following trust policy, which restricts `ecs-tasks.amazonaws.com` to the account:

```json
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": { "Service": "ecs-tasks.amazonaws.com" },
    "Action": "sts:AssumeRole",
    "Condition": {
      "StringEquals": { "aws:SourceAccount": "<account>" },
      "ArnLike": { "aws:SourceArn": "arn:aws:ecs:<region>:<account>:*" }
    }
  }]
}
```

| Role | Permissions |
|---|---|
| `$NAME-exec` | The AWS managed policy `AmazonECSTaskExecutionRolePolicy`, plus `secretsmanager:GetSecretValue` on `secret:$NAME/*` and on the RDS-managed master secret (the latter for the setup task only). |
| `$NAME-task` | The inline policy below. |

```json
{
  "Version": "2012-10-17",
  "Statement": [
    { "Effect": "Allow", "Action": ["kms:DescribeKey", "kms:GetPublicKey", "kms:Sign"],
      "Resource": "arn:aws:kms:<region>:<account>:key/<key id>" },
    { "Effect": "Allow", "Action": "s3:ListBucket", "Resource": "arn:aws:s3:::<bucket>" },
    { "Effect": "Allow", "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:AbortMultipartUpload"],
      "Resource": "arn:aws:s3:::<bucket>/*" }
  ]
}
```

When each action is called is specified in [AWS calls made by the task](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/aws#aws-calls-made-by-the-task).

**ECS Exec (optional).** To open a shell in the running task for diagnosis, add the four `ssmmessages:*`
channel actions to the task role and pass `--enable-execute-command` when creating the service in step 8.

## 7. HTTPS

### 7.1 Certificate

```bash
CERT=$(aws acm request-certificate --region $REGION --domain-name $HOST --validation-method DNS \
  --query CertificateArn --output text)
aws acm describe-certificate --region $REGION --certificate-arn $CERT \
  --query 'Certificate.DomainValidationOptions[0].ResourceRecord'
```

Create the returned CNAME record in the authoritative zone (see [Prerequisites](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/aws#prerequisites)) and wait
for validation:

```bash
aws acm wait certificate-validated --region $REGION --certificate-arn $CERT
```

### 7.2 Load balancer, target group and listeners

```bash
LB=$(aws elbv2 create-load-balancer --region $REGION --name $NAME --type application --scheme internet-facing \
  --subnets ${SUBNETS//,/ } --security-groups $SG_ALB --query 'LoadBalancers[0].LoadBalancerArn' --output text)
aws elbv2 modify-load-balancer-attributes --region $REGION --load-balancer-arn $LB \
  --attributes Key=idle_timeout.timeout_seconds,Value=300 Key=routing.http.drop_invalid_header_fields.enabled,Value=true
TG=$(aws elbv2 create-target-group --region $REGION --name $NAME --protocol HTTP --port 8080 --vpc-id $VPC \
  --target-type ip --health-check-path /api/ready --health-check-interval-seconds 30 \
  --healthy-threshold-count 2 --unhealthy-threshold-count 3 --matcher HttpCode=200 \
  --query 'TargetGroups[0].TargetGroupArn' --output text)
aws elbv2 modify-target-group-attributes --region $REGION --target-group-arn $TG \
  --attributes Key=deregistration_delay.timeout_seconds,Value=30
aws elbv2 create-listener --region $REGION --load-balancer-arn $LB --protocol HTTPS --port 443 \
  --certificates CertificateArn=$CERT --ssl-policy ELBSecurityPolicy-TLS13-1-2-2021-06 \
  --default-actions Type=forward,TargetGroupArn=$TG
aws elbv2 create-listener --region $REGION --load-balancer-arn $LB --protocol HTTP --port 80 \
  --default-actions 'Type=redirect,RedirectConfig={Protocol=HTTPS,Port=443,StatusCode=HTTP_301}'
```

| Resource | Setting | Value | Purpose |
|---|---|---|---|
| Load balancer | Scheme | `internet-facing` | Public entry point. |
| Load balancer | `idle_timeout.timeout_seconds` | `300` | Keeps the dashboard's WebSocket open between updates. |
| Load balancer | `routing.http.drop_invalid_header_fields.enabled` | `true` | Removes HTTP header fields with invalid names before forwarding. |
| Target group | Protocol, port, target type | `HTTP`, `8080`, `ip` | Fargate tasks in `awsvpc` mode register by IP address. |
| Target group | Health check | `/api/ready`, every 30 s; 2 healthy, 3 unhealthy; `200` expected | See the contract below. |
| Target group | `deregistration_delay.timeout_seconds` | `30` | |
| HTTPS listener | Port, policy, action | `443`, `ELBSecurityPolicy-TLS13-1-2-2021-06`, forward to the target group | |
| HTTP listener | Port, action | `80`, redirect to HTTPS with `301` | |

**Health check contract.** `GET /api/ready` is anonymous and returns `503` until the database schema is
applied. The load balancer therefore routes no traffic to a task that cannot yet serve requests. See
[HTTP endpoints](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/aws#http-endpoints).

### 7.3 DNS

Point the hostname at the load balancer's DNS name, with a CNAME record or, if the zone is hosted in
Route 53, an alias record.

## 8. The service

### 8.1 Log group and cluster

```bash
aws logs create-log-group --region $REGION --log-group-name /ecs/$NAME
aws logs put-retention-policy --region $REGION --log-group-name /ecs/$NAME --retention-in-days 30
aws ecs create-cluster --region $REGION --cluster-name $NAME --capacity-providers FARGATE
```

### 8.2 Task definition

| Parameter | Value |
|---|---|
| `cpu` | `1024` |
| `memory` | `2048` |
| `networkMode` | `awsvpc` |
| `runtimePlatform.cpuArchitecture` | `X86_64` |
| `executionRoleArn` | `$NAME-exec` |
| `taskRoleArn` | `$NAME-task` |
| Container name | `bulksigner` |
| Container image | `$IMAGE` (step 3) |
| Container port | `8080` |
| `stopTimeout` | `60` |
| Log driver | `awslogs`, group `/ecs/$NAME` |

The container definition carries the following environment and secrets:

```json
"environment": [
  { "name": "ASPNETCORE_ENVIRONMENT", "value": "Production" },
  { "name": "Database__Provider", "value": "SqlServer" },
  { "name": "Auth__SessionKeyRing__Placement", "value": "OperationalStore" },
  { "name": "Hosting__ForwardedHeaders__Enabled", "value": "true" },
  { "name": "Hosting__ForwardedHeaders__KnownNetworks__0", "value": "<the VPC CIDR>" },
  { "name": "Storage__Provider", "value": "S3" },
  { "name": "Storage__S3__BucketName", "value": "<bucket>" },
  { "name": "Storage__S3__Prefix", "value": "work" },
  { "name": "Storage__S3__Region", "value": "<region>" },
  { "name": "Storage__S3__Credential", "value": "InstanceRole" },
  { "name": "Storage__Inputs__0__Name", "value": "default" },
  { "name": "Storage__Inputs__0__Path", "value": "inputs/default" },
  { "name": "Storage__Inputs__0__PollIntervalSeconds", "value": "30" },
  { "name": "Signing__Certificate__Source", "value": "AwsKms" },
  { "name": "Signing__Certificate__AwsKms__Region", "value": "<region>" },
  { "name": "Signing__Certificate__AwsKms__KeyId", "value": "alias/<name>" },
  { "name": "Signing__Certificate__AwsKms__Credential", "value": "InstanceRole" },
  { "name": "Signing__Certificate__AwsKms__S3__Bucket", "value": "<bucket>" },
  { "name": "Signing__Certificate__AwsKms__S3__Key", "value": "signing-material/<name>.cer" },
  { "name": "Signing__Certificate__AwsKms__S3__Region", "value": "<region>" },
  { "name": "Signing__Certificate__AwsKms__S3__Credential", "value": "InstanceRole" },
  { "name": "Signer__Endpoint", "value": "https://<your Lacuna Signer address>" }
],
"secrets": [
  { "name": "ConnectionStrings__Default", "valueFrom": "<$NAME/sql-app ARN>:connectionString::" },
  { "name": "Auth__ApiKey",               "valueFrom": "<$NAME/auth-api-key ARN>" },
  { "name": "Signing__PkiSdkLicense",     "valueFrom": "<$NAME/pki-sdk-license ARN>" },
  { "name": "Signer__ApiKey",             "valueFrom": "<$NAME/signer-api-key ARN>" },
  { "name": "WebPki__License",            "valueFrom": "<$NAME/webpki-license ARN>" }
]
```

Each key is specified in the [Configuration reference](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/aws#configuration-reference). The following rules
apply to this deployment:

- **Session key ring.** `Auth__SessionKeyRing__Placement = OperationalStore` stores the ring in the
  database. Without it, the ring is written to the task's disk and every deployment ends the sessions of
  all operators and approvers. The first boot with this setting creates a new ring, which ends all
  sessions once
  ([Configuration](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/configuration#authsessionkeyring--where-the-session-key-ring-is-kept)).
- **Forwarded headers.** The load balancer's IP addresses change and cannot be listed individually, so
  the entire VPC range is trusted; the security group from step 4 ensures that only the load balancer
  reaches the port. Without these settings, Bulk Signer sees the scheme as `http`, its cookies are not
  marked secure, an approval records the load balancer's address instead of the approver's, and Entra ID
  sign-in (step 10) fails ([Configuration](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/configuration#hosting)).
- **Watched folder.** The input specifies no provider and therefore inherits S3 and the work share's
  bucket. `PollIntervalSeconds` is mandatory, because new objects in a bucket are detected only by
  listing it.
- **Signing profile seed.** `Signing:Certificate` is the seed (initial load) of the `default` signing
  profile, applied on the first boot against an empty database only. Subsequent changes are made on the
  dashboard's **Signing profiles** page. Every credential is `InstanceRole`, so no profile stores a
  secret and `Signing:ProfileSecretsKey` is not required.
- **Lacuna Signer.** The `Signer` keys are optional and must be set together: setting `Signer__Endpoint`
  makes `Signer__ApiKey` mandatory at boot. Omit both to run without Lacuna Signer. The endpoint is the
  address on which the Signer API itself responds (`GET <endpoint>/api/documents` returns `401` without
  a key), which is not necessarily the address of the product's home page. Verify the endpoint with that
  request.
- **Homologation.** For an environment that signs with Lacuna's test certificates, set
  `ASPNETCORE_ENVIRONMENT` to `Staging` and add `Signing__TrustLacunaTestRoot=true`
  ([Configuration](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/configuration#signingtrustlacunatestroot--test-certificates-for-a-homologation)).
  Neither setting is required for an ICP-Brasil production certificate.

### 8.3 Service

```bash
aws ecs create-service --region $REGION --cluster $NAME --service-name bulksigner --task-definition $NAME \
  --desired-count 1 --launch-type FARGATE \
  --deployment-configuration 'minimumHealthyPercent=0,maximumPercent=100,deploymentCircuitBreaker={enable=true,rollback=false}' \
  --network-configuration "awsvpcConfiguration={subnets=[$SUBNETS],securityGroups=[$SG_TASK],assignPublicIp=ENABLED}" \
  --load-balancers targetGroupArn=$TG,containerName=bulksigner,containerPort=8080 \
  --health-check-grace-period-seconds 300
```

| Parameter | Value | Requirement |
|---|---|---|
| `minimumHealthyPercent`, `maximumPercent` | `0`, `100` | Every deployment stops the running task before starting the new one. The ECS default starts the new task first, and two tasks on one S3 work share cannot be detected. Each deployment therefore incurs a brief outage. |
| `--desired-count` | `1` | Must not be increased, for the same reason. Auto scaling must not be attached. |
| `--health-check-grace-period-seconds` | `300` | The first boot applies every database migration and probes the bucket. The grace period prevents the load balancer from stopping the task before it reports ready. |
| `deploymentCircuitBreaker` | `enable=true`, `rollback=false` | A failed deployment stops instead of rolling back, which would start a second task. |
| `assignPublicIp` | `ENABLED` | Required for outbound calls from public subnets (see [Network placement](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/aws#network-placement)). |
| `--load-balancers` | Target group, container `bulksigner`, port `8080` | Registers the task with the target group from step 7. |

## 9. Verification

### 9.1 Readiness and profile

```bash
curl -s https://$HOST/api/ready
KEY=$(aws secretsmanager get-secret-value --region $REGION --secret-id $NAME/auth-api-key --query SecretString --output text)
curl -s -H "X-API-Key: $KEY" https://$HOST/api/ready/details
curl -s -H "X-API-Key: $KEY" https://$HOST/api/profiles
```

On a successful first boot, `/api/ready/details` reports the following checks:

| Check | Expected result |
|---|---|
| `database` | Reachable, naming the RDS host and `BulkSigner`. |
| `storage-bucket:…` | Reachable, with **every conditional request refused as expected**. This is the startup check confirming that S3 honours the conditional requests the work share depends on. |
| `input-folder:default` | Running, at `s3://…/inputs/default`. |
| `work-share-owner` | *Written at startup, not held*. This is the normal value on S3, not a fault. |
| `pki-license` | Loaded. |

`/api/profiles` lists the `default` profile with `certificateSource` = `AwsKms` and `degraded` = `false`.
This confirms that the task read the certificate from S3, accessed KMS with its role and found the key
that matches the certificate.

### 9.2 Signing test

1. Submit a PDF: `POST /api/files`, multipart, field `file`. The response is `202 Accepted` with the
   `jobId`.
2. Poll `GET /api/jobs/{id}` until `status` is `Completed`.
3. Download the signed file: `GET /api/jobs/{id}/output`.

```bash
JOB=$(curl -s -H "X-API-Key: $KEY" -F "file=@sample.pdf" https://$HOST/api/files | jq -r .jobId)
curl -s -H "X-API-Key: $KEY" https://$HOST/api/jobs/$JOB | jq -r .status
curl -s -H "X-API-Key: $KEY" -o sample.signed.pdf https://$HOST/api/jobs/$JOB/output
```

Expected evidence:

- The job history records the output as `s3://…/work/output/<name>.signed.pdf`.
- The CloudTrail event history shows the task's `Sign` call within a few minutes.

A file name that has completed is reserved. Resubmitting the same name returns `409` with
`file.already-processed` until the job is deleted on the dashboard's **Jobs** page.

### 9.3 Expected startup warnings

The following startup log warnings are expected in this topology and do not indicate a fault:

| Warning | Explanation |
|---|---|
| Work-share marker *written, not held* | S3 provides no lease. |
| Key ring stored unencrypted | The ring is stored in the database and protected by the database's access control. |
| `ValidateCertificate=false` on the seeded profile | The setting can be enabled on the profile's page. |

## 10. Microsoft Entra ID sign-in (optional)

By default, the dashboard's sign-in page requests the API key. When an `Auth:EntraId` section is
configured, the page offers **Sign in with Microsoft** instead, and two app roles control access:
`Administrator` for the operator dashboard and `Approver` for the approver pages
([Configuration](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/configuration#authentraid--optional-microsoft-entra-id-sign-in)). The REST API's
`X-API-Key` is unaffected.

### 10.1 App registration

Assign the environment **a dedicated app registration**, not one shared with another environment, so
that access is governed by its own role assignments and its client secret follows its own rotation
schedule. [`New-BulkSignerEntraApp.ps1`](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/samples#powershell-7--new-bulksignerentraappps1) creates it:

```bash
pwsh ./New-BulkSignerEntraApp.ps1 -BaseUrl https://$HOST -DisplayName "Lacuna Bulk Signer (AWS)"
```

| Parameter | Value | Description |
|---|---|---|
| `-BaseUrl` | `https://$HOST` | The public base URL; the redirect URI is derived from it. |
| `-DisplayName` | `Lacuna Bulk Signer (AWS)` | The display name of the app registration. |

The script displays the client secret once. Store it in Secrets Manager as `$NAME/entra-client-secret`
(the execution role already reads `secret:$NAME/*`) and clear the console. Assign users in the Entra
admin center under **Enterprise applications** → the application → **Users and groups**. A user without
a role assignment cannot sign in.

### 10.2 Configuration

Add the following entries to the task definition, register the new revision and update the service:

```json
{ "name": "Auth__EntraId__TenantId", "value": "<directory (tenant) ID>" },
{ "name": "Auth__EntraId__ClientId", "value": "<application (client) ID>" }
```

```json
{ "name": "Auth__EntraId__ClientSecret", "valueFrom": "<$NAME/entra-client-secret ARN>" }
```

### 10.3 Preconditions and effects

| Item | Description |
|---|---|
| Dependency on forwarded headers | The sign-in builds its redirect URI from the scheme Bulk Signer observes. Without the forwarded-header settings from step 8, it sends `http://…/signin-oidc`, which Microsoft rejects with `AADSTS50011`. |
| Session termination | Sessions created by API-key sign-in stop working as soon as the section is present. |
| Client secret expiry | The script's default lifetime is twelve months. An expired secret fails only at sign-in, with an `AADSTS` error. Rotate it into the same secret and force a new deployment. |

### 10.4 Verification

Follow the first redirect without signing in:

```bash
curl -s -o /dev/null -w '%{redirect_url}\n' https://$HOST/api/auth/entra-login
```

The URL must point to `login.microsoftonline.com/<tenant>/oauth2/v2.0/authorize`, with the application's
client ID and `redirect_uri=https://<host>/signin-oidc`. The **`https`** scheme in `redirect_uri` is the
value to confirm.

## 11. Operations

| Operation | Procedure | Notes |
|---|---|---|
| Upgrade | Push a new image tag (step 3), register a task definition revision that references it, and run `aws ecs update-service --cluster $NAME --service bulksigner --task-definition $NAME`. | The running task stops before the new task starts. Database migrations run on the new task's boot. |
| Secret rotation | Update the secret, then run `aws ecs update-service … --force-new-deployment`. | A rotated secret reaches the container only when the task is replaced. |
| Database password rotation | Run `ALTER LOGIN` on the server, then update `$NAME/sql-app`, then force a new deployment. | The two changes must be made in that order. |
| Certificate renewal | Run the import script with the new PFX and a new alias, upload the new `.cer`, set `KeyId` to the new alias, and restart. | Certificate changes take effect at restart. |
| Pause | Run `aws ecs update-service … --desired-count 0` and `aws rds stop-db-instance`. | RDS restarts a stopped instance automatically after seven days. The load balancer and the public IPv4 address continue to be billed. |
| Decommission | Delete the resources in the order listed below. | |

Decommission order:

1. The service (scaled to 0, then deleted).
2. The cluster.
3. The listeners, the load balancer and the target group.
4. The DNS records.
5. The ACM certificate.
6. The RDS instance.
7. The security groups.
8. The IAM roles.
9. The secrets.
10. The bucket (emptied first).
11. The ECR repository.
12. The KMS key, last, with `schedule-key-deletion` and a waiting period of 7 to 30 days.

Signatures already produced remain verifiable after the key is deleted, because verification uses the
certificate's public key. Deleting the key removes the ability to sign with it, unless the PFX backup is
imported again.

## Configuration reference

All keys are set as environment variables in the task definition (step 8). A `__` in an environment
variable name corresponds to `:` in the configuration key.

### Core settings

| Environment variable | Value in this deployment | Required | Description |
|---|---|---|---|
| `ASPNETCORE_ENVIRONMENT` | `Production` (`Staging` for homologation) | Yes | The hosting environment. |
| `Database__Provider` | `SqlServer` | Yes | The operational store provider. |
| `ConnectionStrings__Default` | Secret `$NAME/sql-app`, key `connectionString` | Yes | The SQL Server connection string (step 5.2). |
| `Auth__ApiKey` | Secret `$NAME/auth-api-key` | Yes | The REST API's `X-API-Key`; also the dashboard sign-in key when Entra ID is not configured. |
| `Auth__SessionKeyRing__Placement` | `OperationalStore` | Yes, for this topology | Stores the session key ring in the database so that sessions survive deployments. |
| `Hosting__ForwardedHeaders__Enabled` | `true` | Yes | Applies the load balancer's forwarded headers. |
| `Hosting__ForwardedHeaders__KnownNetworks__0` | `$VPC_CIDR` | Yes | The network from which forwarded headers are trusted. |
| `Signing__PkiSdkLicense` | Secret `$NAME/pki-sdk-license` | Yes | The PKI SDK licence. |
| `WebPki__License` | Secret `$NAME/webpki-license` | If Web PKI is used | The Web PKI licence. |

### Work share and watched folder

| Environment variable | Value in this deployment | Required | Description |
|---|---|---|---|
| `Storage__Provider` | `S3` | Yes | Places the work share in S3. |
| `Storage__S3__BucketName` | `$BUCKET` | Yes | The bucket. |
| `Storage__S3__Prefix` | `work` | Yes | The work share's prefix. Must not contain a watched folder. |
| `Storage__S3__Region` | `$REGION` | Yes | The bucket's Region. |
| `Storage__S3__Credential` | `InstanceRole` | Yes | Authenticates with the task role. |
| `Storage__Inputs__0__Name` | `default` | Yes | The watched folder's name. |
| `Storage__Inputs__0__Path` | `inputs/default` | Yes | The folder's prefix in the bucket. The marker object must exist (step 1). |
| `Storage__Inputs__0__PollIntervalSeconds` | `30` | Yes, on S3 | The interval between listings of the folder. |

### Signing certificate (seed of the `default` profile)

| Environment variable | Value in this deployment | Required | Description |
|---|---|---|---|
| `Signing__Certificate__Source` | `AwsKms` | Yes | The certificate source. |
| `Signing__Certificate__AwsKms__Region` | `$REGION` | Yes | The key's Region. |
| `Signing__Certificate__AwsKms__KeyId` | `alias/$NAME` | Yes | The key, by alias (step 2). |
| `Signing__Certificate__AwsKms__Credential` | `InstanceRole` | Yes | Authenticates to KMS with the task role. |
| `Signing__Certificate__AwsKms__S3__Bucket` | `$BUCKET` | Yes | The bucket holding the certificate. |
| `Signing__Certificate__AwsKms__S3__Key` | `signing-material/$NAME.cer` | Yes | The certificate's object key. |
| `Signing__Certificate__AwsKms__S3__Region` | `$REGION` | Yes | The certificate bucket's Region. |
| `Signing__Certificate__AwsKms__S3__Credential` | `InstanceRole` | Yes | Authenticates to S3 with the task role. |
| `Signing__TrustLacunaTestRoot` | `true` | Homologation only | Trusts Lacuna's test root, for test certificates. |

These keys are read on the first boot against an empty database only. Afterwards, the profile is edited
on the dashboard.

### Lacuna Signer (optional)

| Environment variable | Value in this deployment | Required | Description |
|---|---|---|---|
| `Signer__Endpoint` | `https://<Lacuna Signer address>` | No | The address of the Signer API. |
| `Signer__ApiKey` | Secret `$NAME/signer-api-key` | Yes, when `Signer__Endpoint` is set | The Signer API key, `<application>\|<secret>`. Its absence stops the task at boot. |

### Microsoft Entra ID (optional)

| Environment variable | Value in this deployment | Required | Description |
|---|---|---|---|
| `Auth__EntraId__TenantId` | The directory (tenant) ID | For Entra ID sign-in | The tenant. |
| `Auth__EntraId__ClientId` | The application (client) ID | For Entra ID sign-in | The app registration. |
| `Auth__EntraId__ClientSecret` | Secret `$NAME/entra-client-secret` | For Entra ID sign-in | The client secret. |

### Settings that must not be used

| Setting | Reason |
|---|---|
| `Cluster:Enabled` | Refused with an S3 work share. |
| `Backup:Enabled` | Refused with `Database:Provider = SqlServer`; RDS automated backups replace it. |

## Interface contracts

### HTTP endpoints

The endpoints used on this page. Authenticated requests send `X-API-Key: <Auth:ApiKey>`. Error responses
are `ProblemDetails` bodies (RFC 9457) with a stable `code` field; clients must dispatch on `code`, not on
`title` ([REST API](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/rest-api#error-envelope)).

| Method | Route | Authentication | Response | Used by |
|---|---|---|---|---|
| `GET` | `/api/ready` | Anonymous (unless `Readiness:RequireApiKey = true`) | `200`, or `503` when a gating check fails. Body: `{ ready, checks: [{ name, ok }] }`, without detail. | The target group health check (step 7); verification (step 9). |
| `GET` | `/api/ready/details` | API key | The same report with each check's `detail`, and the same `200` / `503` rule. | Verification (step 9). |
| `GET` | `/api/profiles` | API key | `200`, listing every signing profile, including `certificateSource` and `degraded`. | Verification (step 9). |
| `POST` | `/api/files` | API key | `202 Accepted` with `jobId`, `fileName`, `format` and `status` (`Queued`). | Signing test (step 9). |
| `GET` | `/api/jobs/{id}` | API key | `200` with the job, including `status` and a `history` array. | Signing test (step 9). |
| `GET` | `/api/jobs/{id}/output` | API key | The signed file. `409 job.output-unavailable` if the job is not `Completed`; `410 job.output-gone` if the file has left `output/`. | Signing test (step 9). |
| `GET` | `/api/auth/entra-login` | Anonymous | A redirect to the Microsoft sign-in endpoint. | Entra ID verification (step 10). |
| `GET` | `/api/metrics` | API key (when `Metrics:RequireApiKey = true`, the default) | Prometheus exposition format. | Not configured on this page (see [Limitations](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/aws#limitations)). |

The complete API is specified in [REST API](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/rest-api).

### External endpoint

| Method | Route | Expected response | Purpose |
|---|---|---|---|
| `GET` | `<Signer__Endpoint>/api/documents` | `401` without a key | Confirms that `Signer__Endpoint` is the address of the Signer API. |

### AWS calls made by the task

| Service | Action | Resource | When called |
|---|---|---|---|
| AWS KMS | `kms:DescribeKey`, `kms:GetPublicKey` | The signing key | Once, at boot, when the certificate is opened. |
| AWS KMS | `kms:Sign` | The signing key | Once per signed file. |
| Amazon S3 | `s3:GetObject` | `signing-material/<name>.cer` | Once, at boot. |
| Amazon S3 | `s3:ListBucket` | The bucket | Every `PollIntervalSeconds`, to detect files in the watched folder; also by the startup checks. |
| Amazon S3 | `s3:GetObject`, `s3:PutObject`, `s3:DeleteObject`, `s3:AbortMultipartUpload` | Objects in the bucket | During job processing. A move between prefixes is a copy followed by a delete. |

KMS request quotas are shared by every caller in the account and Region.

## Error reference

### Error codes

| Code | Origin | HTTP status | Meaning | Resolution |
|---|---|---|---|---|
| `file.already-processed` | Bulk Signer API | `409` | A `Completed` or active job already holds this file name. | Delete the job on the **Jobs** page, or rename the file. |
| `job.output-unavailable` | Bulk Signer API | `409` | The job is not `Completed`. | Poll `GET /api/jobs/{id}` until `Completed`. |
| `job.output-gone` | Bulk Signer API | `410` | The job is `Completed`, but its file is no longer in `output/`. | — |
| `job.staged-copy-changed` | Bulk Signer job | — (recorded on the job) | The staged copy in the bucket was modified between staging and signing. Nothing was signed. | Ensure that only Bulk Signer writes under `work/`. |
| `ThrottlingException`, `KeyUnavailableException` | AWS KMS | — (recorded on the job) | The KMS request quota was exceeded, or the key was unavailable. | See [Troubleshooting](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/troubleshooting#aws-kms-jobs-fail-with-throttlingexception-or-keyunavailableexception). |
| `AADSTS50011` | Microsoft Entra ID | — | The redirect URI does not match the app registration; it is `http://`. | Configure the forwarded-header settings from step 8 with the correct range. |
| `AADSTS` error at sign-in, after a period of normal operation | Microsoft Entra ID | — | The client secret has expired. | Rotate the secret into `$NAME/entra-client-secret` and force a new deployment. |
| *The certificate chain was issued by an authority that is not trusted* | SQL Server client | — | The RDS server certificate is not trusted by the image. | Add `TrustServerCertificate=True` to the connection string (step 5.2). |
| `error storing credentials … The stub received bad data` | Docker on Windows | — | Windows Credential Manager rejects the ECR token's length. | Use a temporary `--config` directory (step 3). |

The complete list of API error codes is in [REST API](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/rest-api#error-envelope).

### Symptoms

| Symptom | Cause | Resolution |
|---|---|---|
| The ACM certificate remains in *Pending validation*. | The validation CNAME was written to a zone that is not authoritative for the domain. | Check the public NS records and write the record to the authoritative zone. |
| The task stops at boot and its log names `Signer:ApiKey`. | `Signer__Endpoint` is set without `Signer__ApiKey`. | Set both keys or neither. |
| `/api/ready` returns `503` and the `database` check is failing. | The task is running, but the schema was not applied. If the detail names an untrusted certificate chain, the connection string lacks `TrustServerCertificate=True`. Otherwise, the database does not exist yet, or the database security group does not admit the task's group. | Correct the connection string (step 5.2), run the setup task (step 5.3), or correct the security group (step 4). |
| `input-folder:default` is not running. | The `inputs/default/` marker object is missing, or the task role lacks `s3:ListBucket` on the bucket. | Create the marker (step 1) or correct the task role (step 6). See [Troubleshooting](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/troubleshooting#a-blob-or-s3-input-folder-reads-as-missing). |
| The `default` profile is degraded. | The certificate object in S3 cannot be read, KMS refused the task role, or the `.cer` does not match the KMS key. | Confirm that the task role's ARN is exempted from the key policy's deny. The reason is shown on `/api/ready/details` and on the profile's page; see [Troubleshooting](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/troubleshooting#an-awskms-profile-is-degraded-at-startup). |
| All users are signed out after each deployment. | `Auth__SessionKeyRing__Placement` is not `OperationalStore`. | Set the key (step 8). |

## Limitations

| Not provided | Alternative |
|---|---|
| More than one instance | [Azure App Service (cluster mode)](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/azure). |
| Verification of the database server certificate | None in this topology; see `TrustServerCertificate=True` in step 5.2. |
| Metrics in CloudWatch | `/api/metrics` serves Prometheus text behind the API key. Amazon Managed Service for Prometheus or the CloudWatch agent can collect it; neither is configured on this page. |
| Backups made by Bulk Signer | RDS automated backups. |
