Pular para o conteúdo principal

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.

Minimum version: 2.20.0

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.

Single-instance constraint

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​

Bulk Signer on Amazon ECS Fargate: one task behind an Application Load Balancer, with RDS for SQL Server, S3, AWS KMS, Secrets Manager and CloudWatch Logs

IAM roles​

RoleAssumed byPurpose
Execution role ($NAME-exec)Amazon ECSPulls the image, writes the log stream and reads the secrets injected into the container environment.
Task role ($NAME-task)Bulk SignerThe 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​

ComponentSelectionRationale
ComputeECS Fargate, one task, 1 vCPU / 2 GBThe image runs unmodified. A Fargate task has no disk that persists beyond the task, which determines the next four rows.
Operational storeRDS for SQL Server 2022SQLite 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 inputsOne 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 ringOperational 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 keyAWS 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 bucketNo disk path is available for a certificate file. The object is read once, at boot.
Logsstdout → CloudWatch LogsThe file log is still written, but to a disk that is discarded with the task.
Database backupRDS automated backupsBackup:Enabled is refused with Database:Provider = SqlServer.

Prerequisites​

RequirementSpecification
ToolsAWS 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 permissionsPermission 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 credentialsThe credentials Lacuna issued for the container image. See Obtaining the product.
Signing materialThe PFX file whose key will sign, or a KMS key together with the certificate a certificate authority (CA) issued for it.
Licences and keysThe PKI SDK licence (required). The Lacuna Signer API key and the Web PKI licence, if those integrations are used.
DNSA hostname, and write access to the DNS zone that is authoritative for it.
Identify the authoritative DNS zone

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

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)
VariableContent
REGIONThe AWS Region of every resource.
ACCOUNTThe AWS account ID of the authenticated identity.
NAMEThe prefix of every resource name.
BUCKETThe bucket name. It includes the account ID because S3 bucket names are globally unique.
HOSTThe public hostname of the deployment.
VPC, SUBNETS, VPC_CIDRThe 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:

PrefixContentCreated by
work/The work share: processing/, output/ and error/Bulk Signer, at boot
inputs/default/The watched folderThe 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:

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

The script prompts for the PFX password.

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
ParameterValueDescription
-PfxPath./signer.pfxThe PFX file holding the key and the certificate.
-Region$REGIONThe AWS Region in which the key is created.
-Alias$NAMEThe KMS alias. Step 8 references it as alias/<name> in Signing:Certificate:AwsKms:KeyId. The script stops if the alias already exists.
-S3Bucket$BUCKETThe bucket that receives the certificate.
-S3Keysigning-material/$NAME.cerThe object key of the certificate.
-SignerPrincipalArnThe task role ARNThe 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.

The key is only as secure as every copy of the PFX

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

Key generated in KMS. A key created inside KMS instead requires a CSR signed by KMS and a certificate issued for it. That procedure, and the checks Bulk Signer performs when it opens the key, are documented in Certificates.

Key requirements, regardless of origin:

PropertyRequired value
Key typeRSA 2048, 3072 or 4096
Key usageSign 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
RequirementReason
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).
Docker Desktop on Windows: "The stub received bad data"

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

4. Security groups​

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

GroupInbound ruleSource
$NAME-albTCP 443 and TCP 800.0.0.0/0
$NAME-taskTCP 8080$NAME-alb
$NAME-dbTCP 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)
ParameterValueNotes
--enginesqlserver-exExpress edition. For production, use sqlserver-web or sqlserver-se, and evaluate Multi-AZ.
--engine-versionA 16.00 versionEngine versions 16.00 are SQL Server 2022. The first command lists those available in the Region.
--db-instance-classdb.t3.small
--storage-type, --allocated-storagegp3, 20
--master-usernamebsadminUsed 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-period7Days 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:

KeyContentConsumer
usernamebulksigner—
passwordThe generated password (40 characters, no punctuation)The setup task in 5.3 (APP_PASSWORD)
connectionStringThe connection string belowThe service (ConnectionStrings__Default)
Connection string propertyValue
Servertcp:<RDS endpoint>,1433
Initial CatalogBulkSigner
User IDbulksigner
PasswordThe generated password
EncryptTrue
TrustServerCertificateTrue (see the warning below)
TrustServerCertificate=True is a known limitation

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

