Reports, Briefings, and Scheduled Delivery
Configure the AWS report scheduler, S3 artifact storage, PDF generation, and outbound email for self-hosted Semaphor
Self-hosted Semaphor uses the Semaphor Report Scheduler AWS SAM stack for PDF and CSV rendering, scheduled Automation execution, Briefing analysis, asynchronous exports, and outbound report email. The stack creates the Lambda functions, Step Functions state machines, EventBridge rules, and private S3 bucket these features require.
The app container is not the email sender
Setting RESEND_API_KEY in the Semaphor app does not configure Briefing or
scheduled-report email. Those messages are sent by the scheduler stack. The
app must have BRIEFINGS_EMAIL_SENDER_URL and the same LAMBDA_API_KEY used
by that stack.
What this setup enables
- On-demand dashboard and document-sheet PDF exports
- Briefing and scheduled-report PDF or CSV attachments
- Run now and recurring delivery by email
- AI-generated Briefing analysis
- EventBridge-driven Automation execution
- Asynchronous large CSV exports
Prerequisites
- A working self-hosted Semaphor deployment with a public HTTPS URL
- AWS CLI credentials that can deploy CloudFormation, Lambda, IAM, S3, Step Functions, EventBridge, and CloudWatch resources
- AWS SAM CLI, Docker, Node.js 22, and npm on the machine used to deploy the scheduler
- Either:
- an SES verified sender in the deployment region, with production access for unrestricted recipients; or
- a Resend account and verified sender domain
- An OpenAI API key if you want AI-generated Briefings
1. Deploy the report scheduler
Clone and configure the scheduler:
git clone https://github.com/semaphor-analytics/report-scheduler.git
cd report-scheduler
cp .env.example .envGenerate a shared service key:
openssl rand -hex 32The repository's .env.example is the canonical scheduler configuration.
Replace .env with the complete template below. Required settings are active.
Optional features and the alternative Resend provider are present but commented
out so customers can discover them without accidentally enabling them.
# ===== Required: Semaphor connection =====
# Public HTTPS URL with no trailing slash. Lambda must be able to reach it.
SEMAPHOR_APP_URL=https://semaphor.yourdomain.com
# Generate with: openssl rand -hex 32
# Copy this exact value to LAMBDA_API_KEY in the Semaphor app environment.
LAMBDA_API_KEY=replace-with-the-generated-key
# ===== Required for email: choose one provider =====
# Amazon SES is the default. The sender must be verified in this region.
EMAIL_PROVIDER_MODE=SES
SES_REGION=us-east-1
SES_SENDER_EMAIL=reports@yourdomain.com
# To use the same-stack Resend provider instead of SES, change
# EMAIL_PROVIDER_MODE above to EXTERNAL and uncomment the three values below.
# SES_REGION and SES_SENDER_EMAIL remain present as deployment parameters but
# are not used in EXTERNAL mode.
# EMAIL_EXTERNAL_AUTH_SECRET=replace-with-another-random-secret
# RESEND_API_KEY=re_...
# RESEND_SENDER_EMAIL=reports@yourdomain.com
# ===== Optional: AI-generated Briefings =====
# Leave all four commented if you only use custom-message reports.
# INSIGHT_LOOP_MODEL_PROVIDER=openai
# INSIGHT_LOOP_MODEL=gpt-5.5
# INSIGHT_LOOP_REASONING_EFFORT=medium
# OPENAI_API_KEY=sk-...SEMAPHOR_APP_URL must be the externally reachable HTTPS URL. Lambda uses it for Automation execution and Briefing progress, completion, and failure callbacks. Keep every variable in this one file; uncomment optional settings only when the corresponding feature is enabled.
Deploy the stack:
./deploy.shThe first deployment may prompt for AWS region and CloudFormation capabilities. Keep the scheduler and Semaphor app in the same region unless you have a deliberate cross-region design.
2. Record the stack outputs
sam list stack-outputs --stack-name semaphor-report-schedulerRecord these outputs. Whether each value is required depends on the capability you enable:
| Stack output | Semaphor app variable | Required when |
|---|---|---|
S3BucketName | S3_EXPORTS_BUCKET | Using Briefings, scheduled reports, or asynchronous exports |
GeneratePdfFunctionUrl | PDF_FUNCTION_URL | Generating PDF or CSV attachments and on-demand PDFs |
EmailSenderFunctionUrl | BRIEFINGS_EMAIL_SENDER_URL | Delivering reports by email |
InsightRunnerIngressFunctionUrl | BRIEFINGS_RUNNER_URL | Using AI-generated Briefings |
ExportStateMachineArn | EXPORT_STATE_MACHINE_ARN | Using asynchronous large exports |
Keep the Function URLs private as configuration values. The functions also validate the shared LAMBDA_API_KEY where required.
3. Give the Semaphor app access to S3
BRIEFINGS_ARTIFACT_STORAGE=s3 makes the app write Briefing and scheduled-report run artifacts under briefings/ in the scheduler bucket. The app therefore needs AWS credentials with access to that prefix.
On EC2, attach an instance profile to the Semaphor instance. Use long-lived access keys only when an IAM role is not available. A minimum policy for Briefing artifacts is:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "SemaphorBriefingArtifacts",
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": "arn:aws:s3:::YOUR_S3_BUCKET/briefings/*"
}
]
}If you also enable asynchronous exports, add these entries to the policy's Statement array:
[
{
"Sid": "SemaphorExportDownloads",
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::YOUR_S3_BUCKET/exports/*"
},
{
"Sid": "SemaphorStartExports",
"Effect": "Allow",
"Action": "states:StartExecution",
"Resource": "YOUR_EXPORT_STATE_MACHINE_ARN"
}
]If the app runs in Docker on EC2 and cannot obtain instance-profile credentials, set the instance metadata response hop limit to 2, or provide credentials to the container through your secrets manager. Do not commit AWS keys to source control.
4. Configure the Semaphor app
Keep all Semaphor-side report settings in one block in semaphor.env. Required
settings are active below; optional capabilities are present but commented out.
AWS_REGION=us-east-1
BRIEFINGS_ARTIFACT_STORAGE=s3
S3_EXPORTS_BUCKET=semaphor-reports-123456789012-semaphor-report-scheduler
PDF_FUNCTION_URL=https://example.lambda-url.us-east-1.on.aws/
BRIEFINGS_EMAIL_SENDER_URL=https://example.lambda-url.us-east-1.on.aws/
# Must exactly match the scheduler stack value
LAMBDA_API_KEY=replace-with-the-generated-key
# Optional: the From address Brand Studio shows admins as the sending address.
# Display only; set it to the address your scheduler actually sends from.
# BRIEFINGS_EMAIL_SENDER_FROM_ADDRESS=reports@yourdomain.com
# Optional: AI-generated Briefings
# BRIEFINGS_RUNNER_URL=https://example.lambda-url.us-east-1.on.aws/
# Optional: asynchronous large exports
# EXPORT_STATE_MACHINE_ARN=arn:aws:states:us-east-1:123456789012:stateMachine:example
# Optional: use only when the app has no EC2 instance profile or workload role.
# Prefer injecting these through a secrets manager instead of storing them here.
# AWS_ACCESS_KEY_ID=AKIA...
# AWS_SECRET_ACCESS_KEY=...
# AWS_SESSION_TOKEN=...When the app does not run with an EC2 instance profile, uncomment the AWS credential variables and provide them through your deployment's secret-management path.
Restart the app container after changing the environment:
docker-compose down
docker-compose up -d5. Verify configuration without printing secrets
From the directory containing docker-compose.yml, run:
docker-compose exec api-service node -e '
const names = [
"AWS_REGION",
"S3_EXPORTS_BUCKET",
"PDF_FUNCTION_URL",
"BRIEFINGS_EMAIL_SENDER_URL",
"LAMBDA_API_KEY"
];
for (const name of names) {
console.log(`${name}=${process.env[name] ? "configured" : "MISSING"}`);
}
for (const name of ["BRIEFINGS_RUNNER_URL", "EXPORT_STATE_MACHINE_ARN"]) {
console.log(`${name}=${process.env[name] ? "configured" : "not configured (optional)"}`);
}
console.log(`BRIEFINGS_ARTIFACT_STORAGE=${process.env.BRIEFINGS_ARTIFACT_STORAGE || "production default (s3)"}`);
'Then test in this order:
- Export a dashboard or document sheet to PDF.
- Create a custom-message report with one email recipient and one PDF attachment, then click Run now.
- Create an AI-generated Briefing and run it.
- Create a recurring schedule and confirm the next EventBridge-driven run.
This order separates PDF, storage, email, runner, and scheduler problems.
Branded report email
Organization admins can put their own name, logo, accent color, intro, and footer on every scheduled report and Briefing email, with an optional reply-to address, BCC mailbox, and app button. The customer-facing description is in Branded Report Emails. This section covers what a self-hosted deployment needs so those settings take effect.
Branding is applied by the scheduler stack's EmailSenderFunction, not by the
app container. An organization with no email branding saved is unaffected:
its emails, provider requests, and sender are exactly what they are today.
Deployment order
Deploy the scheduler release that supports branding before, or together with, the app release that offers it. The app sends branded email with a new request action that older scheduler releases reject before anything is sent, so an outdated scheduler fails closed: a scheduled report from a branded organization does not go out, and Send test email in Brand Studio reports that the email service is out of date. Unbranded organizations continue to deliver through either release.
- Pull the current scheduler release and run
./deploy.sh. - Upgrade the Semaphor app.
- In Brand Studio, open Email reports, set up branding, and use Send test email to confirm delivery.
Amazon SES
No additional configuration is required. The From address stays
SES_SENDER_EMAIL; branding supplies the display name, and the optional
reply-to and BCC are applied by the sender function. BCC is passed to SES as
an envelope recipient and never appears in the message headers recipients see.
The EmailSenderFunction role already holds ses:SendRawEmail. If you manage
that policy yourself, no new SES permission is needed for branding.
Bundled Resend provider
Branded email uses version 2 of the signed contract between
EmailSenderFunction and ResendProviderFunction. Configure Resend normally:
EMAIL_PROVIDER_MODE=EXTERNAL
EMAIL_EXTERNAL_AUTH_SECRET=replace-with-another-random-secret
RESEND_API_KEY=re_...
RESEND_SENDER_EMAIL=reports@yourdomain.comThe sender automatically adds the version 2 field group only when branding is present:
RESEND_SENDER_EMAILremains the sending address. The organization's From name is composed around it as the display name, for exampleAcme Reports <reports@yourdomain.com>.- Reply-to and BCC from the organization's branding are passed to Resend.
- Unbranded organizations are unchanged: their requests to the provider carry no version 2 fields.
Verify and use your sending domain in your Resend account as you do today. Semaphor's SES-based custom sending domain setup does not apply to Resend deployments.
Contract version 2 fields
A branded request adds contractVersion: 2, a required fromName, and
optional replyTo and bcc to the existing signed webhook body. The
from, to, subject, text, html, attachments, and metadata
fields keep their current meanings. The bundled Resend function rejects a
request that carries any of the four branding fields without
contractVersion: 2 and a valid fromName. Unbranded requests are
byte-for-byte what they were before.
Default From address in Brand Studio
Brand Studio shows admins a read-only From address so they know what
recipients will see. Set BRIEFINGS_EMAIL_SENDER_FROM_ADDRESS in
semaphor.env to the address your scheduler sends from (SES_SENDER_EMAIL
or RESEND_SENDER_EMAIL). It is display only; the scheduler stack decides the
actual sender. When unset, the app shows noreply@semaphor.cloud.
Organization logo in email
Emails reuse the organization logo. It appears in email only when it is an HTTPS PNG, JPEG, or GIF; otherwise the email shows the From name as text and Brand Studio explains why. Admins can upload the same organization logo from Email reports or Organization settings. Because email clients fetch the image when the message is opened, the logo URL must be reachable from the public internet, not only from inside your network.
Custom sending domain
Organization admins can configure and verify a sending domain before saving email branding. Semaphor keeps using the provider's default address until the domain is verified, then immediately sends report email from the configured address whether branding is saved or not. The admin experience is described in Custom Sending Domain: enter and submit the complete From address, add the three CNAME records Semaphor returns, then press Check again. This section covers what the deployment needs and how self-service correction and removal work.
How it works
- Setup and status checks run through the scheduler's
EmailSenderFunctionin the same AWS account and region that sends mail. Setup creates the domain identity in SES and returns its three DKIM CNAME records; Check again reads the identity's current status and stores it. When SES has already given up on the records (it searches for 72 hours after setup and then marks Easy DKIM failed), Check again restarts that search with the same records, so an admin who published DNS late does not have to start over. Only identities that use Easy DKIM are restarted. If your team moved the identity to Bring Your Own DKIM or to Easy DKIM keys from another region, Check again reads its status and leaves its DKIM setup unchanged. - Only Check again changes a stored status. Opening Brand Studio and sending a report perform no SES or DNS calls; delivery reads the stored status from the database and nothing else.
PENDINGandFAILEDdomains keep sending from the provider's default address (SES_SENDER_EMAIL) with the organization's branding intact.VERIFIEDdomains send from the configured custom address.- Check again stays available after verification. If it finds the domain
no longer verified, the status becomes
FAILEDand later sends return to the default address. If DNS records are removed and nobody checks, SES rejects those sends and the affected reports fail; Semaphor does not fall back to another address on the send path, which avoids duplicate delivery after an ambiguous provider response. - A domain belongs to exactly one organization; a second organization cannot configure the same domain. An identity that already exists in SES when an organization first sets it up is refused, and the admin is told to contact support.
Amazon SES
The scheduler release that supports custom domains adds ses:CreateEmailIdentity,
ses:GetEmailIdentity, ses:PutEmailIdentityDkimSigningAttributes (to
restart a failed Easy DKIM search), and ses:DeleteEmailIdentity to the
EmailSenderFunction role. It does not add tag or list permissions. No other
configuration is required: there is no feature flag,
allowlist, or per-organization enablement.
Bundled Resend provider
Custom domains through Semaphor apply to SES only. On a deployment in
EXTERNAL mode, branded email continues to work exactly as described above,
but the sending address is RESEND_SENDER_EMAIL, and the domain it belongs to
is added and verified in your Resend account. When an admin presses Use your
own domain on such a deployment, the dialog answers "Set up the sending
domain in your email provider." and nothing is stored.
Correcting or removing a sending domain
Organization admins manage this from Brand Studio → Email reports. Pending and Failed setups with DNS records expose Start over. A zero-record recovery state requires Check again first. Verified domains expose Remove. Both require confirmation.
The app first makes a Verified row inactive so subsequent sends use the default
address. The existing authenticated EmailSenderFunction then deletes the
exact SES identity, and the app deletes the exact organization/domain row only
after provider cleanup succeeds. SES "not found" is treated as already removed,
so retrying after a lost response is safe. If provider or database cleanup
cannot be confirmed, Brand Studio retains the inactive row and the admin can
retry. There is no separate maintenance route, CLI, tag, lease, or recovery
service.
Before offering custom domains broadly
All custom-domain identities currently send from one SES account and share its reputation. Before making custom sending domains generally available to many organizations, place each organization's identity in its own SES tenant so a single organization's bounces or complaints cannot affect the others. This tenant management is a required follow-up to the first release, not part of it, and the first release is intended for a small, known set of organizations.
Troubleshooting
This domain already exists in the email service. Contact support.
An admin tried to set up a domain whose identity already exists in the SES account, usually from an earlier setup that was removed from Semaphor without step 3 of the runbook, or from another system that uses the same account. Confirm ownership with the admin, delete the identity in SES, then have the admin set the domain up again.
Email service is out of date
Brand Studio's Send test email returned this, or a branded organization's
scheduled reports fail with it. The Semaphor app was upgraded before the
scheduler. Pull the current scheduler release, run ./deploy.sh, and retry.
Unbranded organizations are not affected.
BRIEFING_ARTIFACT_STORAGE_FAILED
The app could not write a generated artifact to S3. Check the S3_EXPORTS_BUCKET value, AWS_REGION, container credentials, and s3:PutObject permission on briefings/*.
docker-compose logs api-service | grep -A 12 "\[briefings\] Artifact storage failed"The structured log includes the AWS error name, status code, storage mode, and whether the bucket was configured.
BRIEFINGS_EMAIL_SENDER_URL is not configured
Set BRIEFINGS_EMAIL_SENDER_URL to the scheduler stack's EmailSenderFunctionUrl output, set LAMBDA_API_KEY to the same value used during scheduler deployment, and recreate the app container. RESEND_API_KEY in semaphor.env does not replace this URL.
Email sender returns 401 or 403
Confirm that LAMBDA_API_KEY matches exactly in the scheduler stack and Semaphor app. Redeploy the scheduler after changing its key, then restart the app.
External provider response missing success=true
The Semaphor app reached EmailSenderFunction, and that function received a
successful HTTP status from its external email provider, but the response body
was empty, was not JSON, or did not contain a top-level success: true. For the
same-stack Resend setup, this usually means the scheduler deployment is stale
or the sender is wired to an endpoint that does not implement the current
provider contract.
- Confirm that
EMAIL_PROVIDER_MODE=EXTERNALand thatEMAIL_EXTERNAL_AUTH_SECRET,RESEND_API_KEY, andRESEND_SENDER_EMAILare set in the scheduler.env. - Pull the current scheduler release and run
./deploy.shagain. This updatesEmailSenderFunctionandResendProviderFunctiontogether and gives both functions the same external-auth secret. - Run
sam list stack-outputs --stack-name semaphor-report-schedulerand confirm that the Semaphor app'sBRIEFINGS_EMAIL_SENDER_URLis theEmailSenderFunctionUrloutput, notResendProviderFunctionUrl. - Retry the report. If it still fails, inspect both functions for the same run:
sam logs -n EmailSenderFunction --stack-name semaphor-report-scheduler --tail
sam logs -n ResendProviderFunction --stack-name semaphor-report-scheduler --tailDo not rotate only one copy of EMAIL_EXTERNAL_AUTH_SECRET. If the two
functions have different values, redeploy the stack from one .env so
CloudFormation updates both together.
SES accepts the request but email is not delivered
- Verify the sender identity in the same region configured by
SES_REGION. - If the SES account is still in the sandbox, verify each recipient or request production access.
- Review
EmailSenderFunctionlogs in CloudWatch.
sam logs -n EmailSenderFunction --stack-name semaphor-report-scheduler --tailBRIEFINGS_RUNNER_URL is not configured
Set BRIEFINGS_RUNNER_URL to InsightRunnerIngressFunctionUrl. If the runner starts but analysis fails, verify that the scheduler was deployed with a valid OPENAI_API_KEY and review both Insight Runner Lambda logs.
PDF attachment generation fails
Set PDF_FUNCTION_URL to GeneratePdfFunctionUrl, then verify GeneratePdfFunction logs. The Function URL must be reachable from the Semaphor app.
A recurring report does not start
Confirm the scheduler stack's Automation EventBridge rule is enabled and that Lambda can reach SEMAPHOR_APP_URL over HTTPS. Review AutomationDispatcherFunction logs.
sam logs -n AutomationDispatcherFunction --stack-name semaphor-report-scheduler --tailS3 retention
The scheduler stack configures lifecycle rules for its pdfs/, emails/, and exports/ prefixes. Configure an S3 lifecycle rule for briefings/ that matches your organization's retention requirements. Keep the bucket private; artifact access should continue through Semaphor's permission-checked routes.