Semaphor
Self-Hosting

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 .env

Generate a shared service key:

openssl rand -hex 32

The 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.sh

The 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-scheduler

Record these outputs. Whether each value is required depends on the capability you enable:

Stack outputSemaphor app variableRequired when
S3BucketNameS3_EXPORTS_BUCKETUsing Briefings, scheduled reports, or asynchronous exports
GeneratePdfFunctionUrlPDF_FUNCTION_URLGenerating PDF or CSV attachments and on-demand PDFs
EmailSenderFunctionUrlBRIEFINGS_EMAIL_SENDER_URLDelivering reports by email
InsightRunnerIngressFunctionUrlBRIEFINGS_RUNNER_URLUsing AI-generated Briefings
ExportStateMachineArnEXPORT_STATE_MACHINE_ARNUsing 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 -d

5. 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:

  1. Export a dashboard or document sheet to PDF.
  2. Create a custom-message report with one email recipient and one PDF attachment, then click Run now.
  3. Create an AI-generated Briefing and run it.
  4. 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.

  1. Pull the current scheduler release and run ./deploy.sh.
  2. Upgrade the Semaphor app.
  3. 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.com

The sender automatically adds the version 2 field group only when branding is present:

  • RESEND_SENDER_EMAIL remains the sending address. The organization's From name is composed around it as the display name, for example Acme 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 EmailSenderFunction in 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.
  • PENDING and FAILED domains keep sending from the provider's default address (SES_SENDER_EMAIL) with the organization's branding intact. VERIFIED domains send from the configured custom address.
  • Check again stays available after verification. If it finds the domain no longer verified, the status becomes FAILED and 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.

  1. Confirm that EMAIL_PROVIDER_MODE=EXTERNAL and that EMAIL_EXTERNAL_AUTH_SECRET, RESEND_API_KEY, and RESEND_SENDER_EMAIL are set in the scheduler .env.
  2. Pull the current scheduler release and run ./deploy.sh again. This updates EmailSenderFunction and ResendProviderFunction together and gives both functions the same external-auth secret.
  3. Run sam list stack-outputs --stack-name semaphor-report-scheduler and confirm that the Semaphor app's BRIEFINGS_EMAIL_SENDER_URL is the EmailSenderFunctionUrl output, not ResendProviderFunctionUrl.
  4. 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 --tail

Do 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 EmailSenderFunction logs in CloudWatch.
sam logs -n EmailSenderFunction --stack-name semaphor-report-scheduler --tail

BRIEFINGS_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 --tail

S3 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.

On this page