5.3 Database creation​

Bulk Signer creates its tables but never its database (Choosing where the operational store lives), 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:

ItemTypeValue
Image—mcr.microsoft.com/mssql-tools
DB_HOSTEnvironment variableThe RDS endpoint ($DB_HOST)
MASTER_USEREnvironment variablebsadmin
MASTER_PASSWORDSecret"<RDS-managed secret ARN>:password::"
APP_PASSWORDSecret"<$NAME/sql-app ARN>:password::"
Execution role—$NAME-exec (step 6)
Logging—The service's awslogs group, /ecs/$NAME (step 8)
Network—The task's subnets and the $NAME-task security group

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

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

6. Secrets and roles​

6.1 Secrets​

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

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:

SecretContentCreated inInjected as
$NAME/sql-appJSON with username, password and connectionStringStep 5.2ConnectionStrings__Default (key connectionString)
$NAME/auth-api-keyA random 48-character keyStep 6.1Auth__ApiKey
$NAME/pki-sdk-licenseThe PKI SDK licenceStep 6.1, manuallySigning__PkiSdkLicense
$NAME/signer-api-keyThe Lacuna Signer API key, in the format <application>|<secret>; the application part may contain spacesStep 6.1, manuallySigner__ApiKey
$NAME/webpki-licenseThe Web PKI licenceStep 6.1, manuallyWebPki__License
$NAME/entra-client-secretThe Entra ID client secretStep 10 (optional)Auth__EntraId__ClientSecret
RDS-managed master secretThe bsadmin passwordStep 5.1, by RDSMASTER_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>:*" }
}
}]
}
RolePermissions
$NAME-execThe 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-taskThe 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}'
ResourceSettingValuePurpose
Load balancerSchemeinternet-facingPublic entry point.
Load balanceridle_timeout.timeout_seconds300Keeps the dashboard's WebSocket open between updates.
Load balancerrouting.http.drop_invalid_header_fields.enabledtrueRemoves HTTP header fields with invalid names before forwarding.
Target groupProtocol, port, target typeHTTP, 8080, ipFargate tasks in awsvpc mode register by IP address.
Target groupHealth check/api/ready, every 30 s; 2 healthy, 3 unhealthy; 200 expectedSee the contract below.
Target groupderegistration_delay.timeout_seconds30
HTTPS listenerPort, policy, action443, ELBSecurityPolicy-TLS13-1-2-2021-06, forward to the target group
HTTP listenerPort, action80, 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​

ParameterValue
cpu1024
memory2048
networkModeawsvpc
runtimePlatform.cpuArchitectureX86_64
executionRoleArn$NAME-exec
taskRoleArn$NAME-task
Container namebulksigner
Container image$IMAGE (step 3)
Container port8080
stopTimeout60
Log driverawslogs, 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 = OperationalStore stores the ring in the database. Without it, the ring is written to the task's disk and every deployment ends the sessions of all operators and approvers. The first boot with this setting creates a new ring, which ends all sessions once (Configuration).
  • 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. PollIntervalSeconds is mandatory, because new objects in a bucket are detected only by listing it.
  • Signing profile seed. Signing:Certificate is the seed (initial load) of the default signing profile, applied on the first boot against an empty database only. Subsequent changes are made on the dashboard's Signing profiles page. Every credential is InstanceRole, so no profile stores a secret and Signing:ProfileSecretsKey is not required.
  • Lacuna Signer. The Signer keys are optional and must be set together: setting Signer__Endpoint makes Signer__ApiKey mandatory at boot. Omit both to run without Lacuna Signer. The endpoint is the address on which the Signer API itself responds (GET <endpoint>/api/documents returns 401 without a key), which is not necessarily the address of the product's home page. Verify the endpoint with that request.
  • Homologation. For an environment that signs with Lacuna's test certificates, set ASPNETCORE_ENVIRONMENT to Staging and add Signing__TrustLacunaTestRoot=true (Configuration). 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
