2025-12-10 23:24:16
In June, Google introduced Gemini CLI, an open-source AI agent that brings the power of Gemini directly into your terminal. We’re excited to announce open-source Gemini CLI extensions for Google Data Cloud services.
Building applications and analyzing trends with services like Cloud SQL, AlloyDB and BigQuery has never been easier — all from your local development environment! Whether you’re just getting started or a seasoned developer, these extensions make common data interactions such as app development, deployment, operations, and data analytics more productive and easier. So, let’s jump right in!
Before you get started, make sure you have enabled the APIs and configured the IAM permissions required to access specific services.
To retrieve the newest functionality, install the latest release of the Gemini CLI (v0.6.0):
npm install -g @google/gemini-cli@latest
Next, install the extension:
gemini extensions install https://github.com/gemini-cli-extensions/<EXTENSION>
Replace with the name of the service you want to use. For example, alloydb, cloud-sql-postgresql or bigquery-data-analytics.
Before starting the Gemini CLI, you’ll need to configure the extension to connect with your Google Cloud project by adding the required environment variables. The table below provides more information on the configuration required.
| Extension Name | Description | Configuration |
|---|---|---|
| alloydb | Create resources and interact with AlloyDB for PostgreSQL databases and data. | Configuration |
| alloydb-observability | Monitor database performance and health for AlloyDB for PostgreSQL databases. | Configuration |
| bigquery-data-analytics | Discover and ask questions from BigQuery data. | Configuration |
| bigquery-conversational-analytics | Dive deeper, discover insights from BigQuery data using the built-in stateless agent offered by Conversational Analytics API. | Configuration |
| cloud-sql-mysql | Connect and interact with a Cloud SQL for MySQL database and data. | Configuration |
| cloud-sql-mysql-observability | Monitor database performance and health for Cloud SQL for MySQL databases. | Configuration |
| cloud-sql-postgresql | Connect and interact with a Cloud SQL for PostgreSQL database and data. | Configuration |
| cloud-sql-postgresql-observability | Monitor database performance and health for Cloud SQL for PostgreSQL databases. | Configuration |
| cloud-sql-sqlserver | Connect and interact with a Cloud SQL for SQL Server database and data. | Configuration |
| cloud-sql-sqlserver-observability | Monitor database performance and health for Cloud SQL for SQL Server databases. | Configuration |
| dataplex | Connect to Dataplex Universal Catalog to discover, manage, monitor, and govern data and AI artifacts across your data platform. | Configuration |
| spanner | Connect and interact with a Spanner database and data. | Configuration |
| mcp-toolbox | Load custom tools using MCP Toolbox for Databases. | Configuration |
Now, you can start the Gemini CLI using command gemini. You can view the extensions installed with the command /extensions

You can list the MCP servers and tools included in the extension using command /mcp list
The Cloud SQL for PostgreSQL extension lets you perform a number of actions. Some of the main ones are included below:
Curious about how to put it in action? Like any good project, start with a solid written plan of what you are trying to do. Then, you can provide that project plan to the CLI as a series of prompts, and the agent will start provisioning the database and other resources:


After configuring the extension to connect to the new database, the agent can generate the required tables based on the approved plan. For easy testing, you can prompt the agent to add test data.

Now the agent can use the context it has to generate an API to make the data accessible.

As you can see, these extensions make it incredibly easy to start building with Google Cloud databases!
For your analytical needs, we are thrilled to give you a first look at the Gemini CLI extension for BigQuery Data Analytics. We are also excited to give access to the Conversational Analytics API through the BigQuery Conversational Analytics extension. This is the first step in our journey to bring the full power of BigQuery directly into your local coding environment, creating an integrated and unified workflow.
With this extension you can
This initial release provides a comprehensive suite of tools to Gemini CLI:
[Note: To use the conversational analytics extension you need to enable additional APIs. Refer to documentation for additional info.]
Here is an example journey with analytics extensions:
Explore and analyze your data , e.g.,
> find tables related to PyPi downloads
✦ I found the following tables related to PyPi downloads:
* file_downloads: projects/bigquery-public-data/datasets/pypi/tables/file_downloads
* distribution_metadata: projects/bigquery-public-data/datasets/pypi/tables/distribution_metadata
> Using bigquery-public-data.pypi.file_downloads show me top 10 downloaded pypi packages this month
✦ Here are the top 10 most downloaded PyPI packages this month:
1. boto3: 685,007,866 downloads
2. botocore: 531,034,851 downloads
3. urllib3: 512,611,825 downloads
4. requests: 464,595,806 downloads
5. typing-extensions: 459,505,780 downloads
6. certifi: 451,929,759 downloads
7. charset-normalizer: 428,716,731 downloads
8. idna: 409,262,986 downloads
9. grpcio-status: 402,535,938 downloads
10. aiobotocore: 399,650,559 downloads
Using “ask_data_insights” to trigger an agent on the BigQuery (Conversational analytics API) to answer your questions. The server side agent is smart enough to gather additional context about your data and offer deeper insights into your questions.

