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):
- Console (recommended): AWS Console → License Manager →
Start using License Manager → grant permissions (creates
AWSServiceRoleForAWSLicenseManagerRole). - 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.
assignPublicIpon RunTask is usuallyDISABLEDfor 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
EcsClusterNameis 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 5 →
Licensing 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.
Related¶
- Quickstart — conceptual walkthrough
- Drift detection — CI gate after deploy
- Public Helm chart — Kubernetes CronJob patterns (on request for commercial env wiring)