ParameterValueRequirement
minimumHealthyPercent, maximumPercent0, 100Every 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-count1Must not be increased, for the same reason. Auto scaling must not be attached.
--health-check-grace-period-seconds300The 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.
deploymentCircuitBreakerenable=true, rollback=falseA failed deployment stops instead of rolling back, which would start a second task.
assignPublicIpENABLEDRequired for outbound calls from public subnets (see Network placement).
--load-balancersTarget group, container bulksigner, port 8080Registers 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:

CheckExpected result
databaseReachable, 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:defaultRunning, at s3://…/inputs/default.
work-share-ownerWritten at startup, not held. This is the normal value on S3, not a fault.
pki-licenseLoaded.

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

9.2 Signing test​

  1. Submit a PDF: POST /api/files, multipart, field file. The response is 202 Accepted with the jobId.
  2. Poll GET /api/jobs/{id} until status is Completed.
  3. Download the signed file: GET /api/jobs/{id}/output.
JOB=$(curl -s -H "X-API-Key: $KEY" -F "file=@sample.pdf" https://$HOST/api/files | jq -r .jobId)
curl -s -H "X-API-Key: $KEY" https://$HOST/api/jobs/$JOB | jq -r .status
curl -s -H "X-API-Key: $KEY" -o sample.signed.pdf https://$HOST/api/jobs/$JOB/output

Expected evidence:

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

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

9.3 Expected startup warnings​

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

WarningExplanation
Work-share marker written, not heldS3 provides no lease.
Key ring stored unencryptedThe ring is stored in the database and protected by the database's access control.
ValidateCertificate=false on the seeded profileThe 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)"
ParameterValueDescription
-BaseUrlhttps://$HOSTThe public base URL; the redirect URI is derived from it.
-DisplayNameLacuna 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​

ItemDescription
Dependency on forwarded headersThe 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 terminationSessions created by API-key sign-in stop working as soon as the section is present.
Client secret expiryThe 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​

OperationProcedureNotes
UpgradePush 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 rotationUpdate 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 rotationRun 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 renewalRun 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.
PauseRun 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.
DecommissionDelete the resources in the order listed below.

Decommission order:

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

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

Configuration reference​

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

Core settings​

Environment variableValue in this deploymentRequiredDescription
ASPNETCORE_ENVIRONMENTProduction (Staging for homologation)YesThe hosting environment.
Database__ProviderSqlServerYesThe operational store provider.
ConnectionStrings__DefaultSecret $NAME/sql-app, key connectionStringYesThe SQL Server connection string (step 5.2).
Auth__ApiKeySecret $NAME/auth-api-keyYesThe REST API's X-API-Key; also the dashboard sign-in key when Entra ID is not configured.
Auth__SessionKeyRing__PlacementOperationalStoreYes, for this topologyStores the session key ring in the database so that sessions survive deployments.
Hosting__ForwardedHeaders__EnabledtrueYesApplies the load balancer's forwarded headers.
Hosting__ForwardedHeaders__KnownNetworks__0$VPC_CIDRYesThe network from which forwarded headers are trusted.
Signing__PkiSdkLicenseSecret $NAME/pki-sdk-licenseYesThe PKI SDK licence.
WebPki__LicenseSecret $NAME/webpki-licenseIf Web PKI is usedThe Web PKI licence.

Work share and watched folder​

Environment variableValue in this deploymentRequiredDescription
Storage__ProviderS3YesPlaces the work share in S3.
Storage__S3__BucketName$BUCKETYesThe bucket.
Storage__S3__PrefixworkYesThe work share's prefix. Must not contain a watched folder.
Storage__S3__Region$REGIONYesThe bucket's Region.
Storage__S3__CredentialInstanceRoleYesAuthenticates with the task role.
Storage__Inputs__0__NamedefaultYesThe watched folder's name.
Storage__Inputs__0__Pathinputs/defaultYesThe folder's prefix in the bucket. The marker object must exist (step 1).
Storage__Inputs__0__PollIntervalSeconds30Yes, on S3The interval between listings of the folder.

Signing certificate (seed of the default profile)​

