Skip to content

Deploy on AWS ECS (Fargate)

Audience: DevOps running PrivaCI as a one-shot ECS Fargate RunTask after subscribing on AWS Marketplace.

When you are done: You have deployed the quick-launch stack (or equivalent task definition) and completed a masked run with live CheckoutLicense entitlement.

Prerequisites: Deployment options · Licensing & entitlement


1. Subscribe and note the image URI

From your Marketplace subscription page, copy:

  • Marketplace ECR image URI (primary production path)
  • AWS region where you subscribed

Do not use the public ghcr.io/boundarylogic/privaci image in production — that runs in community mode without license enforcement.

GHCR (ghcr.io/boundarylogic/privaci-commercial:<tag>) is documented for contributors and pre-fulfillment smoke tests only.

1.1 One-time: License Manager in your account

Subscribing on Marketplace does not always enable License Manager in the buyer account. Before the first CheckoutLicense (container start or CLI test), AWS may require a one-time account setup:

Symptom Cause
Service role not found on CheckoutLicense License Manager service-linked roles missing
Task exits 5 immediately with entitlement error Same — entitlement API not ready in account

Fix (pick one):

  1. Console (recommended): AWS Console → License ManagerStart using License Manager → grant permissions (creates AWSServiceRoleForAWSLicenseManagerRole).
  2. CLI:
aws iam create-service-linked-role --aws-service-name license-manager.amazonaws.com
aws iam create-service-linked-role --aws-service-name license-management.marketplace.amazonaws.com

Run once per subscribing AWS account before the first PrivaCI job. This is not the ECS task role from Launch step 3 — that role is created by the quick-launch stack. See Troubleshooting — License Manager setup.


2. Prepare customer-owned resources

The CloudFormation template does not create VPC, RDS, Secrets Manager secrets, or S3 buckets. You supply them as parameters (or create them first).

Resource Purpose Created by stack?
ECS cluster Fargate RunTask host Optional — leave EcsClusterName empty to create <stack>-cluster
Subnets + security groups awsvpc networking — see section 2.3 No
Two PostgreSQL databases Source (data to mask) + target (empty database) No — see section 2.0
Secrets Manager DSNs + salt — see section 2.1 No
Config storage S3 object or EFS volume with mask-rules.yaml — see section 2.2 No
Optional S3 bucket Compliance report output prefix No

2.0 Postgres roles and grants

Create dedicated DB roles (do not use the RDS master user in production). Put each role’s password into the DSN secret from section 2.1.

Source database (read-only for masking):

-- Connect as a superuser / RDS master to the SOURCE database.
CREATE ROLE privaci_source LOGIN PASSWORD '<strong-password>';
GRANT CONNECT ON DATABASE your_source_db TO privaci_source;
GRANT USAGE ON SCHEMA public TO privaci_source;   -- repeat per schema you mask
GRANT SELECT ON ALL TABLES IN SCHEMA public TO privaci_source;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
  GRANT SELECT ON TABLES TO privaci_source;

Also grant USAGE / SELECT on any other schemas listed in mask-rules.yaml. The engine reads table/column catalogs and streams row data; it does not need INSERT/UPDATE/DELETE on source.

Target database (empty DB dedicated to masked output + _privaci state):

-- Connect as a superuser / RDS master to the TARGET database.
CREATE ROLE privaci_target LOGIN PASSWORD '<strong-password>';
GRANT CONNECT ON DATABASE your_target_db TO privaci_target;
-- Required so PrivaCI can create the _privaci schema on first run:
GRANT CREATE ON DATABASE your_target_db TO privaci_target;
GRANT USAGE, CREATE ON SCHEMA public TO privaci_target;  -- or your target schema

After the first successful run, _privaci exists. Keep CREATE on the database (or equivalent) so schema upgrades and resume still work. Full state details: public state schema docs.

Network — see section 2.3 for how to pick subnet and security group IDs for CloudFormation.

  • ECS task security group → outbound TCP 5432 to both RDS instances.
  • Each RDS security group → inbound TCP 5432 from the task security group (not 0.0.0.0/0).

Do not point TARGET_DB_URL at production. Use an empty database PrivaCI owns for masked tables and _privaci.

