Add Audit Logging Before SOC 2 Forces You To

Most platforms have no audit log until compliance demands one. Event schemas, immutable storage, retention policies and the query patterns SOC 2 needs.

David Viejo
David Viejo · Updated · Published

To add audit logging to a deployment platform, capture every write operation — deployments, env var changes, user actions — as structured, immutable records in PostgreSQL, enriched with actor identity, IP, and timestamp. The core pattern is a strongly-typed event schema plus a database role that can only INSERT, never UPDATE or DELETE.

When a deploy breaks or an account is misused, the audit log is how you reconstruct who did what. Without one, you're piecing it together from shell history, git logs and chat messages.

This guide covers how audit logging works from the ground up: event capture, schema design, tamper resistance, retention policies, and querying patterns. We'll also look at how Temps records deployment and configuration events in its open-source binary, and where its guarantees stop.

TL;DR: Audit logs record every significant action in your deployment platform — who did what, when, and from where. They're essential for compliance, debugging, and security investigations. Make them append-only at the database level (an INSERT-only role plus a trigger that rejects UPDATE and DELETE). Temps ships a searchable audit log in the free open-source binary; it is written by the application and is not immutable at the database level — tamper-evident hash chaining is a Premium feature.


Built-in vs Datadog vs DIY Audit Logging

Managed platforms often reserve audit logs for their top tier — Vercel's audit log is Enterprise-only. If you run your own platform, you have three options:

FeatureTemps (self-hosted)Datadog Audit TrailRoll your own
CostIncluded — free, MIT / Apache 2.0See Datadog pricingEngineering time only
SetupNone: most write handlers record an event after the operation succeedsDatadog account and configurationFull implementation required
Tamper resistanceApplication-level append; no database-enforced immutability. Tamper-evident hash chain and log streaming are PremiumManaged by DatadogYou implement it (role + trigger below)
Geolocation enrichmentAutomatic (country, city, coordinates)AvailableYou implement it
CLI accessbunx @temps-sdk/cli audit listAPI and web UIYou implement it
Retention controlYour PostgreSQL, your rules (retention policies are Premium)Set by your Datadog planFull control
Compliance scopeDeployments, team and access changes, logins, env-var value reveals (not env-var create/edit/delete)Broad but separate from deploysWhatever you build

The main advantage of Temps is that audit logging is built into the deployment platform itself — deployment, team, access and sign-in events are recorded by the same binary that performs them. Coverage is not complete: revealing an environment variable's value and promoting it to a secret are recorded, but creating, editing or deleting a variable is not audited yet. The audit logging docs list every event type.


Why Do Audit Logs Matter for Deployment Platforms?

Audit logs serve three distinct purposes: compliance, debugging, and security forensics. Deployment platforms sit at the center of your infrastructure — they hold production credentials and decide what code runs — so they need all three.

Compliance and regulatory requirements

SOC 2, HIPAA, GDPR, and ISO 27001 all require organizations to maintain records of system access and changes. Your deployment platform controls who can push code to production, modify environment variables, and change DNS records. Auditors want to see exactly who performed those actions and when.

Without audit logs, you'll spend weeks reconstructing evidence from scattered server logs, git histories, and Slack messages during your next compliance audit. With them, you run a query and hand over the results.

Debugging production incidents

Most production incidents aren't caused by bad code alone. They're caused by configuration changes: someone rotated an API key but forgot to update staging, or a teammate changed a build setting that broke the deploy pipeline.

When your app goes down at 2am, the first question isn't "what's broken?" — it's "what changed?" Audit logs answer that question in seconds.

Security forensics and insider threat detection

If an attacker compromises a team member's account, audit logs reveal every action that account took. Which projects did they access? Did they export environment variables? Did they modify deployment targets?

In practice, the most common security-relevant events we've seen teams investigate aren't external attacks — they're former employees whose access wasn't revoked promptly, or shared credentials used by someone who shouldn't have had them.


What Should You Log in a Deployment Platform?

The rule is straightforward: log every state change, never log secrets. According to OWASP's Logging Cheat Sheet, applications should log all input validation failures, authentication successes and failures, authorization failures, and application errors — at minimum.

Events you must capture

Here's a practical list for a deployment platform:

