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 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.
The S3 storage provider, the
AwsKms certificate source and the
Auth:SessionKeyRing:Placement
key were introduced in 2.20.0. Earlier images refuse the configuration on this page at boot.
Cluster mode 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).
Architecture
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. |
| 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. |
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:
dig +short NS example.com
Shell variables
All commands on this page run in one shell session, initialised with the following variables:
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:
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:
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 and Installation.
2. The signing key
For an existing PFX file, Import-PfxToKms.ps1 performs
the complete import:
- Creates an RSA signing key in KMS with imported key material.
- Wraps the private key locally in the format KMS requires.
- Imports the wrapped key material.
- Compares the KMS public key with the certificate, using the same check Bulk Signer performs at boot.
- Uploads the
.cerfile to S3.
The script prompts for the PFX password.
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.
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.
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:
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). |
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 |
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
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:
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) |
TrustServerCertificate=True is a known limitationThe 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),
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:
#!/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
BulkSignerdatabase exists withREAD_COMMITTED_SNAPSHOTenabled, which RDS, like any SQL Server outside Azure, does not enable by default; - the
bulksignerlogin exists and holdsdb_datareader,db_datawriteranddb_ddladmin, and notdb_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:
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:
{
"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. |
{
"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.
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
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) and wait for validation:
aws acm wait certificate-validated --region $REGION --certificate-arn $CERT
7.2 Load balancer, target group and listeners
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.
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
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:
"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. The following rules apply to this deployment:
- Session key ring.
Auth__SessionKeyRing__Placement = OperationalStorestores 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). - 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). - Watched folder. The input specifies no provider and therefore inherits S3 and the work share's
bucket.
PollIntervalSecondsis mandatory, because new objects in a bucket are detected only by listing it. - Signing profile seed.
Signing:Certificateis the seed (initial load) of thedefaultsigning profile, applied on the first boot against an empty database only. Subsequent changes are made on the dashboard's Signing profiles page. Every credential isInstanceRole, so no profile stores a secret andSigning:ProfileSecretsKeyis not required. - Lacuna Signer. The
Signerkeys are optional and must be set together: settingSigner__EndpointmakesSigner__ApiKeymandatory at boot. Omit both to run without Lacuna Signer. The endpoint is the address on which the Signer API itself responds (GET <endpoint>/api/documentsreturns401without 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_ENVIRONMENTtoStagingand addSigning__TrustLacunaTestRoot=true(Configuration). Neither setting is required for an ICP-Brasil production certificate.
8.3 Service
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). |
--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
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
- Submit a PDF:
POST /api/files, multipart, fieldfile. The response is202 Acceptedwith thejobId. - Poll
GET /api/jobs/{id}untilstatusisCompleted. - Download the signed file:
GET /api/jobs/{id}/output.
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
Signcall 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). 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 creates it:
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:
{ "name": "Auth__EntraId__TenantId", "value": "<directory (tenant) ID>" },
{ "name": "Auth__EntraId__ClientId", "value": "<application (client) ID>" }
{ "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:
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:
- The service (scaled to 0, then deleted).
- The cluster.
- The listeners, the load balancer and the target group.
- The DNS records.
- The ACM certificate.
- The RDS instance.
- The security groups.
- The IAM roles.
- The secrets.
- The bucket (emptied first).
- The ECR repository.
- The KMS key, last, with
schedule-key-deletionand 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).
| 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). |
The complete API is specified in 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. |
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.
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. |
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. |
| 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). |
| 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. |