2.1 Create Secrets Manager secrets

Never put database passwords in CloudFormation parameters, task definitions, shell history you commit, or plaintext env vars on the task. Store credentials only in AWS Secrets Manager and pass ARNs into the stack. ECS injects the current secret value at each RunTask start. If you later enable Secrets Manager automatic rotation (for example every 7 days), the next job picks up the new password with no stack change.

DbSecretFormat=RdsJson

Recommended for source and target DB secrets. Use the standard RDS / Aurora Secrets Manager JSON shape (same whether you enable automatic rotation or keep a fixed password):

{
  "username": "privaci_source",
  "password": "<rotated-by-secrets-manager>",
  "host": "source.xxx.rds.amazonaws.com",
  "port": 5432,
  "dbname": "production",
  "engine": "postgres"
}

Create the DB user/grants from section 2.0, then store that JSON in Secrets Manager (Console → “Credentials for Amazon RDS database”, or a hand-built secret with the same keys).

Automatic rotation is optional. Leave rotation off for a long-lived password, or turn it on (for example every 7 days) later — same DbSecretFormat=RdsJson and same ARNs. When rotation is on, each new RunTask loads the current password; you do not change CloudFormation.

Deploy the stack with:

DbSecretFormat=RdsJson
SourceDbSecretArn=arn:aws:secretsmanager:...:secret:privaci/source-...
TargetDbSecretArn=arn:aws:secretsmanager:...:secret:privaci/target-...
SaltSecretArn=arn:aws:secretsmanager:...:secret:privaci/salt-...

(RdsJson is the template default.) The task reads JSON keys via ECS, URL-encodes the password, and builds SOURCE_DB_URL / TARGET_DB_URL in-process before privaci run. You never paste the password into CloudFormation.

Alternative: DbSecretFormat=PlainDsn