CategoryEvents
AuthenticationLogin success, login failure, token creation, token revocation, session expiry
DeploymentsDeploy triggered, build started, build completed, deploy promoted, deploy rolled back
ConfigurationEnv var created, env var updated, env var deleted, build settings changed
Access controlUser invited, role changed, user removed, permission granted
InfrastructureDomain added, DNS configured, SSL certificate issued, node joined cluster
DatabasesBackup created, backup restored, database credentials rotated

Every write operation (create, update, delete) should generate an audit event. Read operations generally don't need logging unless you're tracking access to sensitive resources like environment variables or database credentials.

What you must never log

This is equally important. Your audit logs should never contain:

  • Plaintext secrets — environment variable values, API keys, database passwords
  • Full request bodies with user data that falls under GDPR or HIPAA
  • Authentication tokens — session cookies, JWT values, OAuth tokens
  • Personal data beyond what's necessary for identification (email and user ID are fine; addresses are not)

Log that an environment variable was updated. Log its key name. Never log its value. The difference between a useful audit trail and a security liability comes down to this distinction.


How Do You Design an Audit Event Schema?

A good audit event schema answers five questions: who, what, when, where, and how. NIST SP 800-92 on Log Management (2006) recommends that every log entry contain a timestamp, source, event type, and identity of the actor — at minimum.

The core event structure

Here's a JSON schema for an audit event that covers the essentials:

{
  "id": "evt_a1b2c3d4",
  "timestamp": "2026-03-12T14:32:07.123Z",
  "actor": {
    "user_id": 42,
    "email": "dev@example.com",
    "ip_address": "198.51.100.23",
    "user_agent": "Mozilla/5.0..."
  },
  "operation": "environment_variable.updated",
  "resource": {
    "type": "environment_variable",
    "id": "var_789",
    "project_id": "proj_456",
    "name": "DATABASE_URL"
  },
  "metadata": {
    "previous_value_hash": "sha256:a1b2c3...",
    "source": "dashboard",
    "request_id": "req_x7y8z9"
  }
}

Notice that the resource field includes the variable name but not its value. The metadata includes a hash of the previous value so you can verify whether it actually changed, without storing the secret itself.

Operation type naming conventions

Use a consistent resource.action format for operation types. This makes filtering and grouping trivial:

deployment.created
deployment.promoted
deployment.rolled_back
environment_variable.created
environment_variable.updated
environment_variable.deleted
user.invited
user.role_changed
domain.added
domain.verified

Avoid past tense or inconsistent naming. We've seen teams use a mix of created, create, was_created, and new for the same action across different services. Six months later, querying becomes a nightmare. Pick one convention and enforce it at the schema level.

PostgreSQL table design

Here's a production-ready audit log table:

