---
title: "Signing Lacuna Signer documents on Amazon ECS Fargate with CloudFormation"
description: "Procedure for deploying Bulk Signer on Amazon ECS Fargate with RDS for SQL Server, S3 and AWS KMS, the AWS infrastructure created by one CloudFormation stack, and for signing documents created in a Lacuna Signer folder with the SignerFolder method and the Signer:WebhookSecret webhook, with the template parameters, interface contracts and error codes involved."
canonical: "https://docs.lacunasoftware.com/en-us/articles/bulk-signer/aws-signer-folder-cloudformation"
locale: "en"
product: "Bulk Signer"
last_updated: "2026-11-11"
---

# Signing Lacuna Signer documents on Amazon ECS Fargate with CloudFormation

This page specifies a single-instance deployment of Bulk Signer on Amazon Web Services that signs
documents created in **Lacuna Signer**. A user or the API creates a document in a Signer folder bound to a Bulk
Signer signing profile; Bulk Signer signs it with the organization's certificate, whose private key is
held in **AWS KMS**, and Signer stores the signed document. 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 the signing certificate in **one Amazon S3 bucket**;
- the signing key in **AWS KMS**;
- a signing profile that uses the
  [`SignerFolder` method](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/lacuna-signer#signing-in-a-lacuna-signer-folder--signerfolder);
- the `Signer:WebhookSecret` webhook, through which Signer notifies Bulk Signer of new documents.

The infrastructure is the one specified in [Amazon ECS Fargate](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/aws). On this page a **CloudFormation
template** creates all of it in one stack ([step 5](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/aws-signer-folder-cloudformation#stack)), on an empty AWS account. Three things stay
outside the stack and are prepared before it: the signing key, imported from the PFX into KMS
([step 2](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/aws-signer-folder-cloudformation#signing-key)); the container image, copied into ECR ([step 3](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/aws-signer-folder-cloudformation#image)); and the secrets whose
values a person types ([step 4](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/aws-signer-folder-cloudformation#secrets)). After the stack come the DNS record and the Lacuna Signer
side: the profile, the webhook and a test ([steps 6 to 10](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/aws-signer-folder-cloudformation#dns)).

The same deployment, built command by command with the AWS CLI instead of a template, is specified in
[Amazon ECS Fargate + Lacuna Signer](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/aws-signer-folder). Both pages produce the same resources and the
same configuration; this one leaves fewer commands to run.

An environment already deployed by hand from [Amazon ECS Fargate](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/aws) is not taken over by the
template. To add the Lacuna Signer integration to it, follow [step 1](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/aws-signer-folder-cloudformation#prepare-signer), create the two
Lacuna Signer secrets of [step 4](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/aws-signer-folder-cloudformation#secrets), add the `Signer` settings of
[Bulk Signer configuration](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/aws-signer-folder-cloudformation#bulk-signer-configuration) to a new task definition revision, deploy it, and
follow [steps 8 to 10](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/aws-signer-folder-cloudformation#profile).

The procedure was validated in a homologation environment: a PDF created in Lacuna Signer was signed by
the Fargate task with an e-CNPJ certificate whose key was held in AWS KMS, in a job with a total
processing time of 1.2 seconds. On 2026-11-11 the template was deployed in `sa-east-1` with the commands
of steps 5 and 7, on a Windows workstation with Git Bash: the stack was complete 18 minutes after
`create-stack`, every check of step 7 passed on the first attempt, an update with
`aws cloudformation deploy` took three minutes, and the stack was then deleted.

> **Info: Minimum version: 2.20.0**
>
> The `SignerFolder` method and the `Signer:WebhookSecret` key were introduced in 2.16.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. The ECS service the stack creates 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

[![Signing in a Lacuna Signer folder on AWS: a user or the API creates a document at Signer, Signer notifies the Fargate task through the load balancer, the task signs with the key in AWS KMS and hands the signature back to Signer](https://docs.lacunasoftware.com/images/bulk-signer/aws-signer-folder-architecture.svg)](https://docs.lacunasoftware.com/images/bulk-signer/aws-signer-folder-architecture.svg)

### Connections

Compared with a deployment that signs files only, three connections are added. None of them is an AWS
resource.

| Direction | Content | Credential |
|---|---|---|
| Signer → load balancer → task | The webhook: `POST https://<host>/api/signer/notifications` | `Signer:WebhookSecret`, sent as `Authorization: Bearer` |
| Task → Signer | The sweep, the read-back of each document, and the start and completion of each signature | `Signer:ApiKey`, sent as `X-Api-Key` |
| Task → KMS | `kms:Sign`, one call per signed document | The task role |

### Differences from file signing

| Aspect | Behaviour with the `SignerFolder` method |
|---|---|
| Document content | No file passes through Bulk Signer. Signer sends the hash to sign, the task signs it in KMS, and Signer builds the signature into the document and stores it. Nothing enters a watched folder and nothing is written to `work/output/`. The signed document is downloaded from Signer. |
| Identification of the holder | Signer matches the participant of the document to the CPF contained in the profile's certificate. The certificate must therefore be an ICP-Brasil **e-CPF** or **e-CNPJ** (which contains the CPF of its responsible person). |
| Signature format | Signer selects the format: PAdES for a PDF, XAdES for XML, CAdES for any other type. The profile's own format setting is ignored. |

## Function of `Signer:WebhookSecret` in performance

### Discovery mechanisms

Bulk Signer has two mechanisms for detecting a document that awaits its signature:

| Mechanism | Operation | Availability |
|---|---|---|
| Sweep | Every `Signer:FolderSweepIntervalSeconds` (default: 300 seconds), the task lists the pending documents of every bound folder and reads each one back from Signer. | Always active. Requires only `Signer:ApiKey`. |
| Notification | Signer calls `POST /api/signer/notifications` when a document is created, signed or approved. The task queues a check for the document named in the call, responds immediately, and reads that document back. | Only when `Signer:WebhookSecret` is set. Without it, the route returns `404`. |

[![How long a document waits: with Signer it is signed seconds after it is created; with the sweep alone it waits for the next pass, up to five minutes by default](https://docs.lacunasoftware.com/images/bulk-signer/aws-signer-folder-webhook-vs-sweep.svg)](https://docs.lacunasoftware.com/images/bulk-signer/aws-signer-folder-webhook-vs-sweep.svg)

### Effect of the setting

| Aspect | Sweep only | With `Signer:WebhookSecret` |
|---|---|---|
| Time until a document is detected | Up to the sweep interval: 0 to 5 minutes by default, 2.5 minutes on average. | Seconds. |
| Calls to the Signer API | On every pass, one listing per bound folder plus one read per pending document in it, regardless of whether anything changed. | One read per notified document. |
| Flows with several participants | Each hand-over to the holder waits for a sweep. | The notification of the step preceding the holder's is the event through which the holder's turn is detected, in seconds. |

The detection time can also be reduced, without the webhook, by lowering
`Signer:FolderSweepIntervalSeconds`. Its minimum is 60 seconds, and every pass repeats the same listing
and the same reads. At the minimum, the number of calls to Signer is five times that of the default, and
a document still waits up to one minute. With the webhook, the number of calls is proportional to the
number of events, and the sweep interval can remain at the default or be increased.

### Scope

| Property | Specification |
|---|---|
| Signature duration | Not affected. The setting changes the time until a document is detected, not the time required to sign it. In the homologation environment, a job took 1.2 seconds, queue wait included. Throughput is determined by [`Pipeline:MaxConcurrency`](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/configuration) and by the [KMS quota](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/certificates#quota-and-throttling). |
| Delivery guarantee | Provided by the sweep, not by the notification. If Signer cannot reach the load balancer, or a delivery is lost, the document is signed at the next sweep. A document is delayed, never lost. |
| Trust in the notification | None. A notification identifies which document to read; no other content of it is used. The document is read back from Signer with `Signer:ApiKey` before a job is created. |

### Security properties

The route is public, and the secret is the only element that distinguishes a call from Signer from any
other call.

| Property | Specification |
|---|---|
| Minimum length | 32 characters. Leading or trailing whitespace is refused at boot. |
| Comparison | In fixed time. |
| Call without the secret | `401`. |
| Effect of a forged call that presents the secret | At most, the task reads one document from Signer. |

## Prerequisites

| Requirement | Specification |
|---|---|
| Tools | The **AWS CLI v2**, authenticated (`aws sts get-caller-identity` must succeed), and a **bash** shell for the commands on this page (on Windows, Git Bash from Git for Windows). **Docker**, to copy the image (step 3). **PowerShell 7** and **OpenSSL 3.x** (included with Git for Windows), for the key import (step 2). **`jq`**, which ships with neither Windows nor Git for Windows (`winget install jqlang.jq`; `brew install jq` on macOS; `apt install jq` on Linux), to read the responses in steps 7 to 10 and to push the image from Docker Desktop for Windows. |
| The template | [`bulksigner-ecs-fargate.yaml`](https://docs.lacunasoftware.com/content/bulk-signer/bulksigner-ecs-fargate.yaml), saved in the working directory. It is also in the product repository, under `docs/samples/`. |
| IAM permissions | Permission to create a CloudFormation stack with named IAM resources (`CAPABILITY_NAMED_IAM`) and the resources it creates: S3, EC2 security groups, RDS, Secrets Manager, IAM (`iam:CreateRole`, `iam:PutRolePolicy`, `iam:PassRole`), ACM, Elastic Load Balancing, CloudWatch Logs, ECS and Lambda; plus KMS for step 2 and ECR for step 3. 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). |
| Licence | The **PKI SDK licence**. |
| DNS | A **hostname** that Signer can reach over the internet, and write access to the DNS zone that is authoritative for it. Confirm the delegation with `dig +short NS example.com`: a domain can have a Route 53 hosted zone in the account while being delegated to another provider. |
| Lacuna Signer organization | An **organization** on a Lacuna Signer instance, and a user with the organization administrator role. That role is required to register the webhook. |
| Lacuna Signer API key | An **API key** of that organization (step 1.2). |
| Signing material | The **PFX** file of an ICP-Brasil **e-CPF** or **e-CNPJ** certificate, with an RSA 2048, 3072 or 4096 key, and its password. A certificate that contains no CPF is refused when the profile is created. |
| Holder identification | The **CPF** contained in the certificate and an **e-mail address** of the holder. Documents must name the holder as a participant with exactly that CPF. |
| Microsoft Entra ID (optional) | Only for operator sign-in with a Microsoft account ([step 4.2](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/aws-signer-folder-cloudformation#entra-id)): a tenant, the `Microsoft.Graph.Applications` PowerShell module, and an account with the **Cloud Application Administrator** role or higher. |

### Shell variables

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

```bash
export MSYS_NO_PATHCONV=1             # Git Bash only; see the note below
REGION=sa-east-1
ACCOUNT=$(aws sts get-caller-identity --query Account --output text)
NAME=bulksigner                       # the stack name and the prefix of every resource
BUCKET=$NAME-$ACCOUNT                 # created by the stack; bucket names are global
HOST=bulksigner.example.com           # the public hostname
SIGNER=https://<Lacuna Signer address>
PROFILE=aws-signer                    # the profile that signs in the Signer folder
```

| Variable | Content |
|---|---|
| `REGION` | The AWS Region of every resource. |
| `ACCOUNT` | The AWS account ID of the authenticated identity. |
| `NAME` | The stack name, and the prefix of every resource name. From 2 to 21 lowercase letters, digits and hyphens. |
| `BUCKET` | The name of the bucket the stack creates. It includes the account ID because S3 bucket names are globally unique. |
| `HOST` | The public hostname of the deployment. Signer calls it. |
| `SIGNER` | The address of the Lacuna Signer API (step 1.1), without a trailing slash. |
| `PROFILE` | The name of the signing profile bound to the Signer folder. It must not be `default`. |
| `MSYS_NO_PATHCONV` | Turns off Git Bash's path conversion. Without it, an argument that starts with `/` is rewritten as a Windows path before it reaches the AWS CLI: `aws logs tail /ecs/$NAME` reads a log group named `C:/Program Files/Git/ecs/…`. In other shells the line has no effect. |

### Network placement

The stack uses the default VPC of the Region and its default subnets, one per Availability Zone. 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. In private subnets, the task
requires a **NAT gateway**: Lacuna Signer is not an AWS service and cannot be reached through a VPC
endpoint. The template does not support private subnets.

## 1. Lacuna Signer preparation

The following four items are defined in Lacuna Signer before any AWS resource is created.

### 1.1 API address

`SIGNER` is the address on which the Signer instance's API responds, which is not necessarily the address
of the product's home page. Without a key, the API returns `401`:

```bash
curl -s -o /dev/null -w '%{http_code}\n' $SIGNER/api/documents      # 401
```

### 1.2 API key

Create an application in the organization and generate its key, as specified in
[step 1 of Getting started with the Signer API](https://docs.lacunasoftware.com/en-us/articles/signer/get-started#step-1-create-the-application-and-the-api-key).

| Property | Specification |
|---|---|
| Access to the **Applications** menu | Restricted to the instance's system administrator. Without that access, request a key of the organization from the administrator of the Signer instance. |
| Format | `<application>\|<secret>`. The application part may contain spaces. |
| Display | The key is displayed once, when it is created. |
| Scope | The organization in which it was created. The folder selector in step 8 lists only that organization's folders, and the task reads documents with this key. |
| Required access | A role that grants access to the folders and documents of the organization. |

### 1.3 Folder

In **Folders**, select the organization and create a folder with the **New folder** icon.

| Rule | Specification |
|---|---|
| Binding | One-to-one. Two profiles cannot share a folder. |
| Scope | The folder itself. A document in one of its subfolders is not signed. |

The screenshots on this page use a folder named `AWSTest`.

### 1.4 PDF signature policy

Whether a PDF signed with an ICP-Brasil certificate receives the ICP-Brasil policy (AD-RB) or a basic
PAdES signature is determined by Signer's `UseBrazilianPdfSigningPolicies` setting, not by Bulk Signer.
The setting belongs to the Signer instance:

| Signer instance | Where the setting is defined |
|---|---|
| On-premises | In the instance's [settings](https://docs.lacunasoftware.com/en-us/articles/signer/on-premises/settings). |
| Lacuna's cloud | By Lacuna. Request confirmation of the value in effect. |

Define the policy before the first document is signed.

## 2. The signing key and the certificate

A PFX file contains two items with different destinations:

| Item | Destination | Property |
|---|---|---|
| Private key | AWS KMS | KMS never returns it. |
| Certificate | A `.cer` file, which the stack checks against the key and uploads to the S3 bucket | Public material. |

When the profile is opened, Bulk Signer reads both and refuses the pair if the public keys differ.

[![Importing a PFX for the CloudFormation stack: the private key is wrapped on your workstation and imported into an AWS KMS key, the public certificate is passed to the stack, which checks it against the key and uploads it to S3, and the Fargate task checks one against the other](https://docs.lacunasoftware.com/images/bulk-signer/aws-signer-folder-key-import-cloudformation.svg)](https://docs.lacunasoftware.com/images/bulk-signer/aws-signer-folder-key-import-cloudformation.svg)

### 2.1 Import with the script

[`Import-PfxToKms.ps1`](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/samples#powershell-7--import-pfxtokmsps1) performs the complete import and
prompts for the PFX password. The bucket does not exist yet, so the certificate is written to a file:

```bash
pwsh ./Import-PfxToKms.ps1 -PfxPath ./signer.pfx -Region $REGION -Alias $NAME \
  -CerPath ./signer.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. Steps 5 and 8 reference it as `alias/<name>`. The script stops if the alias already exists. |
| `-CerPath` | `./signer.cer` | The certificate, which step 5 passes to the stack. |
| `-SignerPrincipalArn` | The task role ARN | The principal exempted from the key policy's `kms:Sign` deny. The stack creates the role, with exactly this name, in step 5; 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.

### 2.2 Manual import

The following procedure is equivalent to the script and exposes each call. Run it in an empty directory,
on a machine trusted to hold the PFX.

**Import with the AWS CLI and OpenSSL**

**1. Extract the certificate and the private key.** Each command prompts for the PFX password. A PFX
protected with legacy algorithms requires `-legacy` on both commands.

```bash
openssl pkcs12 -in signer.pfx -clcerts -nokeys | openssl x509 -outform DER -out signer.cer
openssl pkcs12 -in signer.pfx -nocerts -nodes | openssl pkcs8 -topk8 -nocrypt -outform DER -out private-key.der
openssl x509 -inform DER -in signer.cer -noout -text | grep 'Public-Key'       # 2048, 3072 or 4096
```

`signer.cer` is public material. `private-key.der` is the private key in plaintext and exists until
item 7.

**2. Create a KMS key without key material.** `--origin EXTERNAL` creates a key that awaits an import.
The key spec must match the size reported in item 1. The policy is the default KMS key policy plus the
deny that reserves `kms:Sign` for the task role:

```bash
cat > key-policy.json <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    { "Sid": "EnableIamUserPermissions", "Effect": "Allow",
      "Principal": { "AWS": "arn:aws:iam::$ACCOUNT:root" }, "Action": "kms:*", "Resource": "*" },
    { "Sid": "OnlyNamedPrincipalsMaySign", "Effect": "Deny",
      "Principal": "*", "Action": "kms:Sign", "Resource": "*",
      "Condition": { "StringNotEquals": { "aws:PrincipalArn": ["arn:aws:iam::$ACCOUNT:role/$NAME-task"] } } }
  ]
}
EOF
KEY_ID=$(aws kms create-key --region $REGION --origin EXTERNAL --key-spec RSA_2048 --key-usage SIGN_VERIFY \
  --description "Bulk Signer signing key" --policy file://key-policy.json --query KeyMetadata.KeyId --output text)
aws kms create-alias --region $REGION --alias-name alias/$NAME --target-key-id $KEY_ID
```

**3. Obtain the import parameters.** KMS returns a single-use RSA public key (the wrapping key) and an
import token, both valid for 24 hours:

```bash
aws kms get-parameters-for-import --region $REGION --key-id $KEY_ID \
  --wrapping-algorithm RSA_AES_KEY_WRAP_SHA_256 --wrapping-key-spec RSA_4096 > import-params.json
jq -r .PublicKey   import-params.json | base64 -d > wrapping-public-key.der
jq -r .ImportToken import-params.json | base64 -d > import-token.bin
```

**4. Wrap the private key.** An RSA private key exceeds the size that can be encrypted directly under the
wrapping key, so KMS requires a hybrid algorithm, `RSA_AES_KEY_WRAP_SHA_256`. The private key is wrapped
under a random AES-256 key, the AES key is encrypted under the wrapping key, and the import file is the
concatenation of the two, with the encrypted AES key first.

```bash
openssl rand -out aes-key.bin 32
openssl enc -id-aes256-wrap-pad -K "$(xxd -p -c 64 aes-key.bin)" -iv A65959A6 \
  -in private-key.der -out key-material-wrapped.bin
openssl pkeyutl -encrypt -in aes-key.bin -out aes-key-wrapped.bin -inkey wrapping-public-key.der -keyform DER -pubin \
  -pkeyopt rsa_padding_mode:oaep -pkeyopt rsa_oaep_md:sha256 -pkeyopt rsa_mgf1_md:sha256
cat aes-key-wrapped.bin key-material-wrapped.bin > encrypted-key-material.bin
```

**5. Import the key material.**

```bash
aws kms import-key-material --region $REGION --key-id $KEY_ID \
  --encrypted-key-material fileb://encrypted-key-material.bin --import-token fileb://import-token.bin \
  --expiration-model KEY_MATERIAL_DOES_NOT_EXPIRE
```

**6. Compare the key with the certificate.** The two output lines must be identical. This is the
comparison Bulk Signer performs when the profile is opened:

```bash
aws kms get-public-key --region $REGION --key-id $KEY_ID --query PublicKey --output text | base64 -d \
  | openssl rsa -pubin -inform DER -noout -modulus
openssl x509 -inform DER -in signer.cer -noout -modulus
```

**7. Remove the private key.** Keep `signer.cer` for step 5.

```bash
rm -f private-key.der aes-key.bin key-material-wrapped.bin aes-key-wrapped.bin encrypted-key-material.bin import-token.bin
```

`rm` removes the directory entries only. The script overwrites each file with zeros before deleting it.

### 2.3 Verification

```bash
aws kms describe-key --region $REGION --key-id alias/$NAME \
  --query 'KeyMetadata.{State:KeyState,Usage:KeyUsage,Spec:KeySpec,Origin:Origin}'
```

| Item | Expected value |
|---|---|
| `State` | `Enabled` |
| `Usage` | `SIGN_VERIFY` |
| `Spec` | `RSA_2048`, `RSA_3072` or `RSA_4096`, according to the key |
| `Origin` | `EXTERNAL` |
| `signer.cer` | Exists in the working directory. The stack compares it with the key again in step 5. |

> **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 is documented in [Certificates](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/certificates#aws-setup).

## 3. The image

Copy the tag Lacuna specified for installation into an ECR repository in the account. Along with the
registry credentials, Lacuna provides three values:

| Variable | Content |
|---|---|
| `REGISTRY` | The registry host only, for example `<name>.azurecr.io`. It is the argument of `docker login`, which does not accept a path. |
| `REPOSITORY` | The image's path inside the registry, for example `lacuna/bulksigner`. |
| `VERSION` | The version to install, for example `2.20.0`. Use an explicit version, not `latest`. |

```bash
REGISTRY=<Lacuna registry host>
REPOSITORY=<image path in the registry>
VERSION=<version>
IMAGE=$ACCOUNT.dkr.ecr.$REGION.amazonaws.com/bulksigner:$VERSION

aws ecr create-repository --repository-name bulksigner --region $REGION \
  --image-tag-mutability IMMUTABLE --image-scanning-configuration scanOnPush=true

docker login $REGISTRY --username <registry-username>     # prompts for the token
docker pull --platform linux/amd64 $REGISTRY/$REPOSITORY:$VERSION
docker tag $REGISTRY/$REPOSITORY:$VERSION $IMAGE
```

Push the image to ECR. On Linux and macOS:

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

**On Docker Desktop for Windows**, `docker login` to ECR fails with
`error storing credentials … The stub received bad data`: the ECR token exceeds the size Windows
Credential Manager accepts. An empty `--config` directory does not help, because Docker Desktop keeps
using Credential Manager. Write the token straight into the `config.json` of a temporary directory and
push with it:

```bash
mkdir -p .docker-ecr
aws ecr get-authorization-token --region $REGION --query 'authorizationData[0].authorizationToken' --output text \
  | jq -R --arg r "$ACCOUNT.dkr.ecr.$REGION.amazonaws.com" '{auths: {($r): {auth: .}}}' > .docker-ecr/config.json
docker --config .docker-ecr push $IMAGE
rm -rf .docker-ecr
```

The directory holds an ECR access token valid for 12 hours; the `rm` removes it. Confirm the push:

```bash
aws ecr describe-images --region $REGION --repository-name bulksigner --query 'imageDetails[].imageTags' --output text
```

## 4. Secrets entered by hand

### 4.1 Secrets

The deployment reads six secrets. The stack generates three of them; the values of the other three are
issued outside AWS and are entered by hand before the stack is created. The template receives only
their ARNs, never their values.

| Secret | Content | Created by | Injected as |
|---|---|---|---|
| `$NAME/sql-app` | JSON with `username`, `password` and `connectionString` | The stack | `ConnectionStrings__Default` (key `connectionString`) |
| `$NAME/auth-api-key` | A random 48-character key: the REST API's `X-API-Key` and the dashboard's sign-in key | The stack | `Auth__ApiKey` |
| `$NAME/signer-webhook-secret` | The token Signer presents on a notification: 48 characters, without punctuation | The stack | `Signer__WebhookSecret` |
| `$NAME/pki-sdk-license` | The PKI SDK licence | By hand, below | `Signing__PkiSdkLicense` |
| `$NAME/signer-api-key` | The Lacuna Signer API key from step 1.2, in the format `<application>\|<secret>` | By hand, below | `Signer__ApiKey` |
| `$NAME/entra-client-secret` | The client secret of the Microsoft Entra ID app registration (optional) | By hand, step 4.2 | `Auth__EntraId__ClientSecret` |

Create the two required secrets from a prompt that does not echo input, and keep their ARNs for step 5.
Paste the Signer API key exactly as Signer displayed it, including any spaces in the application part:

```bash
read -rsp 'PKI SDK licence: ' VALUE; echo
PKI_ARN=$(aws secretsmanager create-secret --region $REGION --name $NAME/pki-sdk-license \
  --secret-string "$VALUE" --query ARN --output text)
read -rsp 'Lacuna Signer API key: ' VALUE; echo
SIGNER_KEY_ARN=$(aws secretsmanager create-secret --region $REGION --name $NAME/signer-api-key \
  --secret-string "$VALUE" --query ARN --output text)
unset VALUE
```

The secrets can also be created in the console (**Other type of secret → Plaintext**, containing the bare
value only); then copy each ARN into `PKI_ARN` and `SIGNER_KEY_ARN`.

### 4.2 Microsoft Entra ID sign-in (optional)

By default the dashboard's sign-in page asks for the `$NAME/auth-api-key` key. With an `Auth:EntraId`
section it 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` does not change. To sign in to the dashboard with the API key, skip this step.

> **Warning: Configure it before the stack**
>
> Sessions created by the API-key sign-in stop working once the `Auth:EntraId` section is present.
> Configured at this point, Entra ID is part of the stack's first deployment, and the profile in step 8 is
> created with the Microsoft account. To add it to a running stack, see [Operations](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/aws-signer-folder-cloudformation#day-two).

**1. Create the app registration.** Give the environment its own registration, not one shared with
another environment. [`New-BulkSignerEntraApp.ps1`](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/samples#powershell-7--new-bulksignerentraappps1)
creates a single-tenant registration with the redirect URI `https://$HOST/signin-oidc`, the
`Administrator` and `Approver` app roles, the `email` claim, and an enterprise application with
**Assignment required**:

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

The script refuses a display name that already exists in the tenant. At the end it prints, once, the
`Auth__EntraId__TenantId`, `Auth__EntraId__ClientId` and `Auth__EntraId__ClientSecret` block.

**2. Store the values.** The client secret goes into Secrets Manager from a prompt that does not echo;
the tenant and the client are not secrets:

```bash
read -rsp 'Client secret: ' VALUE; echo
ENTRA_ARN=$(aws secretsmanager create-secret --region $REGION --name $NAME/entra-client-secret \
  --secret-string "$VALUE" --query ARN --output text)
unset VALUE
TENANT_ID=<the Auth__EntraId__TenantId value>
CLIENT_ID=<the Auth__EntraId__ClientId value>
```

Clear the console afterwards.

**3. Assign people.** In the Entra admin center, under **Enterprise applications → the application →
Users and groups**, assign the **Administrator** app role to each operator. An account with no assignment
cannot sign in, and an account that authenticates with neither role is refused.

| Item | Description |
|---|---|
| Dependence on forwarded headers | Sign-in builds its redirect URI from the scheme Bulk Signer sees. The stack sets `Hosting:ForwardedHeaders`; without it the URI would be `http://…/signin-oidc`, and Microsoft refuses it with `AADSTS50011`. |
| Client secret expiry | The script's default lifetime is twelve months. An expired secret fails only at sign-in time, with an `AADSTS` error. Rotate it into the same secret and force a new deployment ([Operations](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/aws-signer-folder-cloudformation#day-two)). |

## 5. The stack

### 5.1 What the stack creates

One CloudFormation stack, named `$NAME`, creates the whole AWS infrastructure of the deployment. Every
resource name starts with `$NAME`.

| Resource | Name | Specification |
|---|---|---|
| S3 bucket | `$NAME-<account>` | All public access blocked; incomplete multipart uploads aborted after one day; the `inputs/default/` folder; the certificate at `signing-material/$NAME.cer`. |
| Security groups | `$NAME-alb`, `$NAME-task`, `$NAME-db` | TCP 443 and 80 from `0.0.0.0/0` into the load balancer; TCP 8080 from the load balancer into the task; TCP 1433 from the task into the database. |
| RDS for SQL Server | `$NAME` | `sqlserver-ex` 16.00 (SQL Server 2022), `db.t3.small`, 20 GiB gp3, encrypted, not publicly accessible, seven-day backups. The master user `bsadmin` has a password managed by RDS in Secrets Manager. |
| Database and login | `BulkSigner`, `bulksigner` | Created by a one-off Fargate task that runs `sqlcmd` (image `mcr.microsoft.com/mssql-tools`) as the master user: the database with `READ_COMMITTED_SNAPSHOT` on, and a login that holds `db_datareader`, `db_datawriter` and `db_ddladmin`, not `db_owner`. The stack fails if the task exits with a code other than `0`. |
| Secrets | `$NAME/sql-app`, `$NAME/auth-api-key`, `$NAME/signer-webhook-secret` | Generated by Secrets Manager (step 4.1). The connection string carries `TrustServerCertificate=True`, because the Bulk Signer image does not trust the Amazon RDS certificate authority ([step 5 of Amazon ECS Fargate](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/aws#5-the-database)). |
| IAM roles | `$NAME-exec`, `$NAME-task` | `$NAME-exec`, assumed by Amazon ECS: `AmazonECSTaskExecutionRolePolicy` plus `secretsmanager:GetSecretValue` on the six secrets and on the RDS master secret (the latter for the database task only). `$NAME-task`, assumed by Bulk Signer: `kms:DescribeKey`, `kms:GetPublicKey` and `kms:Sign` on the key from step 2, `s3:ListBucket` on the bucket, and reading, writing and deleting its objects. Both trust `ecs-tasks.amazonaws.com` in the account only. |
| ACM certificate | — | For `$HOST`, with DNS validation (step 5.3). Not created when an existing certificate ARN is given. |
| Load balancer and target group | `$NAME` | Internet-facing, idle timeout 300 seconds, invalid header fields dropped. HTTPS listener with `ELBSecurityPolicy-TLS13-1-2-2021-06`; HTTP listener that redirects to HTTPS with `301`. Target group on port 8080, health check `/api/ready` every 30 seconds, deregistration delay 30 seconds. The purpose of each setting is specified in [step 7 of Amazon ECS Fargate](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/aws#7-https). |
| Log groups | `/ecs/$NAME`, `/aws/lambda/$NAME-provisioner` | 30-day retention. |
| Cluster, task definition and service | `$NAME`, `$NAME`, `bulksigner` | Fargate, 1 vCPU and 2 GB, `X86_64`, port 8080, `stopTimeout` 60. One task, with the deployment settings of step 5.5. |
| Lambda function | `$NAME-provisioner` | Runs the four operations CloudFormation has no resource type for: it finds the default VPC, writes the `inputs/default/` folder, checks the certificate against the KMS key and uploads it, and creates the database. It is not in the VPC and calls AWS APIs only. |

> **Note: Port 443 must remain reachable by Signer**
>
> The webhook is a call **from Signer to the load balancer**. If the `$NAME-alb` group is restricted to
> specific source addresses, the addresses from which the Signer instance calls must also be admitted;
> obtain them from the operator of the instance. If Signer cannot be admitted, omit the webhook. The sweep
> alone signs every document, with the delay specified in
> [Function of `Signer:WebhookSecret` in performance](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/aws-signer-folder-cloudformation#why-webhook-secret).

The bucket uses three prefixes, which must not nest:

| Prefix | Content | Written by | Used by the Signer folder |
|---|---|---|---|
| `signing-material/` | The signing certificate (`.cer`) | The stack, from step 2's `signer.cer` | **Yes.** Read when the profile is opened. |
| `work/` | The work share | Bulk Signer, at boot | No. Bulk Signer nevertheless does not start without a work share. |
| `inputs/default/` | A watched folder, for files signed by the `default` profile | The stack | No. |

**Versioning and Object Lock must remain disabled.** The behavioural differences of an S3 work share are
documented in [Configuration](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/configuration#the-work-share-in-an-s3-bucket--storageprovider--s3).

### 5.2 Parameters

| Parameter | Value on this page | Description |
|---|---|---|
| `Prefix` | `$NAME` | The prefix of every resource name. The task role is `<Prefix>-task`, the role named in step 2. Default: `bulksigner`. |
| `HostName` | `$HOST` | The public hostname. |
| `ImageUri` | `$IMAGE` | The image in ECR, from step 3. |
| `KmsKeyAlias` | `alias/$NAME` | The alias from step 2. Default: `alias/bulksigner`. |
| `SigningCertificate` | The base64 of `signer.cer` | The certificate from step 2, on one line. The stack refuses it if its public key is not the KMS key's, before any other resource depends on it, and uploads it to the bucket. |
| `PkiSdkLicenseSecretArn` | `$PKI_ARN` | Step 4.1. |
| `SignerEndpoint` | `$SIGNER` | The Signer API address, without a trailing slash. Empty for a deployment that signs files only: no `Signer__*` settings and no webhook secret. |
| `SignerApiKeySecretArn` | `$SIGNER_KEY_ARN` | Step 4.1. Required with `SignerEndpoint`. |
| `CertificateArn` | Empty | An issued ACM certificate for `$HOST` in `$REGION`. Empty: the stack requests one with DNS validation (step 5.3). |
| `Route53HostedZoneId` | Empty | Only when the stack requests the certificate and the zone authoritative for `$HOST` is a Route 53 hosted zone in the account: the stack then creates the validation record itself. |
| `EntraTenantId`, `EntraClientId`, `EntraClientSecretArn` | `$TENANT_ID`, `$CLIENT_ID`, `$ENTRA_ARN` | Step 4.2, optional. All three or none. |
| `AppEnvironment`, `TrustLacunaTestRoot` | `Production`, `false` | For a homologation environment that signs with Lacuna's test certificates: `Staging` and `true` ([Configuration](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/configuration#signingtrustlacunatestroot--test-certificates-for-a-homologation)). The stack refuses `true` with `Production`. |
| `DatabaseEngine`, `DatabaseEngineVersion`, `DatabaseInstanceClass` | `sqlserver-ex`, `16.00`, `db.t3.small` | Express edition is sufficient for a homologation environment; production environments require `sqlserver-web` or `sqlserver-se`. |

### 5.3 Creation

```bash
aws cloudformation create-stack --region $REGION --stack-name $NAME \
  --template-body file://bulksigner-ecs-fargate.yaml --capabilities CAPABILITY_NAMED_IAM \
  --parameters ParameterKey=Prefix,ParameterValue=$NAME ParameterKey=HostName,ParameterValue=$HOST \
    ParameterKey=ImageUri,ParameterValue=$IMAGE ParameterKey=KmsKeyAlias,ParameterValue=alias/$NAME \
    ParameterKey=SigningCertificate,ParameterValue=$(openssl base64 -A -in signer.cer) \
    ParameterKey=PkiSdkLicenseSecretArn,ParameterValue=$PKI_ARN \
    ParameterKey=SignerEndpoint,ParameterValue=$SIGNER ParameterKey=SignerApiKeySecretArn,ParameterValue=$SIGNER_KEY_ARN \
    ${TENANT_ID:+ParameterKey=EntraTenantId,ParameterValue=$TENANT_ID ParameterKey=EntraClientId,ParameterValue=$CLIENT_ID ParameterKey=EntraClientSecretArn,ParameterValue=$ENTRA_ARN}
```

The last line adds the Entra ID parameters only when `TENANT_ID` is set (step 4.2). The stack can also be
created in the console, under **CloudFormation → Create stack → With new resources → Upload a template
file**, with the same parameters.

**Certificate validation.** When `CertificateArn` is empty, the stack waits for the ACM certificate to be
validated, and stops there until the validation record exists. About a minute after creation starts, the
record appears in the stack's events:

```bash
aws cloudformation describe-stack-events --region $REGION --stack-name $NAME \
  --query "StackEvents[?LogicalResourceId=='Certificate' && contains(ResourceStatusReason || '','CNAME')].ResourceStatusReason" \
  --output text
```

Create the returned CNAME record (`Name` and `Value`) in the authoritative zone. The stack continues once
ACM has validated it, usually within minutes. With `Route53HostedZoneId`, this step is not needed.

### 5.4 Completion

Wait for the stack, then read its outputs:

```bash
aws cloudformation wait stack-create-complete --region $REGION --stack-name $NAME
aws cloudformation describe-stacks --region $REGION --stack-name $NAME --query 'Stacks[0].Outputs' --output table
```

Creation takes about 20 minutes, most of it for the RDS instance; a certificate validation adds the time
until the record exists. The first boot of the task applies the
database migrations; the stack is complete once the service is stable.

| Output | Content |
|---|---|
| `Url` | `https://$HOST/` |
| `DnsRecord` | The CNAME record to create in step 6. |
| `LoadBalancerDnsName` | The load balancer's DNS name. |
| `BucketName`, `KmsKeyArn` | The bucket and the KMS key, as used in step 8. |
| `AuthApiKeySecretArn`, `SignerWebhookSecretArn` | The generated secrets read in steps 7, 8 and 9. |
| `WebhookUrl` | The URL registered in Signer in step 9. |
| `LogGroupName` | `/ecs/$NAME`. |

If the creation fails, CloudFormation rolls it back and deletes what it created, the bucket included.
The cause is in the first failed event:

```bash
aws cloudformation describe-stack-events --region $REGION --stack-name $NAME \
  --query "StackEvents[?ends_with(ResourceStatus,'FAILED')].[LogicalResourceId,ResourceStatusReason]" --output text
```

See [Error reference](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/aws-signer-folder-cloudformation#when-something-refuses). After a rollback, delete the stack
(`aws cloudformation delete-stack --region $REGION --stack-name $NAME`) before creating it again.

### 5.5 The service

| Setting | Value | Requirement |
|---|---|---|
| `minimumHealthyPercent`, `maximumPercent` | `0`, `100` | Every deployment stops the running task before starting the new one. |
| Desired count | `1` | Must not be increased. Auto scaling must not be attached. |
| `deploymentCircuitBreaker` | `enable=true`, `rollback=false` | A failed deployment stops instead of rolling back, which would start a second task. |
| Availability Zone rebalancing | Off | Rebalancing starts a task before stopping one. |
| Health check grace period | 300 seconds | The first boot applies the migrations before `/api/ready` answers. |

### 5.6 Bulk Signer configuration

The stack writes the container's environment and secrets. A `__` in an environment variable name
corresponds to `:` in the configuration key.

**Lacuna Signer connection.** One host connects to one Signer organization, and every profile that uses
the `SignerFolder` method uses these settings
([Configuration](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/configuration#signer--lacuna-signer-connection)).

| Environment variable | Value in this deployment | Required | Description |
|---|---|---|---|
| `Signer__Endpoint` | The `SignerEndpoint` parameter | Yes | The address of the Signer API. |
| `Signer__ApiKey` | Secret `$NAME/signer-api-key` | Yes | Sent as `X-Api-Key` on every call to Signer: the sweep, the read-back of each document, and the start and completion of each signature. It also determines the organization whose folders the profile form lists. |
| `Signer__WebhookSecret` | Secret `$NAME/signer-webhook-secret` | No | Enables `POST /api/signer/notifications` and is the Bearer token Signer must present on it. Without it, the route returns `404` and documents are detected by the sweep only (see [Function of `Signer:WebhookSecret` in performance](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/aws-signer-folder-cloudformation#why-webhook-secret)). |
| `Signer__FolderSweepIntervalSeconds` | `300` (the default) | No | The interval between listings of every bound folder, from 60 to 86400 seconds. |

The following rules apply to the `Signer` keys:

- **Joint requirement.** Setting any key under `Signer:`, including the webhook secret alone, makes
  `Signer:Endpoint` and `Signer:ApiKey` mandatory at boot. The template sets all of them or none.
- **Keys not used by this method.** `Signer:PollIntervalSeconds`, `Signer:TimeoutHours` and
  `Signer:MaxConsecutiveApiFailures` apply to the `LacunaSigner` method only.

**Other settings.** The remaining keys are those of the base deployment and are specified in the
[Configuration reference of Amazon ECS Fargate](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/aws#configuration-reference). The following points are
specific to this deployment:

| Setting | Relevance to the Lacuna Signer integration |
|---|---|
| `Signing:Certificate:*` | The seed (initial load) of the `default` signing profile, applied on the first boot against an empty database only. The `default` profile cannot use the `SignerFolder` method; the profile created in step 8 references the same key and the same `.cer`. Every credential is `InstanceRole`, so no profile stores a secret. |
| `Hosting:ForwardedHeaders:*` | With forwarded headers applied, the task observes Signer's address instead of the load balancer's. The rate limit of the notification route (`RateLimiting:SignerNotification`, 600 calls per minute) is counted per caller address; without forwarded headers, all callers share one budget ([Configuration](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/configuration#hosting)). The stack sets the VPC's CIDR block as the known network. |
| `Storage:Inputs:0:*` | The watched folder for files. |

## 6. DNS

Point the hostname at the load balancer, with the CNAME record in the `DnsRecord` output or, if the zone is
hosted in Route 53, an alias record. Signer calls this hostname and validates its certificate; the ACM
certificate satisfies that requirement.

## 7. Verification

```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 | jq '.checks[] | {name, ok, detail}'
```

In addition to the checks specified in [step 9 of Amazon ECS Fargate](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/aws#9-verification) (`database`,
`input-folder:default`, `storage-bucket:…`, `work-share-owner`, `pki-license`), `/api/ready/details`
reports the `cloudhub` check, with `ok: true` and the detail `not configured (CloudHub:ApiKey)`, which is
the normal value on this deployment, and two checks for the Lacuna Signer integration:

| Check | Expected result at this point |
|---|---|
| `signer-notifications` | `accepted; none has arrived at this instance since it started` |
| `signer-folder-sweep` | `ok: true`. After the first pass: `the last pass at this instance finished without a failure at …` |

If `signer-notifications` reports `not accepted (Signer:WebhookSecret is not set)`, the stack was created
with an empty `SignerEndpoint`.

The notification route can be tested without a credential:

```bash
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://$HOST/api/signer/notifications
```

| Response | Meaning |
|---|---|
| `401` (`signer-notification.unauthorized`) | Expected. The route is enabled and requires the Bearer token. |
| `404` (`signer-notification.not-configured`) | No webhook secret is set. |

With Entra ID (step 4.2), follow the sign-in's 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
ID and `redirect_uri=https%3A%2F%2F<host>%2Fsignin-oidc`. The `https` scheme is the value to confirm.

## 8. The signing profile

The `default` profile receives uploads that name no profile and watched folders that are bound to no
profile, and the `SignerFolder` method accepts no files. The Signer folder therefore requires **a
dedicated profile**.

### 8.1 Creation

Open the dashboard at `https://<host>/` and sign in: with **Sign in with Microsoft**, using an account
that holds the **Administrator** app role, if step 4.2 was applied; otherwise with the key stored in
`$NAME/auth-api-key`, copied to the clipboard without being displayed:

```bash
aws secretsmanager get-secret-value --region $REGION --secret-id $NAME/auth-api-key \
  --query SecretString --output text | tr -d '\r\n' | clip
```

Open **Signing profiles → New profile** (`/profiles/_new`).

[![The New signing profile page with the name aws-signer, the Lacuna Signer folder method and a folder chosen in the picker](https://docs.lacunasoftware.com/images/bulk-signer/aws-signer-folder-profile-form.png)](https://docs.lacunasoftware.com/images/bulk-signer/aws-signer-folder-profile-form.png)

| Field | Value |
|---|---|
| **Name** | `aws-signer` (the value of `$PROFILE`). The name cannot be changed after creation. |
| **Signing method** | **Lacuna Signer folder (this host signs documents created at Signer)** |
| **Lacuna Signer folder** | The folder from step 1.3, located with the name search. The selector lists the folders of the organization to which the API key belongs and stores the folder's ID. |
| **Signature format** | Ignored by this method. Signer selects the format. |
| **Certificate source** | **AwsKms** |
| **Where is the file?** | **In an Amazon S3 object** |
| **AWS region**, **KMS key**, **AWS credential mode** | `$REGION`, `alias/$NAME`, **InstanceRole** |
| **S3 bucket**, **S3 object key**, **S3 region**, **S3 credential mode** | `$BUCKET`, `signing-material/$NAME.cer`, `$REGION`, **InstanceRole** |
| **S3-compatible endpoint** | Empty. The field is for S3-compatible storage such as MinIO. |
| **Path-style bucket addressing** | Off, for the same reason. |

When the method is selected, the form hides the settings Signer determines (the input folder,
verification, encryption and the CNAB240 check) and saves them as disabled.

[![The certificate fields of the new profile: In an Amazon S3 object, the AWS region, the KMS key alias, InstanceRole, and the S3 bucket, object key, region and credential mode](https://docs.lacunasoftware.com/images/bulk-signer/aws-signer-folder-profile-kms.png)](https://docs.lacunasoftware.com/images/bulk-signer/aws-signer-folder-profile-kms.png)

Select **Create profile**. **The certificate is opened during the save**: the task reads the `.cer` from
S3, requests the public key from KMS and compares the two. The save is refused, and no profile is created,
in the following cases:

| Condition | Cause |
|---|---|
| KMS key not accessible | An incorrect alias, or a task role without `kms:GetPublicKey` on the key. |
| Certificate without a CPF | The certificate is not an e-CPF or e-CNPJ. |
| Folder already bound | Another profile signs in the selected folder. |

A saved profile takes effect immediately. No restart is required.

### 8.2 Verification

```bash
curl -s -H "X-API-Key: $KEY" https://$HOST/api/profiles/$PROFILE | jq
```

The response must report the `SignerFolder` method and `degraded` = `false`. On the **Signing profiles**
page, the profile's state is **ready**.

### 8.3 Separate certificate for the profile

To use a certificate other than that of the `default` profile, import it with a different alias. The
bucket exists now, so the script can upload the certificate itself, under a different object key:

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

**Before** creating the profile, give the task role the new key in a policy of its own; otherwise the
save is refused at `kms:GetPublicKey`. The stack manages only the role's `bulksigner-app` policy, so a
policy with another name survives stack updates:

```bash
KEY_ARN=$(aws kms describe-key --region $REGION --key-id alias/$NAME-other --query KeyMetadata.Arn --output text)
aws iam put-role-policy --role-name $NAME-task --policy-name kms-$NAME-other --policy-document   "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"kms:DescribeKey\",\"kms:GetPublicKey\",\"kms:Sign\"],\"Resource\":\"$KEY_ARN\"}]}"
```

## 9. The webhook in Lacuna Signer

### 9.1 Registration

Copy the secret to the clipboard, so that it is not displayed. The clipboard command is `clip` on
Windows, `pbcopy` on macOS and `xclip -selection clipboard` on Linux:

```bash
aws secretsmanager get-secret-value --region $REGION --secret-id $NAME/signer-webhook-secret \
  --query SecretString --output text | tr -d '\r\n' | clip
```

In Lacuna Signer, signed in as an administrator of the organization:

1. Open **Organizations** and select the organization.
2. In the **Integration** section, below the **Webhooks** card, select the **+** button. A new
   **Webhook N** panel opens. An organization can have several webhooks; an existing webhook of another
   system is not affected.
3. Fill in the fields as specified below.
4. Select **Save**, which remains disabled until the token is entered.
5. Clear the clipboard.

[![The Integration section of an organization at Lacuna Signer: a new webhook with the URL ending in /api/signer/notifications, the authentication type Bearer and the Authentication Token field](https://docs.lacunasoftware.com/images/bulk-signer/aws-signer-folder-signer-webhook.png)](https://docs.lacunasoftware.com/images/bulk-signer/aws-signer-folder-signer-webhook.png)

| Field | Value | Requirement |
|---|---|---|
| **URL** | `https://<host>/api/signer/notifications` | The public hostname from step 6, not the load balancer's AWS DNS name. The scheme must be `https`: the port-80 listener responds with a redirect, and HTTP clients commonly resend a redirected `POST` as a `GET`, which delivers no notification. |
| **Authentication type** | **Bearer** | **X-Api-Key** must not be used. That header is Bulk Signer's own REST credential, and a notification that presents it is refused. |
| **Authentication Token** | The value of `$NAME/signer-webhook-secret` | Without the word `Bearer`. Signer adds the prefix. |

The content Signer sends, and its authentication and retry behaviour, are specified in
[Webhooks](https://docs.lacunasoftware.com/en-us/articles/signer/webhooks).

### 9.2 Correspondence between Lacuna Signer and Bulk Signer

The complete configuration on the Signer side (steps 1 and 9):

| Item in Lacuna Signer | Location | Corresponding item in Bulk Signer |
|---|---|---|
| The application and its API key | The instance's **Applications** | `Signer:ApiKey` |
| The folder | **Folders**, in the organization | The profile's **Lacuna Signer folder** |
| The webhook URL | **Organizations → Integration** | The public hostname, followed by `/api/signer/notifications` |
| The webhook token, of type **Bearer** | **Organizations → Integration** | `Signer:WebhookSecret` |
| `UseBrazilianPdfSigningPolicies` | The instance's settings | None. The setting alone determines the policy of PDF signatures. |

### 9.3 Delivery behaviour

| Behaviour | Specification |
|---|---|
| Event selection | Not available. Signer delivers every event of the organization to every webhook of the organization. |
| Events processed by Bulk Signer | `DocumentsCreated`, `DocumentSigned` and `DocumentApproved`. Every other event is answered `204`, so that Signer does not retry it. |
| Retries | Signer retries a delivery that does not receive a `2xx` response, up to ten times over several hours ([Retries](https://docs.lacunasoftware.com/en-us/articles/signer/webhooks#retries)). |
| Effect of an incorrect token | Each delivery is answered `401`, and the `unauthorized` outcome of `bulksigner_signer_notifications_total` increases. |

## 10. Signing test

### 10.1 Document creation

In Lacuna Signer:

1. Select **New document**, upload a PDF, and select the organization and **the bound folder**.
2. Add the certificate holder as a participant of type **Signer**, with the name, the **CPF contained in
   the certificate** and the e-mail address. The holder can be the only participant or follow other
   participants; the task acts only when the pending action is the holder's.
3. Select **Send**.

[![The New document page at Lacuna Signer: the organization, the folder AWSTest, and a participant of type Signer with the fields Name, CPF and Email](https://docs.lacunasoftware.com/images/bulk-signer/aws-signer-folder-signer-new-document.png)](https://docs.lacunasoftware.com/images/bulk-signer/aws-signer-folder-signer-new-document.png)

### 10.2 Expected result

Within seconds, the dashboard's **Jobs** page lists a job with the document's name, marked **Lacuna
Signer**, under the `aws-signer` profile:

[![The Jobs page with the job Doc1, tagged Lacuna Signer and application/pdf, under the aws-signer profile with status Completed](https://docs.lacunasoftware.com/images/bulk-signer/aws-signer-folder-jobs.png)](https://docs.lacunasoftware.com/images/bulk-signer/aws-signer-folder-jobs.png)

The job passes through the following states:

| State | Meaning |
|---|---|
| **Queued** | The document was detected and the job was created. |
| **Processing** | The task read the document back, started the signature and signed the hash in KMS. |
| **Verifying** | The signature was handed to Signer, which validates it and builds it into the document. |
| **Completed** | Signer accepted the signature. |

[![The job's page: a total processing time of 1.2 seconds and the timeline Queued, Processing, Verifying, Completed, ending with Signed at Lacuna Signer](https://docs.lacunasoftware.com/images/bulk-signer/aws-signer-folder-job-timeline.png)](https://docs.lacunasoftware.com/images/bulk-signer/aws-signer-folder-job-timeline.png)

In Lacuna Signer, the holder's action is concluded and the document contains the signature. If the holder
was the last participant, the document's status in the folder is **Completed**:

[![The bound folder at Lacuna Signer, listing the document Doc1 with the status Completed](https://docs.lacunasoftware.com/images/bulk-signer/aws-signer-folder-signer-completed.png)](https://docs.lacunasoftware.com/images/bulk-signer/aws-signer-folder-signer-completed.png)

The CloudTrail event history shows the task role's `Sign` call on the key within a few minutes.

### 10.3 Verification from the command line

```bash
curl -s -H "X-API-Key: $KEY" "https://$HOST/api/jobs?profile=$PROFILE" | jq
curl -s -H "X-API-Key: $KEY" https://$HOST/api/ready/details | jq -r '.checks[] | select(.name=="signer-notifications") | .detail'
curl -s -H "X-API-Key: $KEY" https://$HOST/api/metrics \
  | grep -E 'bulksigner_signer_(notifications_total|folder_signatures_total|folder_jobs_enqueued_total)'
```

| Evidence | Expected value |
|---|---|
| `signer-notifications` check | `accepted; the last arrived at this instance at <time>` |
| `bulksigner_signer_folder_signatures_total{outcome="signed"}` | Incremented once per signature accepted by Signer. |
| `bulksigner_signer_notifications_total` | Counts deliveries, by outcome. |

A document that is signed only minutes after its creation was detected by the sweep, which indicates that
the notification did not arrive. In that case, the `signer-notifications` check still reports that none
has arrived, or the `unauthorized` outcome is increasing. See [Symptoms](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/aws-signer-folder-cloudformation#symptoms).

## Operations

The operations of the base deployment are specified in
[step 11 of Amazon ECS Fargate](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/aws#11-operations). In this deployment, every change to the
infrastructure or to the container's configuration is a **stack update**, not a change made in the console
or with the service's own commands: a resource changed outside the stack is overwritten by the next
update. `aws cloudformation deploy` keeps the value of every parameter that is not named:

```bash
aws cloudformation deploy --region $REGION --stack-name $NAME --template-file bulksigner-ecs-fargate.yaml \
  --capabilities CAPABILITY_NAMED_IAM --parameter-overrides <Parameter>=<value> ...
```

| Operation | Procedure | Notes |
|---|---|---|
| Upgrade | Copy the new version into ECR (step 3), pause the pipeline (`POST /api/pipeline/pause`), wait until no job is `Processing` or `Verifying`, run the update with `ImageUri=<new image>`, and resume (`POST /api/pipeline/resume`). | A task stopped during a signature requires the next boot to ask Signer how the job ended, and some of those jobs end `Failed` with `signer-folder.interrupted` ([Lacuna Signer integration](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/lacuna-signer#when-the-run-signing-it-ends)). The sweep runs while the pipeline is paused: new documents become jobs and wait in the queue. |
| Secret rotation | Store the new value in the secret, then start a new task: `aws ecs update-service --region $REGION --cluster $NAME --service bulksigner --force-new-deployment`, with the pipeline paused as for an upgrade. | A task reads its secrets when it starts. A new deployment does not change the stack. |
| Webhook secret rotation | Store a new random value in `$NAME/signer-webhook-secret`, start a new task as above, then enter the new value in the webhook in Signer. | The same value exists on both sides. Between the two changes, notifications are answered `401`; Signer retries them and the sweep detects the remaining documents. No document is lost. |
| Signer API key rotation | Create the new key in Signer, store it in `$NAME/signer-api-key`, start a new task as above, and only then revoke the previous key. | With a revoked key, every sweep fails, and the `signer-folder-sweep` check fails after three consecutive failed passes. The check does not affect `/api/ready`, so the load balancer keeps the task. |
| Certificate renewal | Pause the pipeline. Import the new PFX with a new alias (step 2) and run the update with `KmsKeyAlias=alias/<new alias>` and `SigningCertificate=$(openssl base64 -A -in <new>.cer)`. Then, on the page of each profile that uses the key, set the new alias under **Edit certificate** (the S3 object key does not change), start a new task as above, and resume. | The stack holds one key: the update replaces `signing-material/$NAME.cer` and moves the task role to the new key, so until the profiles name the new alias and a task has restarted, they are degraded. If the new certificate contains a **different CPF**, documents pending in Signer for the previous CPF have no action for the new holder. Each is recorded once as `Failed` with `signer-folder.no-holder-action`. Notify the users who create documents before the change. |
| Folder change | Save the new folder on the profile's Certificate panel. | Takes effect without a restart. Documents remaining in the previous folder are no longer signed by the profile. |
| Adding Entra ID to a running stack | Run step 4.2, then the update with `EntraTenantId`, `EntraClientId` and `EntraClientSecretArn`, with the pipeline paused as for an upgrade. | Sessions created by the API-key sign-in stop working. Existing profiles do not change. |
| Deactivation | Remove the webhook in Signer, then disable the profile on its page or change its method. | Updating the stack with an empty `SignerEndpoint` while a profile still uses the method does not prevent the boot. The profile becomes degraded, and its reason names the two missing keys. |
| Removal | `aws cloudformation delete-stack --region $REGION --stack-name $NAME` | Deletes every resource of the stack except two, which are kept on purpose: the **bucket**, with the work share, and a **final snapshot** of the database. Delete them separately when they are no longer needed; a new stack with the same `Prefix` cannot be created while the bucket exists. Not part of the stack, and not deleted: the KMS key, the ECR image, and the secrets of step 4. |

## Interface contracts

### Notification endpoint

`POST /api/signer/notifications` is called by Lacuna Signer and authenticated with
`Authorization: Bearer <Signer:WebhookSecret>`. The complete contract is specified in
[REST API](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/rest-api#signer-notifications).

| Response | Code | Condition |
|---|---|---|
| `202` | — | A document in a bound folder was queued for a check, or was already queued. |
| `204` | — | Any other authenticated delivery: an event that cannot make a document await a signature, a folder that is not bound, the folder of a disabled profile, or a body that cannot be read. |
| `401` | `signer-notification.unauthorized` | The Bearer token is absent or incorrect, or another scheme was used. |
| `404` | `signer-notification.not-configured` | `Signer:WebhookSecret` is not set. |
| `429` | — | The rate limit of the route (`RateLimiting:SignerNotification`) was exceeded. Signer retries the delivery. |
| `503` | `signer-notification.unavailable` | The operational store is unusable on the instance, or too many checks are queued. Signer retries the delivery. |

Queued checks are held in memory. A restart discards those not yet performed, and the next sweep detects
the corresponding documents.

### Other endpoints used on this page

Authenticated requests send `X-API-Key: <Auth:ApiKey>`.

| Method | Route | Authentication | Used by |
|---|---|---|---|
| `GET` | `/api/ready`, `/api/ready/details` | Anonymous; API key for the details | Verification (steps 7 and 10). |
| `GET` | `/api/profiles/{name}` | API key | Profile verification (step 8). |
| `GET` | `/api/jobs?profile={name}` | API key | Signing test (step 10). |
| `GET` | `/api/metrics` | API key | Signing test (step 10). |
| `POST` | `/api/pipeline/pause`, `/api/pipeline/resume` | API key | Deployment ([Operations](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/aws-signer-folder-cloudformation#day-two)). |
| `GET` | `<Signer__Endpoint>/api/documents` | None | Confirms the Signer API address: `401` without a key (step 1.1). |

### AWS calls made by the task for the profile

| Service | Action | Resource | When called |
|---|---|---|---|
| Amazon S3 | `s3:GetObject` | `signing-material/<name>.cer` | When the profile is opened: during the save in step 8, and at each boot. |
| AWS KMS | `kms:DescribeKey`, `kms:GetPublicKey` | The signing key | When the profile is opened. |
| AWS KMS | `kms:Sign` | The signing key | Once per signed document. |

## Error reference

### Error codes

| Code | Origin | Meaning | Resolution |
|---|---|---|---|
| `signer-notification.unauthorized` | Notification route, `401` | The token registered in Signer differs from the secret, was entered with the word `Bearer`, or the authentication type is not **Bearer**. | Correct the webhook in Signer (step 9). |
| `signer-notification.not-configured` | Notification route, `404` | The webhook secret did not reach the task. | Check the task definition revision the service runs, and that the execution role can read the secret. |
| `signer-folder.no-holder-action` | Job, `Failed` | The document is in the bound folder but has no signing action for the certificate's CPF: the holder is absent, was added as an approver only, or was added without the CPF. | Add the holder as a signer with the CPF. The next sweep detects the new action. Not retriable. |
| `signer-folder.signature-refused` | Job, `Failed` | Signer refused the signature. Its validation results are recorded in the job history; the usual cause is the certificate (revoked, expired, or not the participant's). | Correct the cause reported by Signer, then retry the job. |
| `signer-folder.interrupted` | Job, `Failed` | The task stopped during the signature. | Check the document in Signer, then retry the job. A retry ends `Canceled` if the holder's action is already concluded. |
| `error storing credentials … The stub received bad data` | Docker Desktop for Windows, on the ECR `docker login` | Windows Credential Manager refuses the size of the ECR token. | Push the image with the `.docker-ecr` directory from step 3. An empty `--config` directory does not help. |
| `The public key of SigningCertificate is not the public key of alias/…` | Stack, resource `SigningMaterial` | `signer.cer` is not the certificate of the imported key. | Pass the `.cer` that step 2 wrote for that alias. |
| `Alias arn:aws:kms:…:alias/… is not found` | Stack, resource `SigningMaterial` | `KmsKeyAlias` names no key in the Region. | Pass the alias of step 2, with the `alias/` prefix. |
| `The database setup task … stopped with exit code …` | Stack, resource `DatabaseSetup` | A `sqlcmd` command failed. | The first failed command is in the log the message names: `aws logs tail /ecs/$NAME --region $REGION --log-stream-name-prefix dbsetup`. |
| `… already exists` | Stack, any named resource | A resource with the same name exists: an earlier stack with the same `Prefix`, or its retained bucket. | Use another `Prefix`, or delete the earlier resource. |
| The stack stays in `CREATE_IN_PROGRESS` at `Certificate` | Stack, ACM | The validation record does not exist, or the zone that holds it is not the authoritative one. | Create the record of step 5.3, in the zone the `dig +short NS` check names. |
| `Cannot create a snapshot because the database instance … is not currently in the available state` | Stack deletion, resource `Database` | The stack was deleted while the RDS instance was still being created, and the final snapshot needs an available instance. | Wait for `aws rds wait db-instance-available --region $REGION --db-instance-identifier $NAME`, then delete the stack again. |
| `Circuit breaker` in the `Service` event | Stack, ECS | The task did not become healthy. | The task's log: `aws logs tail /ecs/$NAME --region $REGION --log-stream-name-prefix bulksigner`. |
| `AADSTS50011` | Microsoft Entra ID, at sign-in | The redirect URI sent is `http://…`, or it does not match the app registration. | Confirm that `HostName` is the hostname users open, and the registration's `https://<host>/signin-oidc` URI (step 4.2). |
| `ThrottlingException`, `KeyUnavailableException` | AWS KMS, recorded on the job | The KMS request quota, which is shared by every caller in the account and Region, 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). |

A job that ends `Canceled` carries no code: the document was cancelled, refused or expired in Signer, or
was already signed by the holder, before the task signed it. This is not a fault. Every `signer-folder.*`
code is specified in
[Troubleshooting](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/troubleshooting#a-lacuna-signer-folder-job-ends-failed-or-canceled).

### Symptoms

| Symptom | Cause | Resolution |
|---|---|---|
| The task stops at boot and its log names `Signer:Endpoint` or `Signer:ApiKey`. | A key under `Signer:` is set without those two. Setting `Signer__WebhookSecret` alone is sufficient to require both. | Not reachable with the stack, which sets all of them or none. Check the task definition's environment. |
| The task stops at boot and its log names `Signer:WebhookSecret`. | The secret has fewer than 32 characters, or has leading or trailing whitespace, usually a trailing line break in the secret's value. | Store a new value without whitespace in `$NAME/signer-webhook-secret` and start a new task ([Operations](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/aws-signer-folder-cloudformation#day-two)). |
| `signer-notifications` reports *accepted; none has arrived…* after a document was created. | Signer does not reach the load balancer: the URL registered in Signer is incorrect or uses `http://`, or the `$NAME-alb` security group does not admit Signer's addresses. | Correct the URL (step 9) or the `$NAME-alb` security group (step 5.1). |
| Documents are signed, but minutes after their creation. | The notification does not arrive, and documents are detected by the sweep. | See [Troubleshooting](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/troubleshooting#lacuna-signer-documents-are-found-only-at-the-sweep-interval-not-within-seconds). |
| The folder selector reports that Signer cannot be reached, or lists the folders of another organization. | `Signer__Endpoint` is not the address of the Signer API, or `Signer__ApiKey` belongs to another organization. | Correct `SignerEndpoint` with a stack update, or the secret `$NAME/signer-api-key` and start a new task ([Operations](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/aws-signer-folder-cloudformation#day-two)). |
| **Create profile** is refused with a message about the certificate's CPF. | The certificate contains no CPF. | Use an ICP-Brasil e-CPF or e-CNPJ. See [Troubleshooting](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/troubleshooting#a-signer-folder-profile-is-degraded-saying-its-certificate-carries-no-cpf). |
| **Create profile** is refused with a message about KMS or S3. | The task role lacks `kms:GetPublicKey` on the key or `s3:GetObject` on the `.cer`, the task role's ARN is not exempted from the key policy's deny, or the `.cer` does not match the key. | Correct the role or the key policy (steps 2 and 8.3). See [Troubleshooting](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/troubleshooting#an-awskms-profile-is-degraded-at-startup). |
| The `signer-folder-sweep` check fails. | Three consecutive passes failed. The usual cause is a revoked or incorrect `Signer:ApiKey`. | See [Troubleshooting](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/troubleshooting#the-signer-folder-sweep-row-on-apireadydetails-is-red). |

Errors of the base deployment that do not involve Lacuna Signer are specified in the
[Error reference of Amazon ECS Fargate](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/aws#error-reference).

## Limitations

| Not provided | Alternative |
|---|---|
| A signed file in S3 | Signer stores the document. Download it from Signer; nothing is written to `work/output/`. |
| Verification of the result by Bulk Signer | None. Verification is disabled for this method, because Signer validates the signature when it builds it into the document. |
| The opposite direction: a file sent to Signer for a person to sign | The `LacunaSigner` method, which uses the same `Signer:Endpoint` and `Signer:ApiKey`, requires no further AWS change and is selected per profile. See [Lacuna Signer integration](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/lacuna-signer). |
| More than one instance, verification of the database server certificate, metrics in CloudWatch, backups made by Bulk Signer | The [limitations of Amazon ECS Fargate](https://docs.lacunasoftware.com/en-us/articles/bulk-signer/aws#limitations) apply unchanged. |