Each secret’s entire string is a DSN (postgresql://user:pass@host:5432/db). Stock RDS automatic rotation (which rewrites JSON fields) is not compatible unless you replace it with a custom rotation Lambda that rewrites the full DSN string after each password change.

Your secret (example name) Env / keys Privaci uses Format
Source DB (RdsJson) username, password, host, port, dbname → assembled to SOURCE_DB_URL RDS JSON
Target DB (RdsJson) same keys → TARGET_DB_URL RDS JSON
Source/target (PlainDsn) SOURCE_DB_URL / TARGET_DB_URL Full DSN string
privaci/anonymization-salt ANONYMIZATION_SALT Plain string, ≥32 characters

Salt and signing keys (not DB passwords)

export AWS_REGION=us-east-1

aws secretsmanager create-secret \
  --name privaci/anonymization-salt \
  --secret-string "$(openssl rand -hex 32)"
Secret Automatic rotation? Guidance
Source / target DB (RdsJson) Optional (e.g. every 7 days) Same JSON shape with or without rotation. Enable managed RDS rotation when you want; leave it off for a static password.
Source / target DB (PlainDsn) Only with a custom rotation Lambda that rewrites the DSN string Prefer RdsJson instead
ANONYMIZATION_SALT No Changing the salt breaks deterministic masking. Do not schedule rotation.
Report signing PEM Key ceremony only Publish the new public key before relying on new signatures

Copy each returned ARN into CloudFormation (SourceDbSecretArn, TargetDbSecretArn, SaltSecretArn). Passwords never appear in the template.

Optional — signed reports (Compliance tier)

openssl genpkey -algorithm Ed25519 -out report-signing.pem
aws secretsmanager create-secret \
  --name privaci/report-signing-key-pem \
  --secret-string file://report-signing.pem

On macOS, if you see Algorithm ED25519 not found, use Homebrew OpenSSL 3 — see Signed reports.

Pass the ARN as ReportSigningKeySecretArn when deploying the stack.

2.2 Create and upload mask-rules.yaml

PrivaCI needs a masking config at /config/mask-rules.yaml inside the task. Scaffold it with privaci init (do not hand-write a full schema mapping).

Local init is optional. You can deploy the quick-launch stack (section 3) once secrets and networking are ready, then scaffold when a process in the VPC can reach the source DB. The S3 config object is only required when a masking task starts (S3Init), not when you create the stack.

From a workstation that can reach the source database

Build a real postgresql:// DSN (URL-encode special characters in the password, e.g. @%40). Do not pass a Secrets Manager ARN as SOURCE_DB_URL.

export SOURCE_DB_URL='postgresql://…'

docker run --rm --read-only --tmpfs /tmp \
  -e SOURCE_DB_URL \
  -v "$(pwd):/work" -w /work \
  <marketplace-image-uri> \
  init --source "$SOURCE_DB_URL" --output /work/mask-rules.yaml

docker run --rm --read-only --tmpfs /tmp \
  -e SOURCE_DB_URL \
  -v "$(pwd)/mask-rules.yaml:/config/mask-rules.yaml:ro" \
  <marketplace-image-uri> \
  plan --config /config/mask-rules.yaml --source "$SOURCE_DB_URL"

When the source database is only reachable from the VPC

Deploy the stack first (section 3), then scaffold with an ECS task — see section 4.

Edit as needed, then upload (S3Init mode — default) if you created the file on a workstation:

aws s3 cp mask-rules.yaml s3://MY-CONFIG-BUCKET/config/mask-rules.yaml

Use MaskRulesS3Bucket=MY-CONFIG-BUCKET and MaskRulesS3Key=config/mask-rules.yaml when deploying the stack. The execution role reads this object at task start.

Full reference: public configuration (init first). Commercial flow: quickstart.

2.3 Choose subnets and a task security group

CloudFormation does not create these. Pass existing IDs into SubnetIds and SecurityGroupIds.

Subnets (SubnetIds)

  • Use private subnets in the same VPC as source and target RDS (typical production). Fargate tasks run here.
  • Prefer at least two subnets in different AZs (comma-separated).
  • Tasks need a path to AWS APIs (Secrets Manager, ECR, License Manager): NAT gateway in the VPC, or interface VPC endpoints. Without that, the task starts then fails pulling secrets/image or checking out a license.
  • assignPublicIp on RunTask is usually DISABLED for private subnets.

Find IDs (console: VPC → Subnets, or CLI):

# List subnets in the VPC that holds your RDS instances
aws ec2 describe-subnets --filters Name=vpc-id,Values=<vpc-id> \
  --query 'Subnets[].{Id:SubnetId,AZ:AvailabilityZone,Name:Tags[?Key==`Name`].Value|[0]}' \
  --output table

Security group (SecurityGroupIds)

Create (or reuse) an ECS task security group in that VPC — not the RDS SG as the task SG.

Rule Direction Purpose
TCP 5432 to source + target RDS (or to the RDS security groups) Outbound from task SG Postgres
TCP 443 to 0.0.0.0/0 or to VPC endpoints / prefixes you use Outbound from task SG Secrets Manager, ECR, License Manager, S3
TCP 5432 from the task SG Inbound on each RDS SG Allow tasks only (not 0.0.0.0/0)
# Example: create a dedicated task SG
aws ec2 create-security-group \
  --group-name privaci-ecs-tasks \
  --description "PrivaCI Fargate tasks" \
  --vpc-id <vpc-id>

# Allow Postgres to RDS — attach this rule on the RDS security group(s):
#   Type: PostgreSQL / TCP 5432
#   Source: the privaci-ecs-tasks security group ID

Pass that SG id as SecurityGroupIds=<task-security-group-id> (one is enough for most setups; comma-separate if you need more).


3. Deploy the quick-launch stack

Download the CloudFormation template (public HTTPS URL for Marketplace Quick Launch and manual deploy):

https://docs.boundarylogic.io/commercial/assets/quick-launch.yaml

Replace the placeholders below with your values from section 2.3 and section 2.1:

curl -fsSLO https://docs.boundarylogic.io/commercial/assets/quick-launch.yaml

# SubnetIds — private subnets in the RDS VPC (2+ AZs recommended)
# SecurityGroupIds — task SG that can reach Postgres on 5432 (see section 2.3)
# EcsClusterName — existing cluster, or omit / leave empty to create <stack>-cluster
aws cloudformation deploy \
  --template-file quick-launch.yaml \
  --stack-name privaci-commercial \
  --capabilities CAPABILITY_NAMED_IAM \
  --parameter-overrides \
    EcsClusterName=my-cluster \
    SubnetIds=<subnet-id-az1>,<subnet-id-az2> \
    SecurityGroupIds=<task-security-group-id> \
    PrivaciImageUri=<marketplace-ecr-uri-from-subscription> \
    AwsRegion=us-east-1 \
    SourceDbSecretArn=arn:aws:secretsmanager:us-east-1:ACCOUNT:secret:source \
    TargetDbSecretArn=arn:aws:secretsmanager:us-east-1:ACCOUNT:secret:target \
    SaltSecretArn=arn:aws:secretsmanager:us-east-1:ACCOUNT:secret:salt \
    ConfigMode=S3Init \
    MaskRulesS3Bucket=my-config-bucket \
    MaskRulesS3Key=config/mask-rules.yaml

The stack creates:

  • Optional ECS cluster when EcsClusterName is empty (<stack-name>-cluster)
  • Task role with license-manager:CheckoutLicense + CheckInLicense
  • Execution role (secrets + S3 config read + ECR pull)
  • Fargate task definition (privaci run, non-root, read-only root)
  • Optional report task definition and EventBridge schedule

Parameter reference (all supported keys):

Parameter Required Notes
EcsClusterName No Existing cluster; empty = create <stack>-cluster
DbSecretFormat No Default **RdsJson** (RDS JSON secret; works with or without auto-rotation). Use PlainDsn only for a full postgresql:// secret string
SubnetIds Yes Private subnets in the RDS VPC (2+ AZs). See section 2.3.
SecurityGroupIds Yes Task SG with Postgres egress; RDS allows 5432 from this SG. See section 2.3.
PrivaciImageUri Yes Marketplace ECR URI
AwsRegion No Default us-east-1
PrivaciProductSku No Staging override only
SourceDbSecretArn / TargetDbSecretArn / SaltSecretArn Yes Secrets Manager ARNs only — never password literals
ConfigMode No S3Init (default) or Efs
MaskRulesS3Bucket / MaskRulesS3Key S3Init Config object
EfsFileSystemId / EfsAccessPointId Efs Mount at /config
ReportSigningKeySecretArn No Compliance signed reports
ReportBucketName / ReportBucketPrefix No Scoped report output
EnableScheduledRuns No Default false

4. Scaffold mask-rules.yaml via ECS

Use this when the source DB is only reachable from the VPC. Your workstation needs the AWS CLI (and permission to ecs:RunTask); it does not need Postgres connectivity.

The quick-launch task definition defaults to privaci run. Override the container command to run init and upload the YAML to the same S3 object the stack already reads (MASK_RULES_S3_BUCKET / MASK_RULES_S3_KEY).

One-time IAM: the task role can s3:GetObject that key by default. For this scaffold step, also allow s3:PutObject on that object (attach to <stack>-privaci-task, then remove later if you want).

STACK=privaci-commercial
CLUSTER=$(aws cloudformation describe-stacks --stack-name "$STACK" \
  --query "Stacks[0].Outputs[?OutputKey=='EcsClusterName'].OutputValue" --output text)
TASK_DEF=$(aws cloudformation describe-stacks --stack-name "$STACK" \
  --query "Stacks[0].Outputs[?OutputKey=='RunTaskDefinitionArn'].OutputValue" --output text)

# Same subnet / security group IDs you passed to CloudFormation
SUBNETS='<subnet-id-az1>,<subnet-id-az2>'
SGS='<task-security-group-id>'

OVERRIDE=$(python3 <<'PY'
import json

script = r"""
import os, subprocess, sys
from urllib.parse import quote

def dsn(prefix: str) -> str:
    user = os.environ[f"{prefix}_USERNAME"]
    password = quote(os.environ[f"{prefix}_PASSWORD"], safe="")
    host = os.environ[f"{prefix}_HOST"]
    port = os.environ.get(f"{prefix}_PORT", "5432")
    name = os.environ[f"{prefix}_NAME"]
    return f"postgresql://{user}:{password}@{host}:{port}/{name}"

if "SOURCE_DB_USERNAME" in os.environ:
    os.environ["SOURCE_DB_URL"] = dsn("SOURCE_DB")
elif not os.environ.get("SOURCE_DB_URL"):
    sys.exit("SOURCE_DB_URL not set — check DbSecretFormat / source secret")

out = "/tmp/mask-rules.yaml"
subprocess.check_call(
    ["privaci", "init", "--source", os.environ["SOURCE_DB_URL"], "--output", out]
)
bucket = os.environ.get("MASK_RULES_S3_BUCKET")
key = os.environ.get("MASK_RULES_S3_KEY")
if not bucket or not key:
    sys.exit("MASK_RULES_S3_BUCKET / MASK_RULES_S3_KEY missing on the task")
import boto3
boto3.client("s3").upload_file(out, bucket, key)
print(f"Uploaded s3://{bucket}/{key}")
"""

print(json.dumps({"containerOverrides": [{"name": "privaci", "command": [script]}]}))
PY
)

aws ecs run-task \
  --cluster "$CLUSTER" \
  --task-definition "$TASK_DEF" \
  --launch-type FARGATE \
  --network-configuration \
    "awsvpcConfiguration={subnets=[$SUBNETS],securityGroups=[$SGS],assignPublicIp=DISABLED}" \
  --overrides "$OVERRIDE"

Wait until the task stops (exit code 0). Confirm the object exists:

aws s3 cp "s3://<MaskRulesS3Bucket>/<MaskRulesS3Key>" ./mask-rules.yaml

Review/edit locally if needed, re-upload, then run a normal masking job (section 5) without the override.

Logs: stack output LogGroupName.


5. Run your first masking job

STACK=privaci-commercial
CLUSTER=$(aws cloudformation describe-stacks --stack-name "$STACK" \
  --query "Stacks[0].Outputs[?OutputKey=='EcsClusterName'].OutputValue" --output text)
TASK_DEF=$(aws cloudformation describe-stacks --stack-name "$STACK" \
  --query "Stacks[0].Outputs[?OutputKey=='RunTaskDefinitionArn'].OutputValue" --output text)

aws ecs run-task \
  --cluster "$CLUSTER" \
  --task-definition "$TASK_DEF" \
  --launch-type FARGATE \
  --network-configuration \
    "awsvpcConfiguration={subnets=[<subnet-id>],securityGroups=[<task-security-group-id>],assignPublicIp=DISABLED}"

Expected: task exit code 0, log line Run <uuid> succeeded. Exit 5Licensing troubleshooting.

Logs: stack output LogGroupName (typically /privaci/privaci-commercial).


6. Optional: signed compliance report

Requires Compliance tier plus a signing key in Secrets Manager. Deploy with ReportSigningKeySecretArn, ReportBucketName, and ReportBucketPrefix set, then RunTask the report definition with a RUN_ID environment override on the report container.

Details: Signed reports.


7. Optional: scheduled runs

Redeploy (or create the stack) with:

EnableScheduledRuns=true
ScheduleExpression=cron(0 2 * * ? *)

Default is manual RunTask only.


Manual task definition (without CloudFormation)

If you prefer Terraform or click-ops, mirror the stack:

Setting Value
Launch type Fargate
Task role iam-task-role.json
Execution role AmazonECSTaskExecutionRolePolicy + secretsmanager:GetSecretValue on your ARNs
Image Marketplace ECR URI
User 10001 (non-root)
Readonly root true + writable /tmp volume
Env AWS_REGION only (no dev license, no product code)
Secrets DSNs + salt via secrets block
Command privaci run --config …

Troubleshooting

Symptom Check
Exit 5 at start Active subscription? Task role allows CheckoutLicense? Correct AWS_REGION?
Cannot pull image ECR login via execution role; use subscription fulfillment URI
Config not found S3Init bucket/key or EFS mount at /config/mask-rules.yaml
Exit 2 — privileges Source SELECT / target CREATE ON DATABASE — see section 2.0
Auth failed after password rotation Confirm DbSecretFormat matches the secret shape (RdsJson for managed RDS JSON). Next RunTask must start after rotation completes.
Exit 5 — feature Config uses a Compliance-only capability on Standard tier

See Troubleshooting.