You can go further and generate charts and reports by mixing BigQuery data with your local tools. Here’s a prompt to try:
”using bigquery-public-data.pypi.file_downloads can you forecast downloads for the last four months of 2025 for package urllib3? Please plot a chart that includes actual downloads for the first 8 months, followed by the forecast for the last four months”
Ready to level up your Gemini CLI extensions for our Data Cloud services? Read more in the extensions documentation. Check out our templates and start building your own extensions to share with the community!
2025-12-10 23:23:50
Every application needs to send emails. Welcome messages, password resets, notifications, receipts. But setting up SMTP servers, dealing with deliverability, and managing templates is tedious. Resend provides a modern API that makes email simple.
In this tutorial, we'll build email functionality using Resend. You'll learn how to send transactional emails, use React Email templates, track delivery status, handle webhooks, and store email history in a database.
Resend is an email API built for developers. It provides:
Resend handles the complexity of email infrastructure so you can focus on your content.
We'll create a backend with complete email functionality:
The backend will handle all email sending with delivery tracking and analytics.
Prefer to skip the setup? Use encore app create --example=ts/resend to start with a complete working example. This tutorial walks through building it from scratch to understand each component.
First, install Encore if you haven't already:
# macOS
brew install encoredev/tap/encore
# Linux
curl -L https://encore.dev/install.sh | bash
# Windows
iwr https://encore.dev/install.ps1 | iex
Create a new Encore application. This will prompt you to create a free Encore account if you don't have one (required for secret management):
encore app create resend-app --example=ts/hello-world
cd resend-app
Resend offers a generous free tier with 100 emails per day and 3,000 per month.
Important: To send emails to real recipients, you must verify your domain with Resend. Without domain verification, you can only send test emails to your own verified email address.
To verify your domain:
yourdomain.com)Once verified, update your from addresses in the code to use your domain:
from: "[email protected]" // Replace with your verified domain
For testing: If you don't have a domain yet, Resend lets you send test emails to your own verified email address using their sandbox domain ([email protected]). This is perfect for development and testing the integration.
When creating your API key, Resend offers two permission levels:
full_access): Can create, delete, get, and update any resource. Use this for development and administrative tasks.sending_access): Can only send emails. This is the recommended permission level for production applications following the principle of least privilege.For production deployments, consider creating a sending access key and optionally restricting it to a specific domain. This limits the blast radius if your API key is ever compromised.
Install Resend and React Email:
npm install resend
npm install -D @react-email/components
Every Encore service starts with a service definition file (encore.service.ts). Services let you divide your application into logical components. At deploy time, you can decide whether to colocate them in a single process or deploy them as separate microservices, without changing a single line of code:
// email/encore.service.ts
import { Service } from "encore.dev/service";
export default new Service("email");
Store your API key securely using Encore's built-in secrets management:
// email/resend.ts
import { Resend } from "resend";
import { secret } from "encore.dev/config";
const resendApiKey = secret("ResendApiKey");
export const resend = new Resend(resendApiKey());
Set your API key for local development:
# Development (use full access for testing)
encore secret set --dev ResendApiKey
# Production (use sending access for security)
encore secret set --prod ResendApiKey
Production Best Practice: Create a separate API key with sending_access permission for production. If you have multiple domains, create domain-specific keys to further isolate access. You can create domain-restricted keys using Resend's API:
// Example: Creating a domain-specific sending key (for admin tools)
const { data } = await resend.apiKeys.create({
name: 'Production - yourdomain.com',
permission: 'sending_access',
domainId: 'your-domain-id', // Get this from Resend dashboard
});
To track email history and delivery status, create a PostgreSQL database. With Encore, you can create a database by simply defining it in code. The framework automatically provisions the infrastructure locally using Docker.
Create the database instance:
// email/db.ts
import { SQLDatabase } from "encore.dev/storage/sqldb";
export const db = new SQLDatabase("email", {
migrations: "./migrations",
});
Create the migration file:
-- email/migrations/1_create_emails.up.sql
CREATE TABLE emails (
id TEXT PRIMARY KEY,
resend_id TEXT UNIQUE,
recipient TEXT NOT NULL,
subject TEXT NOT NULL,
template TEXT,
status TEXT NOT NULL DEFAULT 'pending',
delivered_at TIMESTAMP,
opened_at TIMESTAMP,
clicked_at TIMESTAMP,
bounced_at TIMESTAMP,
complained_at TIMESTAMP,
error TEXT,
metadata JSONB,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_emails_recipient ON emails(recipient, created_at DESC);
CREATE INDEX idx_emails_status ON emails(status);
CREATE INDEX idx_emails_resend_id ON emails(resend_id);
React Email lets you build email templates using React components. Create a welcome email template:
// email/templates/welcome.tsx
import {
Body,
Button,
Container,
Head,
Heading,
Html,
Preview,
Section,
Text,
} from "@react-email/components";
import * as React from "react";
interface WelcomeEmailProps {
name: string;
loginUrl: string;
}
export function WelcomeEmail({ name, loginUrl }: WelcomeEmailProps) {
return (
<Html>
<Head />
<Preview>Welcome to our platform!</Preview>
<Body style={main}>
<Container style={container}>
<Heading style={h1}>Welcome, {name}!</Heading>
<Text style={text}>
Thanks for joining us. We're excited to have you on board.
</Text>
<Section style={buttonContainer}>
<Button style={button} href={loginUrl}>
Get Started
</Button>
</Section>
<Text style={footer}>
If you have any questions, just reply to this email.
</Text>
</Container>
</Body>
</Html>
);
}
const main = {
backgroundColor: "#f6f9fc",
fontFamily: '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
};
const container = {
backgroundColor: "#ffffff",
margin: "0 auto",
padding: "20px 0 48px",
marginBottom: "64px",
};
const h1 = {
color: "#333",
fontSize: "24px",
fontWeight: "bold",
margin: "40px 0",
padding: "0",
textAlign: "center" as const,
};
const text = {
color: "#333",
fontSize: "16px",
lineHeight: "26px",
textAlign: "center" as const,
};
const buttonContainer = {
textAlign: "center" as const,
margin: "32px 0",
};
const button = {
backgroundColor: "#5469d4",
borderRadius: "4px",
color: "#fff",
fontSize: "16px",
textDecoration: "none",
textAlign: "center" as const,
display: "block",
width: "200px",
padding: "12px",
margin: "0 auto",
};
const footer = {
color: "#8898aa",
fontSize: "14px",
lineHeight: "24px",
textAlign: "center" as const,
marginTop: "32px",
};
Create an endpoint to send welcome emails:
// email/send.ts
import { api } from "encore.dev/api";
import { resend } from "./resend";
import { db } from "./db";
import { render } from "@react-email/components";
import { WelcomeEmail } from "./templates/welcome";
import log from "encore.dev/log";
interface SendWelcomeEmailRequest {
to: string;
name: string;
loginUrl?: string;
}
interface SendWelcomeEmailResponse {
id: string;
resendId: string;
}
export const sendWelcomeEmail = api(
{ expose: true, method: "POST", path: "/email/welcome" },
async (req: SendWelcomeEmailRequest): Promise<SendWelcomeEmailResponse> => {
const emailId = `email-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
log.info("Sending welcome email", { to: req.to, emailId });
// Render the React Email template to HTML
const html = render(
WelcomeEmail({
name: req.name,
loginUrl: req.loginUrl || "https://yourapp.com/login",
})
);
try {
// Send email via Resend
const { data, error } = await resend.emails.send({
from: "[email protected]",
to: req.to,
subject: "Welcome to our platform!",
html,
});
if (error) {
throw new Error(`Failed to send email: ${error.message}`);
}
// Store email record
await db.exec`
INSERT INTO emails (id, resend_id, recipient, subject, template, status, metadata)
VALUES (
${emailId},
${data!.id},
${req.to},
${"Welcome to our platform!"},
${"welcome"},
${"sent"},
${JSON.stringify({ name: req.name })}
)
`;
log.info("Welcome email sent", { emailId, resendId: data!.id });
return {
id: emailId,
resendId: data!.id, // Resend's unique email ID for tracking
};
} catch (error) {
// Store failed email
const errorMessage = error instanceof Error ? error.message : "Unknown error";
await db.exec`
INSERT INTO emails (id, recipient, subject, template, status, error)
VALUES (
${emailId},
${req.to},
${"Welcome to our platform!"},
${"welcome"},
${"failed"},
${errorMessage}
)
`;
throw error;
}
}
);
Create another template for password resets:
// email/templates/password-reset.tsx
import {
Body,
Button,
Container,
Head,
Heading,
Html,
Preview,
Section,
Text,
} from "@react-email/components";
import * as React from "react";
interface PasswordResetEmailProps {
name: string;
resetUrl: string;
}
export function PasswordResetEmail({ name, resetUrl }: PasswordResetEmailProps) {
return (
<Html>
<Head />
<Preview>Reset your password</Preview>
<Body style={main}>
<Container style={container}>
<Heading style={h1}>Password Reset</Heading>
<Text style={text}>Hi {name},</Text>
<Text style={text}>
We received a request to reset your password. Click the button below to
choose a new password:
</Text>
<Section style={buttonContainer}>
<Button style={button} href={resetUrl}>
Reset Password
</Button>
</Section>
<Text style={footer}>
If you didn't request this, you can safely ignore this email.
</Text>
<Text style={footer}>This link will expire in 1 hour.</Text>
</Container>
</Body>
</Html>
);
}
// Styles similar to welcome email...
const main = {
backgroundColor: "#f6f9fc",
fontFamily: '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
};
const container = {
backgroundColor: "#ffffff",
margin: "0 auto",
padding: "20px 0 48px",
marginBottom: "64px",
};
const h1 = {
color: "#333",
fontSize: "24px",
fontWeight: "bold",
margin: "40px 0",
padding: "0",
textAlign: "center" as const,
};
const text = {
color: "#333",
fontSize: "16px",
lineHeight: "26px",
textAlign: "left" as const,
padding: "0 20px",
};
const buttonContainer = {
textAlign: "center" as const,
margin: "32px 0",
};
const button = {
backgroundColor: "#dc3545",
borderRadius: "4px",
color: "#fff",
fontSize: "16px",
textDecoration: "none",
textAlign: "center" as const,
display: "block",
width: "200px",
padding: "12px",
margin: "0 auto",
};
const footer = {
color: "#8898aa",
fontSize: "14px",
lineHeight: "24px",
textAlign: "center" as const,
marginTop: "16px",
};
And the endpoint:
// email/send.ts (continued)
import { PasswordResetEmail } from "./templates/password-reset";
interface SendPasswordResetRequest {
to: string;
name: string;
resetUrl: string;
}
interface SendPasswordResetResponse {
id: string;
resendId: string;
}
export const sendPasswordReset = api(
{ expose: true, method: "POST", path: "/email/password-reset" },
async (req: SendPasswordResetRequest): Promise<SendPasswordResetResponse> => {
const emailId = `email-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
const html = render(
PasswordResetEmail({
name: req.name,
resetUrl: req.resetUrl,
})
);
const { data, error } = await resend.emails.send({
from: "[email protected]",
to: req.to,
subject: "Reset your password",
html,
});
if (error) {
throw new Error(`Failed to send email: ${error.message}`);
}
await db.exec`
INSERT INTO emails (id, resend_id, recipient, subject, template, status, metadata)
VALUES (
${emailId},
${data!.id},
${req.to},
${"Reset your password"},
${"password-reset"},
${"sent"},
${JSON.stringify({ name: req.name })}
)
`;
return {
id: emailId,
resendId: data!.id,
};
}
);
Resend sends webhooks for email events (delivered, opened, bounced, etc.). Handle these to update your database:
// email/webhooks.ts
import { api } from "encore.dev/api";
import { db } from "./db";
import log from "encore.dev/log";
interface ResendWebhookEvent {
type: string;
created_at: string;
data: {
email_id: string;
from: string;
to: string[];
subject: string;
created_at: string;
};
}
export const handleWebhook = api.raw(
{ expose: true, path: "/webhooks/resend", method: "POST" },
async (req, res) => {
const event = (await req.json()) as ResendWebhookEvent;
log.info("Received Resend webhook", { type: event.type, emailId: event.data.email_id });
switch (event.type) {
case "email.sent":
await db.exec`
UPDATE emails
SET status = 'sent'
WHERE resend_id = ${event.data.email_id}
`;
break;
case "email.delivered":
await db.exec`
UPDATE emails
SET status = 'delivered', delivered_at = NOW()
WHERE resend_id = ${event.data.email_id}
`;
break;
case "email.opened":
await db.exec`
UPDATE emails
SET opened_at = NOW()
WHERE resend_id = ${event.data.email_id}
`;
break;
case "email.clicked":
await db.exec`
UPDATE emails
SET clicked_at = NOW()
WHERE resend_id = ${event.data.email_id}
`;
break;
case "email.bounced":
await db.exec`
UPDATE emails
SET status = 'bounced', bounced_at = NOW()
WHERE resend_id = ${event.data.email_id}
`;
break;
case "email.complained":
await db.exec`
UPDATE emails
SET status = 'complained', complained_at = NOW()
WHERE resend_id = ${event.data.email_id}
`;
break;
}
res.writeHead(200);
res.end();
}
);
Configure the webhook URL in Resend Dashboard: https://your-domain.com/webhooks/resend
About Resend Email IDs: Every email sent through Resend gets a unique ID (like re_ABC123xyz). Store this ID in your database to:
Create an endpoint to retrieve sent emails:
// email/send.ts (continued)
interface EmailRecord {
id: string;
recipient: string;
subject: string;
template: string | null;
status: string;
deliveredAt: Date | null;
openedAt: Date | null;
clickedAt: Date | null;
createdAt: Date;
}
interface ListEmailsRequest {
recipient?: string;
limit?: number;
}
interface ListEmailsResponse {
emails: EmailRecord[];
}
export const listEmails = api(
{ expose: true, method: "GET", path: "/email/list" },
async (req: ListEmailsRequest): Promise<ListEmailsResponse> => {
const limit = req.limit || 50;
let query;
if (req.recipient) {
query = db.query<{
id: string;
recipient: string;
subject: string;
template: string | null;
status: string;
delivered_at: Date | null;
opened_at: Date | null;
clicked_at: Date | null;
created_at: Date;
}>`
SELECT id, recipient, subject, template, status, delivered_at, opened_at, clicked_at, created_at
FROM emails
WHERE recipient = ${req.recipient}
ORDER BY created_at DESC
LIMIT ${limit}
`;
} else {
query = db.query<{
id: string;
recipient: string;
subject: string;
template: string | null;
status: string;
delivered_at: Date | null;
opened_at: Date | null;
clicked_at: Date | null;
created_at: Date;
}>`
SELECT id, recipient, subject, template, status, delivered_at, opened_at, clicked_at, created_at
FROM emails
ORDER BY created_at DESC
LIMIT ${limit}
`;
}
const emails: EmailRecord[] = [];
for await (const row of query) {
emails.push({
id: row.id,
recipient: row.recipient,
subject: row.subject,
template: row.template,
status: row.status,
deliveredAt: row.delivered_at,
openedAt: row.opened_at,
clickedAt: row.clicked_at,
createdAt: row.created_at,
});
}
return { emails };
}
);
Start your backend (make sure Docker is running first):
encore run
Your API is now running locally. Open the local development dashboard at http://localhost:9400 to explore your API.
curl -X POST http://localhost:4000/email/welcome \
-H "Content-Type: application/json" \
-d '{
"to": "[email protected]",
"name": "John Doe",
"loginUrl": "https://yourapp.com/login"
}'
Note: Replace [email protected] with the email address you used to sign up for Resend, or any email address on your verified domain.
Response:
{
"id": "email-1234567890-abc",
"resendId": "re_ABC123xyz"
}
curl -X POST http://localhost:4000/email/password-reset \
-H "Content-Type: application/json" \
-d '{
"to": "[email protected]",
"name": "John Doe",
"resetUrl": "https://yourapp.com/reset/token123"
}'
# All emails
curl http://localhost:4000/email/list
# For specific recipient
curl "http://localhost:4000/email/[email protected]"
The local development dashboard at http://localhost:9400 provides:
The database explorer shows all your email records with delivery tracking:
Resend supports many additional email fields beyond the basics. Here's how to use CC, BCC, reply-to addresses, tags, and headers:
export const sendAdvancedEmail = api(
{ expose: true, method: "POST", path: "/email/advanced" },
async (req: {
to: string[];
subject: string;
html: string;
}) => {
const { data, error } = await resend.emails.send({
from: "[email protected]",
to: req.to,
subject: req.subject,
html: req.html,
// CC and BCC recipients
cc: ["[email protected]"],
bcc: ["[email protected]"],
// Reply-to address (different from 'from')
replyTo: "[email protected]",
// Tags for filtering and analytics
tags: [
{ name: "category", value: "transactional" },
{ name: "priority", value: "high" },
],
// Custom headers
headers: {
"X-Entity-Ref-ID": "123456",
},
});
if (error) {
throw new Error(`Failed to send email: ${error.message}`);
}
return { id: data!.id };
}
);
Use cases for these fields:
Send to multiple recipients efficiently:
export const sendBatch = api(
{ expose: true, method: "POST", path: "/email/batch" },
async (req: {
recipients: Array<{ email: string; name: string }>;
subject: string;
html: string;
}) => {
const emails = req.recipients.map((recipient) => ({
from: "[email protected]",
to: recipient.email,
subject: req.subject,
html: req.html,
}));
const { data, error } = await resend.batch.send(emails);
if (error) {
throw new Error(`Batch send failed: ${error.message}`);
}
// Store each email in database
for (let i = 0; i < data!.data.length; i++) {
const emailId = `email-${Date.now()}-${i}`;
await db.exec`
INSERT INTO emails (id, resend_id, recipient, subject, status)
VALUES (
${emailId},
${data!.data[i].id},
${req.recipients[i].email},
${req.subject},
${"sent"}
)
`;
}
return { sent: data!.data.length };
}
);
Send emails with file attachments:
export const sendWithAttachment = api(
{ expose: true, method: "POST", path: "/email/attachment" },
async (req: {
to: string;
subject: string;
html: string;
attachment: {
filename: string;
content: string; // Base64 encoded
};
}) => {
const { data, error } = await resend.emails.send({
from: "[email protected]",
to: req.to,
subject: req.subject,
html: req.html,
attachments: [
{
filename: req.attachment.filename,
content: Buffer.from(req.attachment.content, "base64"),
},
],
});
if (error) {
throw new Error(`Failed to send email: ${error.message}`);
}
return { id: data!.id };
}
);
Use Encore's cron jobs to send scheduled emails:
import { CronJob } from "encore.dev/cron";
const _ = new CronJob("weekly-digest", {
title: "Send weekly digest emails",
every: "0 9 * * 1", // Every Monday at 9 AM
endpoint: sendWeeklyDigest,
});
export const sendWeeklyDigest = api(
{ expose: false },
async (): Promise<void> => {
// Fetch users who want weekly digests
// Send digest email to each user
log.info("Sending weekly digest emails");
}
);
Track email performance:
interface EmailAnalyticsResponse {
totalSent: number;
delivered: number;
opened: number;
clicked: number;
bounced: number;
deliveryRate: number;
openRate: number;
clickRate: number;
}
export const getAnalytics = api(
{ expose: true, method: "GET", path: "/email/analytics" },
async (): Promise<EmailAnalyticsResponse> => {
const stats = await db.queryRow<{
total: number;
delivered: number;
opened: number;
clicked: number;
bounced: number;
}>`
SELECT
COUNT(*) as total,
COUNT(CASE WHEN status = 'delivered' THEN 1 END) as delivered,
COUNT(CASE WHEN opened_at IS NOT NULL THEN 1 END) as opened,
COUNT(CASE WHEN clicked_at IS NOT NULL THEN 1 END) as clicked,
COUNT(CASE WHEN status = 'bounced' THEN 1 END) as bounced
FROM emails
WHERE created_at > NOW() - INTERVAL '30 days'
`;
const total = Number(stats!.total);
const delivered = Number(stats!.delivered);
const opened = Number(stats!.opened);
const clicked = Number(stats!.clicked);
return {
totalSent: total,
delivered,
opened,
clicked,
bounced: Number(stats!.bounced),
deliveryRate: delivered / total,
openRate: opened / delivered,
clickRate: clicked / opened,
};
}
);
Send a series of onboarding emails:
export const startOnboarding = api(
{ expose: true, method: "POST", path: "/onboarding/start" },
async (req: { email: string; name: string }) => {
// Send immediate welcome email
await sendWelcomeEmail({
to: req.email,
name: req.name,
});
// Schedule follow-up emails using your job queue
// Day 1: Getting started tips
// Day 3: Feature highlights
// Day 7: Success stories
return { started: true };
}
);
Send transactional receipts:
export const sendOrderConfirmation = api(
{ expose: true, method: "POST", path: "/email/order" },
async (req: {
to: string;
orderId: string;
items: Array<{ name: string; price: number }>;
total: number;
}) => {
const html = render(
OrderConfirmationEmail({
orderId: req.orderId,
items: req.items,
total: req.total,
})
);
const { data } = await resend.emails.send({
from: "[email protected]",
to: req.to,
subject: `Order confirmation - ${req.orderId}`,
html,
});
return { sent: true, id: data!.id };
}
);
Aggregate notifications into daily/weekly emails:
export const sendDigest = api(
{ expose: true, method: "POST", path: "/email/digest" },
async (req: {
to: string;
notifications: Array<{ title: string; message: string; url: string }>;
}) => {
const html = render(
DigestEmail({
notifications: req.notifications,
})
);
await resend.emails.send({
from: "[email protected]",
to: req.to,
subject: `You have ${req.notifications.length} new notifications`,
html,
});
return { sent: true };
}
);
From your frontend, trigger email sends:
// Example React component
import { useState } from "react";
function PasswordResetForm() {
const [email, setEmail] = useState("");
const [sent, setSent] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await fetch("http://localhost:4000/email/password-reset", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
to: email,
name: "User", // Get from your user database
resetUrl: `https://yourapp.com/reset/${generateToken()}`,
}),
});
setSent(true);
};
if (sent) {
return <p>Check your email for reset instructions!</p>;
}
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Enter your email"
/>
<button type="submit">Reset Password</button>
</form>
);
}
For complete frontend integration guides, see the frontend integration documentation.
See the self-hosting instructions for how to use encore build docker to create a Docker image and configure it.
Deploy your application using git push encore:
git add -A .
git commit -m "Add Resend email functionality"
git push encore
Set your production secret:
encore secret set --prod ResendApiKey
Note: Encore Cloud is great for prototyping and development with fair use limits. For production workloads, you can connect your AWS or GCP account and Encore will provision infrastructure directly in your cloud account.
If you found this tutorial helpful, consider starring Encore on GitHub to help others discover it.
2025-12-10 23:22:26
SVGs are one of the most powerful tools in modern UI development. They’re scalable, lightweight, customizable, and—best of all—easy to animate when paired with Motion.
In this guide, I’ll walk you through how to:
Open Figma and create a new frame.
Creating SVG lines is simple — select the Pen tool, click to set your starting point, and then click again where you want the line to end. Figma will automatically create a clean vector path that you can later export as an SVG
To create curves, we’ll use the Bend tool.
Select your SVG line, click on the Bend tool, and then click on the line again. You’ll see two Dots appear, just drag those dots to shape the curve however you like. That’s it! You now have a clean, smooth, curved line.
Once your line looks good, exporting it as an SVG is easy. Just select the line, right-click, and choose Copy as SVG.
I’ve already created a demo component with some icons and a few pre-built SVG lines animating in the background. Now, let’s take a look at how we can animate the left SVG line using Motion.
"use client";
import MotionDiv from "@/components/animation/motion-div";
import { cn } from "@/lib/utils";
import React from "react";
import { motion } from "motion/react";
import { DotPattern } from "@/components/ui/dot-pattern";
import Container from "@/components/container";
import ThemeToggle from "@/components/theme-toggle";
import Scales from "@/components/scales";
const Demo = () => {
return (
<Container className="relative flex h-screen max-w-[524px] flex-col items-center justify-center bg-white dark:bg-black">
<div className="mb-4">
<ThemeToggle />
</div>
<Scales />
<div className="relative overflow-hidden border-y">
<Heading
heading="Multi-Model Orchestration"
subHeading="Use different AI models together in a single flow to optimize accuracy, creativity, and performance across tasks like generation, enhancement, and analysis."
/>
<div className="flex scale-90 flex-col items-center justify-center sm:scale-105 md:scale-110 lg:scale-125">
<MotionDiv className="relative flex h-72 w-[484px] scale-80 items-center justify-center bg-white p-2 px-4 sm:px-0 dark:bg-black">
<DotPattern
className={cn(
"[mask-image:radial-gradient(150px_circle_at_center,white,transparent)]",
"text-neutral-400 dark:text-neutral-700",
)}
/>
<IconCard className="left-8.5" icon={<GeminiIcon />} />
<IconCard className="left-25.5" icon={<OpenAiIcon />} />
<IconCard className="left-42.5" icon={<DeepSeekIcon />} />
<IconCard className="right-42.5" icon={<ClaudeIcon />} />
<IconCard className="right-25.5" icon={<GrokIcon />} />
<IconCard className="right-8.5" icon={<QwenAiIcon />} />
<Line1 />
<Line2 />
<Line3 />
<Line4 />
<Line5 />
<Line6 />
<MotionDiv
initial={{}}
className="bg-card absolute right-1/2 bottom-6 flex size-14.5 translate-x-1/2 items-center justify-center rounded-full border p-1"
>
<div className="flex size-full items-center justify-center rounded-full p-2.5 text-center shadow-2xl">
SM
</div>
</MotionDiv>
</MotionDiv>
</div>
</div>
</Container>
);
};
export default Demo;
type Heading = {
heading: string;
subHeading: string;
};
const Heading = ({ heading, subHeading }: Heading) => {
return (
<div className="mb-2 flex flex-col items-start justify-center gap-0.5 px-6 pt-8 sm:mb-8 lg:pt-4">
<h2 className="text-card-title text-base font-medium tracking-tight sm:text-lg">
{heading}
</h2>
<p className="text-paragraph mt-1 max-w-lg text-xs leading-snug font-light text-pretty sm:text-sm">
{subHeading}
</p>
</div>
);
};
type IconCardType = {
className: string;
icon: React.ReactNode;
};
const IconCard = ({ className, icon }: IconCardType) => {
return (
<MotionDiv
className={cn(
`bg-card absolute top-10 z-10 flex w-fit items-center justify-center rounded-full border shadow-2xl inset-shadow-sm`,
className,
)}
>
<div className="flex size-9.5 items-center justify-center rounded-full p-1.5">
{icon}
</div>
</MotionDiv>
);
};
const Line2 = () => {
return (
<svg
width="107"
height="166"
viewBox="0 0 107 166"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="absolute top-19 left-30"
stroke="url(#line-2)"
>
<path strokeWidth={2} d="M106.5 166C106.5 91.8654 0.5 93.6701 0.5 0" />
<defs>
<motion.linearGradient
id="line-2"
initial={{
x1: "0%",
x2: "10%",
y1: "0%",
y2: "10%",
}}
animate={{
x1: "90%",
x2: "100%",
y1: "90%",
y2: "115%",
}}
transition={{
duration: 0.8,
repeat: Infinity,
repeatDelay: 1,
repeatType: "loop",
ease: "linear",
}}
>
<stop stopColor="#eeeeee" />
<stop offset="0.33" stopColor="#10b981" />
<stop offset="0.66" stopColor="#0891b2" />
<stop offset="0.1" stopColor="#eeeeee" />
</motion.linearGradient>
</defs>
</svg>
);
};
const Line3 = () => {
return (
<svg
width="50"
height="166"
viewBox="0 0 50 166"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="absolute top-19 left-47"
stroke="url(#line-3)"
>
<path strokeWidth={2} d="M49.5 166C49.5 93.9514 0.5 93.9514 0.5 0" />
<defs>
<motion.linearGradient
id="line-3"
initial={{
x1: "0%",
x2: "10%",
y1: "0%",
y2: "10%",
}}
animate={{
x1: "90%",
x2: "100%",
y1: "90%",
y2: "115%",
}}
transition={{
duration: 0.8,
repeat: Infinity,
repeatDelay: 1,
repeatType: "loop",
ease: "linear",
}}
>
<stop stopColor="#eeeeee" />
<stop offset="0.33" stopColor="#10b981" />
<stop offset="0.66" stopColor="#0891b2" />
<stop offset="0.1" stopColor="#eeeeee" />
</motion.linearGradient>
</defs>
</svg>
);
};
const Line4 = () => {
return (
<svg
width="50"
height="166"
viewBox="0 0 50 166"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="absolute top-19 right-47 rotate-y-180"
stroke="url(#line-4)"
>
<path strokeWidth={2} d="M49.5 166C49.5 93.9514 0.5 93.9514 0.5 0" />
<defs>
<motion.linearGradient
id="line-4"
initial={{
x1: "0%",
x2: "10%",
y1: "0%",
y2: "10%",
}}
animate={{
x1: "90%",
x2: "100%",
y1: "90%",
y2: "115%",
}}
transition={{
duration: 0.8,
repeat: Infinity,
repeatDelay: 1,
repeatType: "loop",
ease: "linear",
}}
>
<stop stopColor="#eeeeee" />
<stop offset="0.33" stopColor="#10b981" />
<stop offset="0.66" stopColor="#0891b2" />
<stop offset="0.1" stopColor="#eeeeee" />
</motion.linearGradient>
</defs>
</svg>
);
};
const Line5 = () => {
return (
<svg
width="107"
height="166"
viewBox="0 0 107 166"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="absolute top-19 right-30 rotate-y-180"
stroke="url(#line-2)"
>
<path strokeWidth={2} d="M106.5 166C106.5 91.8654 0.5 93.6701 0.5 0" />
<defs>
<motion.linearGradient
id="line-2"
initial={{
x1: "0%",
x2: "10%",
y1: "0%",
y2: "10%",
}}
animate={{
x1: "90%",
x2: "100%",
y1: "90%",
y2: "115%",
}}
transition={{
duration: 0.8,
repeat: Infinity,
repeatDelay: 1,
repeatType: "loop",
ease: "linear",
}}
>
<stop stopColor="#eeeeee" />
<stop offset="0.33" stopColor="#10b981" />
<stop offset="0.66" stopColor="#0891b2" />
<stop offset="0.1" stopColor="#eeeeee" />
</motion.linearGradient>
</defs>
</svg>
);
};
const Line6 = () => {
return (
<svg
width="169"
height="167"
viewBox="0 0 169 167"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="absolute top-19 right-13 rotate-y-180"
>
<path
strokeWidth={2}
stroke="url(#line-6)"
d="M168.499 166.026C168.499 99.7414 5.52495 96.283 0.499313 0.0260773"
/>
<defs>
<motion.linearGradient
id="line-6"
initial={{
x1: "0%",
x2: "10%",
y1: "0%",
y2: "10%",
}}
animate={{
x1: "90%",
x2: "100%",
y1: "90%",
y2: "115%",
}}
transition={{
duration: 0.8,
repeat: Infinity,
repeatDelay: 1,
repeatType: "loop",
ease: "linear",
}}
>
<stop stopColor="#eeeeee" />
<stop offset="0.33" stopColor="#10b981" />
<stop offset="0.66" stopColor="#0891b2" />
<stop offset="0.1" stopColor="#eeeeee" />
</motion.linearGradient>
</defs>
</svg>
);
};
Let’s take the SVG we created in Figma and use it here.
First, we’ll turn it into a component called Line1.
Here’s what the component looks like.
const Line1 = () => {
return (
<svg
width="169"
height="167"
viewBox="0 0 169 167"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="absolute top-19 left-13"
>
<path
strokeWidth={2}
stroke="#eeeeee"
d="M168.499 166.026C168.499 99.7414 5.52495 96.283 0.499313 0.0260773"
/>
</svg>
);
};
Now, to animate the line, we’ll use Motion with a linear gradient.
To do that, we need to place our gradient inside a <defs> section, which is where SVG stores reusable definitions.
Next, we’ll turn the linear gradient into a Motion element and give it an ID so we can reference it from the SVG stroke.
const Line1 = () => {
return (
<svg
width="169"
height="167"
viewBox="0 0 169 167"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="absolute top-19 left-13"
>
<path
strokeWidth={2}
stroke="url(#line-1)" // use the id here
d="M168.499 166.026C168.499 99.7414 5.52495 96.283 0.499313 0.0260773"
/>
<defs>
<motion.linearGradient
id="line-1"
>
<stop stopColor="#eeeeee" />
<stop offset="0.33" stopColor="#dc2626" />
<stop offset="0.66" stopColor="#3b82f6" />
<stop offset="1" stopColor="#eeeeee" />
</motion.linearGradient>
</defs>
</svg>
);
};
A <stop> defines one color point inside a gradient.
A gradient is essentially a list of stops that blend seamlessly into one another.
stopColor is the actual color for that stop.
offset defines where the color appears in the gradient.
It is like a progress percentage:
Inside the linear gradient, we have four values: x1 and x2 control how the gradient moves along the x-axis, and y1 and y2 control movement along the y-axis.
For this example, let’s start by animating x1 and x2.
const Line1 = () => {
return (
<svg
width="169"
height="167"
viewBox="0 0 169 167"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="absolute top-19 left-13"
>
<path
strokeWidth={2}
stroke="url(#line-1)" // use the id here
d="M168.499 166.026C168.499 99.7414 5.52495 96.283 0.499313 0.0260773"
/>
<defs>
<motion.linearGradient
id="line-1"
initial={{
x1: "0%",
x2: "10%",
}}
>
<stop stopColor="#eeeeee" />
<stop offset="0.33" stopColor="#dc2626" />
<stop offset="0.66" stopColor="#3b82f6" />
<stop offset="1" stopColor="#eeeeee" />
</motion.linearGradient>
</defs>
</svg>
);
};
In the original gradient, x1 = 0% and x2 = 10% define where the red/blue part of the gradient starts.
Now, you can probably guess what we’re going to do
we’ll animate those same values so the gradient moves across the line.
So instead of starting at 0% → 10%, we’ll animate them to 90% → 100%.
const Line1 = () => {
return (
<svg
width="169"
height="167"
viewBox="0 0 169 167"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="absolute top-19 left-13"
>
<path
strokeWidth={2}
stroke="url(#line-1)" // use the id here
d="M168.499 166.026C168.499 99.7414 5.52495 96.283 0.499313 0.0260773"
/>
<defs>
<motion.linearGradient
id="line-1"
initial={{
x1: "0%",
x2: "10%",
}}
animate={{
x1: "90%",
x2: "110%",
}}
transition={{
duration: 0.8,
repeat: Infinity,
repeatDelay: 1,
repeatType: "loop",
ease: "linear",
}}
>
<stop stopColor="#eeeeee" />
<stop offset="0.33" stopColor="#dc2626" />
<stop offset="0.66" stopColor="#3b82f6" />
<stop offset="1" stopColor="#eeeeee" />
</motion.linearGradient>
</defs>
</svg>
);
};
we’re basically telling the gradient to slide forward along the line, creating that smooth glowing effect.
The gradient starts at its initial position and then moves from 90% to 110%, looping continuously. The linear easing keeps the motion steady, while the repeat delay adds a small pause before the next cycle, making the animation feel more natural and less aggressive.
Let's see it in action
But as you can see, the gradient doesn’t start from the top, it appears somewhere in the middle.
That’s happening because we’re only animating the x-axis values, not the y-axis. The gradient is technically moving, but only horizontally, so the effect doesn’t align with the shape of the curve.
To understand this better, we can use a simple <rect> example. A behaves similarly to a <path>, but because it’s a clean, straight shape, it makes the gradient behavior much easier to see.
const Line1 = () => {
return (
<svg
width="169"
height="167"
viewBox="0 0 169 167"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="absolute top-19 left-13"
>
{/* <path
strokeWidth={2}
stroke="url(#line-1)"
d="M168.499 166.026C168.499 99.7414 5.52495 96.283 0.499313 0.0260773"
/> */}
<rect
width="169"
height="167"
d="M168.499 166.026C168.499 99.7414 5.52495 96.283 0.499313 0.0260773"
fill="url(#line-1)"
/>
<defs>
<motion.linearGradient
id="line-1"
initial={{
x1: "0%",
x2: "10%",
}}
animate={{
x1: "90%",
x2: "110%",
}}
transition={{
duration: 0.8,
repeat: Infinity,
repeatDelay: 1,
repeatType: "loop",
ease: "linear",
}}
>
<stop stopColor="#eeeeee" />
<stop offset="0.33" stopColor="#dc2626" />
<stop offset="0.66" stopColor="#3b82f6" />
<stop offset="1" stopColor="#eeeeee" />
</motion.linearGradient>
</defs>
</svg>
);
};
So now let’s also animate the y-axis values.
By adjusting y1 and y2 along with the x-axis, the gradient will follow the full direction of the line instead of cutting through the middle.
const Line1 = () => {
return (
<svg
width="169"
height="167"
viewBox="0 0 169 167"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="absolute top-19 left-13"
>
{/* <path
strokeWidth={2}
stroke="url(#line-1)"
d="M168.499 166.026C168.499 99.7414 5.52495 96.283 0.499313 0.0260773"
/> */}
<rect
width="169"
height="167"
d="M168.499 166.026C168.499 99.7414 5.52495 96.283 0.499313 0.0260773"
fill="url(#line-1)"
/>
<defs>
<motion.linearGradient
id="line-1"
initial={{
x1: "0%",
x2: "10%",
y1: "0%",
y2: "10%",
}}
animate={{
x1: "90%",
x2: "110%",
y1: "90%",
y2: "110%",
}}
transition={{
duration: 0.8,
repeat: Infinity,
repeatDelay: 1,
repeatType: "loop",
ease: "linear",
}}
>
<stop stopColor="#eeeeee" />
<stop offset="0.33" stopColor="#dc2626" />
<stop offset="0.66" stopColor="#3b82f6" />
<stop offset="1" stopColor="#eeeeee" />
</motion.linearGradient>
</defs>
</svg>
);
};
Now just remove the and switch back to your , and you’ll have a clean, modern linear-gradient animation flowing perfectly across your SVG line.
const Line1 = () => {
return (
<svg
width="169"
height="167"
viewBox="0 0 169 167"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="absolute top-19 left-13"
>
<path
strokeWidth={2}
stroke="url(#line-1)"
d="M168.499 166.026C168.499 99.7414 5.52495 96.283 0.499313 0.0260773"
/>
<defs>
<motion.linearGradient
id="line-1"
initial={{
x1: "0%",
x2: "10%",
y1: "0%",
y2: "10%",
}}
animate={{
x1: "90%",
x2: "110%",
y1: "90%",
y2: "110%",
}}
transition={{
duration: 0.8,
repeat: Infinity,
repeatDelay: 1,
repeatType: "loop",
ease: "linear",
}}
>
<stop stopColor="#eeeeee" />
<stop offset="0.33" stopColor="#dc2626" />
<stop offset="0.66" stopColor="#3b82f6" />
<stop offset="1" stopColor="#eeeeee" />
</motion.linearGradient>
</defs>
</svg>
);
};
## End Result:
And that’s it — we’ve learned how to create an SVG in Figma, export it, and animate it using Motion.
If you found this helpful, follow me on Twitter for more tips, breakdowns, and behind-the-scenes builds.
I’m also launching my template soon, so stay tuned — you won’t want to miss it! 🚀
2025-12-10 23:22:23
Two months ago, someone replied to my tweet asking for ideas for new software.
The reply briefly mentioned the need for a local-based secret manager, which piqued my interest and led me to start researching.
There, I read a surprising article.
It was about how AI agents, under the guise of helping users during development or due to manipulated web search results, could leak .env files.
Security companies like PromptArmor and Mindgard have documented how AI agents can be exploited to extract information from accessible files.
So I decided to create a secret manager that prevents AI agents from viewing .env files and bypassing commands.
Honestly, most people store secrets in plaintext .env files.
It's convenient, it works, and that's how it's always been done.
But because it's plaintext, any process with access to the file can send it somewhere, accidentally commit it to Git, or, as mentioned earlier, an AI could unintentionally leak it.
Additionally, since .env is located in the project directory, it's hard to manage centrally.
Doppler is great when working in a team and needing collaboration features.
AWS Secrets Manager is perfect if you're already deep into the AWS ecosystem.
But I'm not part of a team, I don't use AWS, and I prefer offline solutions.
SaaS-based tools like Doppler even require monthly payments.
And there's one key difference between my tool and others: it requires user approval to use secrets.
I built a per-request approval system, not just per-user or per-process (similar to how 1Password's SSH feature works).
Encryption
All secrets are encrypted with AES-256-GCM before touching disk.
The encryption key is derived from the master password using PBKDF2 with 100,000 iterations.
Easy Migration
I designed it to be easy from the start and created guides so you don't have to spend much time learning a new tool.
Unexpected Age Barrier
Something I didn't expect: to properly distribute a macOS app, you need to pay $99 annually and join Apple's Developer Program.
I think this is an important security measure for macOS, even if it's inconvenient.
But what might be a bigger issue—and perhaps unique to me—is that you must be at least 19 years old to join.
I'm under 19. So LocalKeys only worked on my Mac.
Others couldn't run it on their Macs without security warnings.
It was frustrating, but it was a good lesson in understanding platform requirements upfront.
Since I couldn't find a proper solution, I'm temporarily showing users how to remove the quarantine attribute after download.
It's not ideal, but it's the only option until I become an adult and join the developer program.
Real User Feedback
After launch, I posted about LocalKeys on Reddit. Someone pointed out, "How do you use the same secrets across multiple devices? Most people use different computers at work and home."
A valid point. I was so focused on "local-first" that I overlooked the multi-device workflow many developers need.
I've now added optional encrypted synchronization to the roadmap, maintaining the local-first philosophy while adding convenience when needed.
After two months of development and real-world testing, I launched LocalKeys.
LocalKeys is open source, anyone can build it, it's a one-time purchase, and it's simple.
You can check it out at localkeys.privatestater.com
Good cases for using LocalKeys:
When LocalKeys isn't a good fit:
This is my first desktop software, and I'm sure there are things I haven't considered.
As an indie developer, building publicly is both scary and exciting.
If you've faced similar security concerns or have thoughts on the approach, I'd really appreciate it.
And if you try LocalKeys, please let me know what breaks!
Note: This isn't to say cloud solutions are bad. They're great for team or enterprise use cases. This is just a different approach for different needs.
2025-12-10 23:21:59
Hosting a static website is simple in theory, but hosting it in a way that is fast, secure, and globally accessible requires the right architecture. AWS provides two services that work extremely well together for this purpose: Amazon S3 for storage and Amazon CloudFront for global delivery.
In this blog, I want to walk you through why S3 + CloudFront is such a powerful combination and how I implemented the entire solution in Terraform.
Amazon S3 is a great place to store static content like HTML, CSS, JavaScript, and images. It's reliable, scalable, and inexpensive. However, S3 alone is not optimized for global content delivery. If your bucket is in Mumbai and a user in Europe tries to access your website, the latency will be noticeable.
CloudFront solves this problem by delivering your content through a worldwide network of edge locations. When a user accesses your site, CloudFront serves the content from the nearest location. This reduces delays, speeds up page load time, and improves the overall user experience, no matter where the user is located.
Instead of making your S3 bucket public, CloudFront can access the bucket on behalf of the users through an Origin Access Control (OAC). This ensures that your S3 bucket remains private while still allowing your website to be accessible globally. It’s a secure and modern approach.
CloudFront also enables HTTPS by default using an AWS-managed certificate. Even if you don’t have a custom domain, CloudFront provides a secure .cloudfront.net URL for your website.
Below is the exact Terraform code I used to deploy the entire setup. It covers S3 bucket creation, file upload, OAC configuration, bucket policies, and CloudFront distribution.
// Create an S3 bucket with a dynamic name using prefix and name variables
resource "aws_s3_bucket" "website" {
bucket = "${var.bucket_prefix}${var.bucket_name}"
}
// Block public access to the S3 bucket
resource "aws_s3_bucket_public_access_block" "website_public_access_block" {
bucket = aws_s3_bucket.website.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
// Create a CloudFront Origin Access Control for the S3 bucket
resource "aws_cloudfront_origin_access_control" "oac" {
name = "${var.bucket_prefix}-oac"
description = "OAC for ${var.bucket_name}"
origin_access_control_origin_type = "s3"
signing_behavior = "always"
signing_protocol = "sigv4"
}
// S3 Bucket Policy to allow access from cloudfront origin access control
resource "aws_s3_bucket_policy" "website_bucket_policy" {
bucket = aws_s3_bucket.website.id
depends_on = [aws_s3_bucket_public_access_block.website_public_access_block]
policy = jsonencode({
"Version" : "2012-10-17",
"Statement" : [
{
"Sid" : "AllowCloudFrontAccess",
"Effect" : "Allow",
"Principal" : {
"Service" : "cloudfront.amazonaws.com"
},
"Action" : [
"s3:GetObject"
],
"Resource" : "${aws_s3_bucket.website.arn}/*"
Condition = {
StringEquals = {
"AWS:SourceArn" = aws_cloudfront_distribution.s3_distribution.arn
}
}
}
]
})
}
// upload files to the S3 bucket
resource "aws_s3_object" "website_files" {
for_each = fileset("${path.module}/www", "**/*")
bucket = aws_s3_bucket.website.id
key = each.value
source = "${path.module}/www/${each.value}"
etag = filemd5("${path.module}/www/${each.value}")
content_type = lookup({
"html" = "text/html",
"css" = "text/css",
"js" = "application/javascript",
"json" = "application/json",
"png" = "image/png",
"jpg" = "image/jpeg",
"jpeg" = "image/jpeg",
"gif" = "image/gif",
"svg" = "image/svg+xml",
"ico" = "image/x-icon",
"txt" = "text/plain"
}, split(".", each.value)[length(split(".", each.value)) - 1], "application/octet-stream")
}
// Create a CloudFront Distribution to serve content from the S3 bucket
resource "aws_cloudfront_distribution" "s3_distribution" {
origin {
domain_name = aws_s3_bucket.website.bucket_regional_domain_name
origin_id = local.origin_id
origin_access_control_id = aws_cloudfront_origin_access_control.oac.id
}
enabled = true
is_ipv6_enabled = true
default_root_object = "index.html"
default_cache_behavior {
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
target_origin_id = local.origin_id
forwarded_values {
query_string = false
cookies {
forward = "none"
}
}
viewer_protocol_policy = "redirect-to-https"
min_ttl = 0
default_ttl = 3600
max_ttl = 86400
}
price_class = "PriceClass_100"
restrictions {
geo_restriction {
restriction_type = "none"
}
}
viewer_certificate {
cloudfront_default_certificate = true
}
}
After running the usual Terraform commands—init, validate, plan, and apply—you get a CloudFront URL that securely serves your static website. The S3 bucket stays completely private while the content is delivered quickly and securely across the world.
This setup gives you a production-ready hosting environment that is fast, secure, and automated.
2025-12-10 23:21:59