CREATE TABLE audit_logs (
    id            SERIAL PRIMARY KEY,
    user_id       INTEGER NOT NULL REFERENCES users(id),
    operation_type VARCHAR(100) NOT NULL,
    ip_address_id INTEGER REFERENCES ip_geolocations(id),
    user_agent    TEXT NOT NULL DEFAULT '',
    data          JSONB NOT NULL DEFAULT '{}',
    audit_date    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    created_at    TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Index for filtering by user
CREATE INDEX idx_audit_logs_user_id ON audit_logs(user_id);

-- Index for filtering by operation type
CREATE INDEX idx_audit_logs_operation_type ON audit_logs(operation_type);

-- Index for time-range queries
CREATE INDEX idx_audit_logs_audit_date ON audit_logs(audit_date DESC);

-- Composite index for the most common query pattern
CREATE INDEX idx_audit_logs_user_date
    ON audit_logs(user_id, audit_date DESC);

A few design decisions worth noting. The data column uses JSONB rather than a fixed schema. Different event types carry different metadata — a deployment event includes build duration and commit hash, while a user invitation event includes the invitee's email and assigned role. JSONB lets you store structured metadata without adding columns for every event type.

The ip_address_id references a separate geolocation table rather than storing the raw IP inline. This normalizes geolocation data and helps with analytics queries later.


How Do You Make Audit Logs Immutable?

Immutability is the single most important property of an audit log. If someone can modify or delete audit records, the entire trail becomes worthless for compliance and forensics. The CIS Controls v8 (2021) explicitly recommend that log data be protected from unauthorized modification and deletion.

Database-level protections

The simplest approach: create a dedicated database role for audit writes that only has INSERT permission.

-- Create a role that can only insert
CREATE ROLE audit_writer;
GRANT INSERT ON audit_logs TO audit_writer;
GRANT USAGE ON SEQUENCE audit_logs_id_seq TO audit_writer;

-- Explicitly deny update and delete
REVOKE UPDATE, DELETE ON audit_logs FROM audit_writer;

-- Your application connects with this role for audit writes

Your application's main database role should have SELECT access for reading audit logs, but write operations should go through the restricted audit_writer role. Even if an attacker gains access to the application database credentials, they can't tamper with existing records.

Application-level safeguards

Beyond database permissions, your application code should enforce immutability:

// Audit log creation -- notice there's no update or delete method
#[async_trait]
pub trait AuditLogger: Send + Sync {
    async fn create_audit_log(
        &self,
        operation: &dyn AuditOperation
    ) -> Result<()>;
    // No update_audit_log method exists
    // No delete_audit_log method exists
}

Temps' AuditLogger trait has this shape: create only. An interface like this prevents accidental mutation from feature code, but it is not immutability. Other code can still run DELETE — Temps itself prunes permission-denied events after 90 days — and anyone with database credentials can edit rows. Database-level controls are what close that gap.

Preventing tampering with triggers

For an extra layer of protection, add a PostgreSQL trigger that blocks updates and deletes entirely:

CREATE OR REPLACE FUNCTION prevent_audit_modification()
RETURNS TRIGGER AS $$
BEGIN
    RAISE EXCEPTION
        'Audit logs are immutable. Cannot % record id=%',
        TG_OP, OLD.id;
    RETURN NULL;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER audit_logs_immutable
    BEFORE UPDATE OR DELETE ON audit_logs
    FOR EACH ROW
    EXECUTE FUNCTION prevent_audit_modification();

This trigger rejects every UPDATE and DELETE, including retention purges. To expire old rows, partition audit_logs by month and drop whole partitions (DROP TABLE on a partition is DDL and isn't blocked by a row-level trigger), or run purges as a separate privileged role that temporarily disables the trigger, with that action itself logged.

Even a superuser running a manual SQL statement will see an explicit error. This makes accidental deletions nearly impossible and intentional tampering obvious.

But what about the data column? Could someone tamper with the JSONB payload in transit before it's written? That's where structured types help. Define your audit operations as strongly-typed Rust structs (or TypeScript interfaces) and serialize them at write time. No raw JSON construction.


How Should You Handle Retention and Archival?

Audit logs grow fast. A mid-size team running 50 deploys per day can generate 500-1,000 audit events daily when you include configuration changes, logins, and access events. Retention requirements vary by framework: PCI DSS asks for at least a year of audit history, and some regulated industries keep records for several years.

Time-based partitioning

PostgreSQL's native partitioning handles this cleanly. Partition by month so you can drop old partitions without vacuuming:

-- Convert to partitioned table
CREATE TABLE audit_logs_partitioned (
    LIKE audit_logs INCLUDING ALL
) PARTITION BY RANGE (audit_date);

-- Create monthly partitions
CREATE TABLE audit_logs_2026_01
    PARTITION OF audit_logs_partitioned
    FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');

CREATE TABLE audit_logs_2026_02
    PARTITION OF audit_logs_partitioned
    FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');

CREATE TABLE audit_logs_2026_03
    PARTITION OF audit_logs_partitioned
    FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');

When a partition exceeds your retention window, you archive it to cold storage (S3, object storage) and then drop it. This is far more efficient than running DELETE on millions of rows.

Tiered retention strategy

Not all audit events deserve the same retention period. Consider a tiered approach:

  • Hot tier (0-90 days): All events in PostgreSQL, fully indexed, fast queries
  • Warm tier (90 days - 1 year): Archived to compressed files on object storage, queryable with effort
  • Cold tier (1-7 years): Compressed archives for compliance, rarely accessed

Most teams over-retain in the hot tier and under-retain in the cold tier. You don't need three years of login events in your primary database. But you absolutely need them somewhere for your SOC 2 auditor. The cost difference between PostgreSQL storage and S3 is roughly 10x — plan accordingly.


What Are the Best Patterns for Querying Audit Logs?

Audit logs are write-heavy and read-rarely — until something goes wrong. Then you need fast, flexible queries across potentially millions of rows.

Common query patterns

Here are the queries you'll run most often during incident investigations:

"What changed in the last hour?" — the first question during any outage:

SELECT
    al.audit_date,
    u.email,
    al.operation_type,
    al.data
FROM audit_logs al
LEFT JOIN users u ON u.id = al.user_id
WHERE al.audit_date > NOW() - INTERVAL '1 hour'
ORDER BY al.audit_date DESC;

"What did this user do?" — after a compromised account is identified:

SELECT
    al.audit_date,
    al.operation_type,
    al.data,
    ig.ip_address,
    ig.country
FROM audit_logs al
LEFT JOIN ip_geolocations ig ON ig.id = al.ip_address_id
WHERE al.user_id = 42
ORDER BY al.audit_date DESC
LIMIT 100;

"Who revealed or locked down this project's secrets?" — narrowing down a configuration issue. This one is written for the Temps database, so it uses Temps' upper-case event names:

SELECT
    al.audit_date,
    u.email,
    al.operation_type,
    al.data::jsonb->>'key' AS variable_name
FROM audit_logs al
LEFT JOIN users u ON u.id = al.user_id
WHERE al.operation_type IN ('ENVIRONMENT_VARIABLE_VALUE_REVEALED', 'ENVIRONMENT_VARIABLE_PROMOTED_TO_SECRET')
  AND al.data::jsonb->>'project_id' = '456'
ORDER BY al.audit_date DESC;

Two details matter when you run these against Temps rather than the table defined above. Temps stores data as a text column holding JSON, so cast it with data::jsonb before using JSON operators (the cast is a no-op on a real JSONB column). And user_id is nullable — it is empty when the acting account has since been deleted — so use LEFT JOIN users. Temps does not record an event when a variable is created, edited or deleted, so this query shows value reveals and promotions to secret only.

JSONB indexing for the data column

If you're querying the data column frequently, add a GIN index:

CREATE INDEX idx_audit_logs_data
    ON audit_logs USING GIN ((data::jsonb));

This makes JSONB containment queries fast:

-- Find all events for a specific project
SELECT * FROM audit_logs
WHERE data::jsonb @> '{"project_id": 456}';

Be cautious with GIN indexes on high-write tables, though. They add overhead to every insert. If your write volume is high, consider using targeted expression indexes instead:

-- Index only the project_id field within data
CREATE INDEX idx_audit_logs_project_id
    ON audit_logs ((data::jsonb->>'project_id'));

CLI access for quick investigations

Command-line access to audit logs is invaluable during incidents. Here's what that looks like with Temps:

# List recent audit events
bunx @temps-sdk/cli audit list --limit 20

# Filter by operation type
bunx @temps-sdk/cli audit list --operation-type PIPELINE_TRIGGERED --limit 50

# View details of a specific event
bunx @temps-sdk/cli audit show --id 42

Having audit data accessible via CLI means your on-call engineer doesn't need to open a database client or navigate a dashboard during a 2am incident. They can query directly from their terminal, filtering by operation type, user ID, or time range.


How Does Temps Handle Audit Logging?

Temps includes a searchable audit log in the free open-source binary. There's no setup and no third-party integration — it ships in the same Rust binary that handles deployments, proxying, and observability. Deployment, team, access, authentication and most configuration events each generate a structured audit record; environment-variable create, edit and delete are a known gap (only value reveals and promotion to secret are recorded). The audit logging docs list the events and how to query them, and the security overview shows where the audit log sits among Temps' other controls.

What to expect from it:

  1. No setup — most write handlers create an audit record after the operation succeeds. There's nothing to opt into.
  2. Typed events — every event implements the AuditOperation trait, so a malformed event is a compile error, not a runtime surprise.
  3. Best-effort, non-blocking capture — if the audit write fails, the error is logged and the operation continues. That keeps deploys running when the audit path has a problem, but it means an event can be missing. To detect gaps, alert on Failed to create audit log lines in the Temps server logs.
  4. Not immutable in the open-source build — rows live in the Temps PostgreSQL database with no INSERT-only role or trigger. Add the role and trigger from the section above if you need database-level tamper resistance, or use Premium, which adds a tamper-evident, hash-chained audit log and streaming to a SIEM or webhook.

Built-in event capture

Most handlers that perform a write operation in Temps follow the same pattern: execute the business logic, then create an audit record. Not every write is covered — creating, editing or deleting an environment variable doesn't produce an event today. Here's a simplified version of how the pattern works internally:

// After a successful configuration update
let audit = ConfigUpdatedAudit {
    context: AuditContext {
        user_id: auth.user_id(),
        ip_address: Some(metadata.ip_address.clone()),
        user_agent: metadata.user_agent.clone(),
    },
    setting_key: key.clone(),
    previous_value_hash: hash_value(&old_value),
};

// Audit failure never blocks the main operation
if let Err(e) = app_state
    .audit_service
    .create_audit_log(&audit)
    .await
{
    error!("Failed to create audit log: {}", e);
}

Notice the pattern: audit log failures are logged but never fail the primary operation. This is a deliberate design choice. If your audit system goes down, you don't want deployments to stop. The error gets captured in application logs and the deploy still goes through — so watch those logs if you depend on a complete trail.

Strongly-typed audit operations

Every event type implements the AuditOperation trait, which enforces structured data at compile time:

pub trait AuditOperation: Send + Sync {
    fn operation_type(&self) -> String;
    fn user_id(&self) -> Option<i32>; // None for deployment tokens or unknown identities
    fn ip_address(&self) -> Option<String>;
    fn user_agent(&self) -> &str;
    fn serialize(&self) -> Result<String>;
}

This means you can't accidentally create a malformed audit event. The Rust compiler catches missing fields, wrong types, and serialization issues before the code even runs. Compare that to a system where audit events are constructed as raw JSON strings — typos in field names, missing timestamps, and schema drift are inevitable.

Geolocation enrichment

Temps enriches audit events with IP geolocation data automatically. The ip_address_id foreign key in the audit log table points to a geolocation record with country, city, and coordinates. During a security investigation, you can immediately spot anomalies: "Why is there a login from a country where we have no employees?"

Querying via CLI and API

Temps exposes audit logs through the dashboard, its REST API and the CLI. The CLI filters by time range (--from, --to), operation type and user ID. Everything is stored in PostgreSQL, so you can also run the SQL patterns above directly against the Temps database. For how the audit log fits with the rest of Temps' controls, see the security overview.


FAQ

How much storage do audit logs consume?

A single audit event in PostgreSQL typically uses 500 bytes to 2KB depending on the JSONB metadata. At 1,000 events per day, that's roughly 0.5-2MB daily, or 180-730MB per year.

Partitioning by month and archiving old partitions to object storage keeps database size manageable. S3 Standard costs $0.023 per GB-month for the first 50 TB in us-east-1, and the Glacier classes cost less. Most deployment platforms won't exceed a few gigabytes per year in their hot tier.

Can audit logs impact application performance?

Audit log writes add minimal latency — typically 1-5ms per INSERT on PostgreSQL with proper indexing. The key is making audit writes asynchronous or fire-and-forget. Temps handles this by logging audit failures without blocking the primary operation.

For high-throughput scenarios, batching audit events and writing them in bulk every few seconds reduces per-request overhead further. To check the headroom on your own hardware, run a custom insert script through pgbench, PostgreSQL's built-in benchmarking tool.

What's the difference between audit logs and application logs?

Application logs capture technical events: errors, warnings, debug info, request timings. They're optimized for debugging code issues and typically use unstructured or semi-structured formats.

Audit logs capture business events: who did what, when, to which resource. They're optimized for compliance, security forensics, and change tracking. They use strict schemas and should be append-only. You need both — application logs serve developers, while audit logs serve security teams, compliance auditors, and incident investigators.

Do I need audit logging if I'm a solo developer?

Yes. Even without compliance requirements, audit logs help you answer "what changed?" when something breaks. They're especially valuable for deployment platforms because so many incidents trace back to configuration changes rather than code bugs. Future you — debugging a production issue at midnight — will thank past you for setting up audit trails. And if your project grows to a team, you'll already have the infrastructure in place.

#audit-logging#compliance#security#postgresql#devops#audit logging deployment platform