Ship logs to Amazon S3
Use this guide to archive your Audit Logs to an Amazon S3 bucket as NDJSON objects. Firezone writes objects by assuming an IAM role in your AWS account. No static credentials are stored or exchanged.
Step 1: Start creating the Log Sink in Firezone
In the admin portal, go to SettingsLog Sinks, click Add, and select Amazon S3.
The form displays an External ID, a UUID generated by Firezone and pinned
to this sink. The IAM role you create next must require it via an
sts:ExternalId condition. The trust policy and snippets shown in the portal
are already pinned to it, so complete the AWS setup and save without leaving
the page.
Step 2: Create the bucket and IAM role in AWS
The portal provides the exact policies and scripts with your External ID and Firezone's AWS account ID filled in, so prefer copying from there. Choose your tool:
-
In the S3 console, create a bucket or pick an existing one, and note its region.
-
In IAM under Roles, choose Create role → Custom trust policy and paste:
{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": { "AWS": "arn:aws:iam::<firezone-aws-account-id>:root" }, "Action": "sts:AssumeRole", "Condition": { "StringEquals": { "sts:ExternalId": "<sink-external-id>" } } } ] }Continue without adding permissions and give the role a name, for example
firezone-logs. -
On the new role, choose Add permissions → Create inline policy → JSON and paste:
{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": "s3:PutObject", "Resource": "arn:aws:s3:::<bucket>/*" } ] }If you plan to set a key prefix on the sink, scope the resource to
arn:aws:s3:::<bucket>/<key-prefix>/*instead. -
Note the role's ARN, shown at the top of the role's page.
Set the variables, then run the script with the AWS CLI logged into your account. It prints the Role ARN for the Firezone form:
BUCKET="my-firezone-logs"
REGION="us-east-1"
ROLE="firezone-logs"
if ! aws s3api head-bucket --bucket "$BUCKET" > /dev/null 2>&1; then
if [ "$REGION" = "us-east-1" ]; then
aws s3api create-bucket --bucket "$BUCKET" --region "$REGION" > /dev/null
else
aws s3api create-bucket --bucket "$BUCKET" --region "$REGION" \
--create-bucket-configuration LocationConstraint="$REGION" > /dev/null
fi
fi
cat > firezone-trust.json <<EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::<firezone-aws-account-id>:root" },
"Action": "sts:AssumeRole",
"Condition": { "StringEquals": { "sts:ExternalId": "<sink-external-id>" } }
}
]
}
EOF
if aws iam get-role --role-name "$ROLE" > /dev/null 2>&1; then
aws iam update-assume-role-policy --role-name "$ROLE" \
--policy-document file://firezone-trust.json
else
aws iam create-role --role-name "$ROLE" \
--assume-role-policy-document file://firezone-trust.json > /dev/null
fi
aws iam put-role-policy --role-name "$ROLE" --policy-name put-objects \
--policy-document "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":\"s3:PutObject\",\"Resource\":\"arn:aws:s3:::$BUCKET/*\"}]}"
echo "Enter these in the Firezone form:"
echo " Bucket: $BUCKET"
echo " Region: $REGION"
echo " Role ARN: $(aws iam get-role --role-name "$ROLE" --query Role.Arn --output text)"
Requires the aws provider. The bucket is created in the provider's region.
The output is the Role ARN for the Firezone form:
locals {
bucket = "my-firezone-logs"
}
resource "aws_s3_bucket" "firezone_logs" {
bucket = local.bucket
}
resource "aws_iam_role" "firezone_logs" {
name = "firezone-logs"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Principal = { AWS = "arn:aws:iam::<firezone-aws-account-id>:root" }
Action = "sts:AssumeRole"
Condition = { StringEquals = { "sts:ExternalId" = "<sink-external-id>" } }
}
]
})
}
resource "aws_iam_role_policy" "firezone_logs_put" {
name = "put-objects"
role = aws_iam_role.firezone_logs.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = "s3:PutObject"
Resource = "${aws_s3_bucket.firezone_logs.arn}/*"
}
]
})
}
output "role_arn" {
value = aws_iam_role.firezone_logs.arn
}
Firezone verifies the External ID is actually enforced. Before the first delivery, it attempts to assume the role with a random External ID. If that succeeds, Firezone refuses to deliver and disables the sink with a configuration error. This protects you from a confused-deputy misconfiguration where any Firezone customer could write to your bucket.
Step 3: Configure the sink
Back in the Firezone form, fill in:
| Field | Description |
|---|---|
| Name | A name to identify this log sink. |
| Bucket | The name of the S3 bucket to write log objects to. |
| Region | The AWS region the bucket is in, for example us-east-1. |
| Role ARN | The IAM role in your AWS account that Firezone assumes to write objects, for example arn:aws:iam::123456789012:role/firezone-logs. |
| Key Prefix | Optional. A prefix added to every object key, for example firezone/logs. |
| External ID | Read-only. Generated by Firezone and pinned to this sink. |
Then choose which Log streams to deliver, all four of which are enabled by default, and optionally check Deliver existing logs to backfill logs recorded before the sink was created.
Deliver existing logs can only be selected when you create the sink. It can't be turned on later for an existing sink.
Click Create. Deliveries begin within about a minute.
Delivery format
Each delivered batch becomes one NDJSON object with one raw
event per line and no envelope, written with content type
application/x-ndjson to a deterministic key:
<key-prefix>/<stream>/<YYYY>/<MM>/<DD>/<first-seq>-<last-seq>.ndjson
For example: firezone/logs/session/2026/07/14/8123-8390.ndjson. The date
comes from the first event in the batch.
One line of change/2026/07/14/4211-4211.ndjson:
{
"type": "change",
"log_id": "c0654d20aa1b4a0000000002",
"timestamp": "2026-07-14T09:26:31.114213Z",
"object": "policies",
"operation": "update",
"before": { "id": "0a83588e-…", "description": "Engineering → GitLab" },
"after": {
"id": "0a83588e-…",
"description": "Engineering → GitLab (MFA required)"
},
"subject": {
"actor_id": "e2f6a9c4-…",
"actor_name": "Jamie Admin",
"actor_email": "jamie@company.com",
"actor_type": "account_admin_user",
"auth_provider_id": "9d0c2f4e-…",
"ip": "203.0.113.44",
"ip_region": "US",
"ip_city": "San Francisco",
"ip_lat": 37.7749,
"ip_lon": -122.4194,
"user_agent": "Mozilla/5.0 …"
}
}
One line of session/2026/07/14/8123-8390.ndjson:
{
"type": "session",
"log_id": "53f2c8a91b7e4d20a6c19e04",
"timestamp": "2026-07-14T09:26:31.114213Z",
"context": "client",
"subject": {
"actor_id": "7a1d9e3c-…",
"actor_name": "Riley Engineer",
"actor_email": "riley@company.com",
"actor_type": "account_user",
"auth_provider_id": "9d0c2f4e-…",
"device_id": "2b4d6f8a-…",
"token_id": "c4e6a8b0-…",
"ip": "203.0.113.44",
"ip_region": "US",
"ip_city": "San Francisco",
"ip_lat": 37.7749,
"ip_lon": -122.4194,
"user_agent": "Firezone/1.4.0 (macOS 15.5; arm64)"
}
}
One line of api_request/2026/07/14/5567-5567.ndjson:
{
"type": "api_request",
"log_id": "a7b3e91c4d208f5a6e1b2c3d",
"timestamp": "2026-07-14T09:26:31.114213Z",
"actor_id": "b8e0c2a4-…",
"api_token_id": "d0f2a4c6-…",
"method": "GET",
"path": "/policies",
"content_length": 0,
"request_id": "F8nMlbf6MUyJZUUABBzB9-yT",
"user_agent": "terraform-provider-firezone/0.4.1",
"ip": "203.0.113.44",
"ip_region": "US",
"ip_city": "San Francisco",
"ip_lat": 37.7749,
"ip_lon": -122.4194
}
Each flow is delivered as two events sharing its log_id. The start event
is suffixed with -s and carries no counters, while the end event is
suffixed with -e and carries the final totals. One line of
flow/2026/07/14/9001-9001.ndjson:
{
"type": "flow",
"log_id": "f4e8a2c61b09d735e2a48b17-e",
"timestamp": "2026-07-14T09:26:01.000000Z",
"flow_start": "2026-07-14T09:25:31.000000Z",
"flow_end": "2026-07-14T09:26:01.000000Z",
"last_packet": "2026-07-14T09:26:01.000000Z",
"device_id": "2b4d6f8a-…",
"role": "initiator",
"policy_authorization_id": "6c8e0a2b-…",
"policy_id": "0a83588e-…",
"resource_id": "5c8f6f6e-…",
"resource_name": "GitLab",
"resource_address": "gitlab.company.com",
"actor_id": "7a1d9e3c-…",
"actor_email": "riley@company.com",
"actor_name": "Riley Engineer",
"client_version": "1.4.0",
"device_os_name": "iOS",
"device_os_version": "17.4",
"protocol": "tcp",
"inner_src_ip": "100.64.0.1",
"inner_src_port": 54321,
"inner_dst_ip": "10.0.0.5",
"inner_dst_port": 443,
"outer_src_ip": "203.0.113.10",
"outer_src_port": 51820,
"outer_dst_ip": "198.51.100.5",
"outer_dst_port": 51820,
"domain": "gitlab.company.com",
"rx_packets": 100,
"tx_packets": 80,
"rx_bytes": 102400,
"tx_bytes": 20480
}
How Firezone responds to HTTP status codes
| Response | Behavior |
|---|---|
2xx | The object is written and delivery advances. |
429, 500, 502, 503, 504 | Retried every minute, including S3 SlowDown. If failures persist for 24 hours, the sink is disabled and account admins are emailed. |
413 | The batch is split in half and retried to isolate an oversized event. |
Other 4xx such as 403 AccessDenied, STS errors, or a failed External ID check | Treated as a configuration error: the sink is disabled immediately. A region redirect is reported with the bucket's actual region so you can correct the Region field. |
Duplicates and redelivery
Redelivery does not create duplicate objects in your bucket. Object keys are
deterministic, so if Firezone delivers the same batch twice, the second
delivery writes the same object to the same key with the same contents. Any
pipeline that reads objects by key, or that deduplicates lines by log_id,
is unaffected.
Recovering a failed sink
A sink showing Warning is retrying automatically, so unless the failures
continue there's nothing you need to do. A sink showing Error has stopped
delivering and needs attention: fix the cause , commonly a deleted role, a
trust policy missing the sts:ExternalId condition, or a bucket moved to
another region, then open the sink, click Edit, and click Save to
re-enable it. Delivery picks up right where it left off, from the last
acknowledged entry, and Deliver Now triggers a delivery immediately if
you don't want to wait for the next cycle.
Deleting the sink deletes its delivery state but not the objects in your bucket. Re-creating it starts fresh as a new sink with a new External ID, so update the role's trust policy to match before deliveries can resume. The new sink delivers logs recorded from its creation onward, plus a full backfill if Deliver existing logs is selected, and because backfilled objects are keyed by the same scheme, overlapping ranges overwrite cleanly.
Need help? See all support options.