Environment variableValue in this deploymentRequiredDescription
Signing__Certificate__SourceAwsKmsYesThe certificate source.
Signing__Certificate__AwsKms__Region$REGIONYesThe key's Region.
Signing__Certificate__AwsKms__KeyIdalias/$NAMEYesThe key, by alias (step 2).
Signing__Certificate__AwsKms__CredentialInstanceRoleYesAuthenticates to KMS with the task role.
Signing__Certificate__AwsKms__S3__Bucket$BUCKETYesThe bucket holding the certificate.
Signing__Certificate__AwsKms__S3__Keysigning-material/$NAME.cerYesThe certificate's object key.
Signing__Certificate__AwsKms__S3__Region$REGIONYesThe certificate bucket's Region.
Signing__Certificate__AwsKms__S3__CredentialInstanceRoleYesAuthenticates to S3 with the task role.
Signing__TrustLacunaTestRoottrueHomologation onlyTrusts 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 variableValue in this deploymentRequiredDescription
Signer__Endpointhttps://<Lacuna Signer address>NoThe address of the Signer API.
Signer__ApiKeySecret $NAME/signer-api-keyYes, when Signer__Endpoint is setThe Signer API key, <application>|<secret>. Its absence stops the task at boot.

Microsoft Entra ID (optional)​

Environment variableValue in this deploymentRequiredDescription
Auth__EntraId__TenantIdThe directory (tenant) IDFor Entra ID sign-inThe tenant.
Auth__EntraId__ClientIdThe application (client) IDFor Entra ID sign-inThe app registration.
Auth__EntraId__ClientSecretSecret $NAME/entra-client-secretFor Entra ID sign-inThe client secret.

Settings that must not be used​

SettingReason
Cluster:EnabledRefused with an S3 work share.
Backup:EnabledRefused 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).

MethodRouteAuthenticationResponseUsed by
GET/api/readyAnonymous (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/detailsAPI keyThe same report with each check's detail, and the same 200 / 503 rule.Verification (step 9).
GET/api/profilesAPI key200, listing every signing profile, including certificateSource and degraded.Verification (step 9).
POST/api/filesAPI key202 Accepted with jobId, fileName, format and status (Queued).Signing test (step 9).
GET/api/jobs/{id}API key200 with the job, including status and a history array.Signing test (step 9).
GET/api/jobs/{id}/outputAPI keyThe 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-loginAnonymousA redirect to the Microsoft sign-in endpoint.Entra ID verification (step 10).
GET/api/metricsAPI 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​

MethodRouteExpected responsePurpose
GET<Signer__Endpoint>/api/documents401 without a keyConfirms that Signer__Endpoint is the address of the Signer API.

AWS calls made by the task​

ServiceActionResourceWhen called
AWS KMSkms:DescribeKey, kms:GetPublicKeyThe signing keyOnce, at boot, when the certificate is opened.
AWS KMSkms:SignThe signing keyOnce per signed file.
Amazon S3s3:GetObjectsigning-material/<name>.cerOnce, at boot.
Amazon S3s3:ListBucketThe bucketEvery PollIntervalSeconds, to detect files in the watched folder; also by the startup checks.
Amazon S3s3:GetObject, s3:PutObject, s3:DeleteObject, s3:AbortMultipartUploadObjects in the bucketDuring 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​

CodeOriginHTTP statusMeaningResolution
file.already-processedBulk Signer API409A Completed or active job already holds this file name.Delete the job on the Jobs page, or rename the file.
job.output-unavailableBulk Signer API409The job is not Completed.Poll GET /api/jobs/{id} until Completed.
job.output-goneBulk Signer API410The job is Completed, but its file is no longer in output/.—
job.staged-copy-changedBulk 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, KeyUnavailableExceptionAWS KMS— (recorded on the job)The KMS request quota was exceeded, or the key was unavailable.See Troubleshooting.
AADSTS50011Microsoft 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 operationMicrosoft 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 trustedSQL 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 dataDocker 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​

SymptomCauseResolution
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 providedAlternative
More than one instanceAzure App Service (cluster mode).
Verification of the database server certificateNone 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 SignerRDS automated backups.
View as Markdown