Compare commits

..

3 Commits

Author SHA1 Message Date
f3eb83bcc0 Add CI/CD for auto-deployment of blog
All checks were successful
Build and Push Docker Image / build (push) Successful in 3m23s
2026-04-15 16:37:12 -07:00
b96f7ed3f0 Add Docker Build Support
Blog posts with 'draft: true' in the frontmatter are excluded from the production artifact
--no-cache docker builds ensure fresh database build each time. Caching isn't needed do to small size anyway
2026-04-15 12:25:31 -07:00
603687c46b Add gzip compression to responses 2026-01-27 17:35:34 -08:00
11 changed files with 511 additions and 42 deletions

15
.dockerignore Normal file
View File

@@ -0,0 +1,15 @@
node_modules
.git
.gitignore
README.md
LICENSE
.vscode
.idea
.env
.editorconfig
coverage*
dist
*.log
blog.sqlite
Dockerfile*
docker-compose*

View File

@@ -0,0 +1,60 @@
name: Build and Push Docker Image
on:
push:
branches:
- main
env:
REGISTRY: git.cbraaten.dev
IMAGE_NAME: git.cbraaten.dev/caleb/blog
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Get short SHA
id: sha
run: echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
- name: Print Secrets
run: |
if [ -z "${{ secrets.REGISTRY_USERNAME }}" ]; then
echo "ERROR: REGISTRY_USERNAME secret is not set"
exit 1
else
echo "✓ REGISTRY_USERNAME is set"
fi
if [ -z "${{ secrets.REGISTRY_PASSWORD }}" ]; then
echo "ERROR: REGISTRY_PASSWORD secret is not set"
exit 1
else
echo "✓ REGISTRY_PASSWORD is set"
fi
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and Push Docker Image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: true
tags: |
${{ env.IMAGE_NAME }}:latest
${{ env.IMAGE_NAME }}:${{ steps.sha.outputs.short_sha }}

View File

@@ -0,0 +1,46 @@
name: Deploy to Nomad
on:
workflow_run:
workflows: ["Build and Push Docker Image"]
types:
- completed
env:
NOMAD_ADDR: ${{ secrets.NOMAD_ADDR }}
NOMAD_TOKEN: ${{ secrets.NOMAD_TOKEN }}
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Get image tag from git SHA
id: tag
run: |
image_tag=$(git rev-parse --short HEAD)
echo "image_tag=$image_tag" >> $GITHUB_OUTPUT
- name: Update image tag in job definition
run: |
# Replace the image tag placeholder with actual tag
jq '.Job.TaskGroups[0].Tasks[0].Config.image |= sub(":.*"; ":${{ steps.tag.outputs.image_tag }}")' \
blog.nomad.json > blog.nomad.final.json
- name: Submit job via Nomad API
run: |
curl -s -X POST \
-H "X-Nomad-Token: ${{ env.NOMAD_TOKEN }}" \
-H "Content-Type: application/json" \
-d @blog.nomad.final.json \
"${{ env.NOMAD_ADDR }}/v1/jobs"
- name: Verify deployment
run: |
echo "Waiting for deployment to stabilize..."
sleep 5
curl -X GET \
-H "X-Nomad-Token: ${{ env.NOMAD_TOKEN }}" \
"${{ env.NOMAD_ADDR }}/v1/job/blog"

2
.gitignore vendored
View File

@@ -1,6 +1,7 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies # dependencies
blog.sqlite
/node_modules /node_modules
/.pnp /.pnp
.pnp.js .pnp.js
@@ -25,6 +26,7 @@ yarn-debug.log*
yarn-error.log* yarn-error.log*
# local env files # local env files
.env
.env.local .env.local
.env.development.local .env.development.local
.env.test.local .env.test.local

51
Dockerfile Normal file
View File

@@ -0,0 +1,51 @@
# Build stage
FROM oven/bun:1 AS builder
WORKDIR /usr/src/app
# Install dependencies
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
# Copy source code and content
COPY . .
# Copy database initialization script
COPY init-db.ts ./
# Initialize database using init-db.ts script
# This script handles draft-aware post verification and stability detection
ENV NODE_ENV=build
RUN bun run init-db.ts
# Final production stage
FROM oven/bun:1
WORKDIR /usr/src/app
# Copy dependencies
COPY --from=builder /usr/src/app/node_modules ./node_modules
COPY --from=builder /usr/src/app/package.json ./
# Copy source code (for runtime transpilation)
COPY --from=builder /usr/src/app/src ./src
COPY --from=builder /usr/src/app/content ./content
COPY --from=builder /usr/src/app/bun_plugins ./bun_plugins
COPY --from=builder /usr/src/app/index.tsx .
COPY --from=builder /usr/src/app/tsconfig.json .
# Copy the initialized database for efficient layer caching
COPY --from=builder /usr/src/app/blog.sqlite ./
# Fix ownership for bun user (needed for runtime operations)
RUN chown -R bun:bun /usr/src/app
# Switch to bun user
USER bun
# Expose the default port
EXPOSE 3000/tcp
# Production environment (onStartup-post-importer won't run since DB exists)
ENV NODE_ENV=production
# Run the application directly (Bun handles TSX transpilation at runtime)
ENTRYPOINT [ "bun", "run", "index.tsx" ]

54
blog.nomad.hcl Normal file
View File

@@ -0,0 +1,54 @@
# export to json for ci/cd scripts with the following command
# nomad job run -output blog.nomad.hcl > blog.nomad.json
locals {
HOST = "cbraaten.dev"
}
variable "image_tag" {
type = string
default = "latest"
}
job "blog" {
type = "service"
group "blog" {
count = 1
network {
port "http" {
to = 3000
}
}
service {
name = "blog"
provider = "consul"
port = "http"
tags = [
"traefik.enable=true",
"traefik.http.routers.domainredirect.tls=true",
"traefik.http.routers.domainredirect.entrypoints=websecure",
"traefik.http.routers.domainredirect.rule=Host(`${local.HOST}`)",
]
}
task "blog" {
driver = "docker"
config {
image = "git.cbraaten.dev/caleb/blog:${var.image_tag}"
ports = ["http"]
}
}
update {
max_parallel = 1
min_healthy_time = "10s"
healthy_deadline = "3m"
auto_revert = true
}
}
}

164
blog.nomad.json Normal file
View File

@@ -0,0 +1,164 @@
{
"Job": {
"Region": null,
"Namespace": null,
"ID": "blog",
"Name": "blog",
"Type": "service",
"Priority": null,
"AllAtOnce": null,
"Datacenters": null,
"NodePool": null,
"Constraints": null,
"Affinities": null,
"TaskGroups": [
{
"Name": "blog",
"Count": 1,
"Constraints": null,
"Affinities": null,
"Tasks": [
{
"Name": "blog",
"Driver": "docker",
"User": "",
"Lifecycle": null,
"Config": {
"image": "git.cbraaten.dev/caleb/blog:latest",
"ports": [
"http"
]
},
"Constraints": null,
"Affinities": null,
"Env": null,
"Services": null,
"Resources": null,
"RestartPolicy": null,
"Meta": null,
"KillTimeout": null,
"LogConfig": null,
"Artifacts": null,
"Vault": null,
"Consul": null,
"Templates": null,
"DispatchPayload": null,
"VolumeMounts": null,
"Leader": false,
"ShutdownDelay": 0,
"KillSignal": "",
"Kind": "",
"ScalingPolicies": null,
"Secrets": null,
"Identity": null,
"Identities": null,
"Actions": null,
"Schedule": null
}
],
"Spreads": null,
"Volumes": null,
"RestartPolicy": null,
"Disconnect": null,
"ReschedulePolicy": null,
"EphemeralDisk": null,
"Update": {
"Stagger": null,
"MaxParallel": 1,
"HealthCheck": null,
"MinHealthyTime": 10000000000,
"HealthyDeadline": 180000000000,
"ProgressDeadline": null,
"Canary": null,
"AutoRevert": true,
"AutoPromote": null
},
"Migrate": null,
"Networks": [
{
"Mode": "",
"Device": "",
"CIDR": "",
"IP": "",
"DNS": null,
"ReservedPorts": null,
"DynamicPorts": [
{
"Label": "http",
"Value": 0,
"To": 3000,
"HostNetwork": "",
"IgnoreCollision": false
}
],
"Hostname": "",
"MBits": null,
"CNI": null
}
],
"Meta": null,
"Services": [
{
"Name": "blog",
"Tags": [
"traefik.enable=true",
"traefik.http.routers.domainredirect.tls=true",
"traefik.http.routers.domainredirect.entrypoints=websecure",
"traefik.http.routers.domainredirect.rule=Host(`cbraaten.dev`)"
],
"CanaryTags": null,
"EnableTagOverride": false,
"PortLabel": "http",
"AddressMode": "",
"Address": "",
"Checks": null,
"CheckRestart": null,
"Connect": null,
"Meta": null,
"CanaryMeta": null,
"TaggedAddresses": null,
"TaskName": "",
"OnUpdate": "",
"Identity": null,
"Weights": null,
"Provider": "consul",
"Cluster": "",
"Kind": ""
}
],
"ShutdownDelay": null,
"StopAfterClientDisconnect": null,
"MaxClientDisconnect": null,
"Scaling": null,
"Consul": null,
"PreventRescheduleOnLost": null
}
],
"Update": null,
"Multiregion": null,
"Spreads": null,
"Periodic": null,
"ParameterizedJob": null,
"Reschedule": null,
"Migrate": null,
"Meta": null,
"UI": null,
"Stop": null,
"ParentID": null,
"Dispatched": false,
"DispatchIdempotencyToken": null,
"Payload": null,
"ConsulNamespace": null,
"VaultNamespace": null,
"NomadTokenID": null,
"Status": null,
"StatusDescription": null,
"Stable": null,
"Version": null,
"SubmitTime": null,
"CreateIndex": null,
"ModifyIndex": null,
"JobModifyIndex": null,
"VersionTag": null
}
}

View File

@@ -14,6 +14,11 @@ import { dbConnection } from "../src/db";
const {data, content } = matter(await Bun.file(`./content/${file}`).text()); const {data, content } = matter(await Bun.file(`./content/${file}`).text());
const route = `/${file.replace(/\.md$/, "")}`; const route = `/${file.replace(/\.md$/, "")}`;
// Omit draft blog posts from database initialization in production
if (process.env.NODE_ENV === 'build' && data.draft) {
continue;
}
// Remove the title from content if it matches the frontmatter title to avoid duplicate H1s // Remove the title from content if it matches the frontmatter title to avoid duplicate H1s
let processedContent = content; let processedContent = content;
if (data.title) { if (data.title) {

View File

@@ -7,6 +7,18 @@ import { NotFound } from "./src/frontend/pages/not-found";
import { Post } from "./src/frontend/pages/post"; import { Post } from "./src/frontend/pages/post";
import { dbConnection } from "./src/db"; import { dbConnection } from "./src/db";
function compressResponse(html: string, status?: number) {
const compressed = Bun.gzipSync(Buffer.from(html));
return new Response(compressed, {
status,
headers: {
"Content-Type": "text/html",
"Content-Encoding": "gzip",
"Vary": "Accept-Encoding"
}
});
}
async function blogPosts(hmr: boolean) { async function blogPosts(hmr: boolean) {
const glob = new Bun.Glob("**/*.md"); const glob = new Bun.Glob("**/*.md");
const blogPosts: Record<string, any> = {}; const blogPosts: Record<string, any> = {};
@@ -24,10 +36,7 @@ async function blogPosts(hmr: boolean) {
const path = new URL(req.url).pathname; const path = new URL(req.url).pathname;
const post = dbConnection.getPost(path); const post = dbConnection.getPost(path);
if (!post) if (!post)
return new Response(renderToString(<NotFound />), { return compressResponse(renderToString(<NotFound />), 404);
status: 404,
headers: { "Content-Type": "text/html" },
});
// Get adjacent posts for navigation // Get adjacent posts for navigation
const { previousPost, nextPost } = dbConnection.getAdjacentPosts(post.path); const { previousPost, nextPost } = dbConnection.getAdjacentPosts(post.path);
@@ -44,18 +53,13 @@ async function blogPosts(hmr: boolean) {
// AppShell is already loaded, just send the <main> content // AppShell is already loaded, just send the <main> content
if (req.headers.get("shell-loaded") === "true") { if (req.headers.get("shell-loaded") === "true") {
return new Response( return compressResponse(
renderToString(<Post meta={data} children={post.content} />), renderToString(<Post meta={data} children={post.content} />)
{
headers: {
"Content-Type": "text/html",
},
},
); );
} }
// AppShell is not loaded, send the <AppShell> with the <main> content inside // AppShell is not loaded, send the <AppShell> with the <main> content inside
return new Response( return compressResponse(
renderToString( renderToString(
<AppShell> <AppShell>
<Post <Post
@@ -63,12 +67,7 @@ async function blogPosts(hmr: boolean) {
children={post.content} children={post.content}
/> />
</AppShell>, </AppShell>,
), )
{
headers: {
"Content-Type": "text/html",
},
},
); );
}; };
} }
@@ -99,24 +98,15 @@ Bun.serve({
const searchParams = new URLSearchParams(req.url.split('?')[1]); const searchParams = new URLSearchParams(req.url.split('?')[1]);
if (req.headers.get("shell-loaded") === "true") { if (req.headers.get("shell-loaded") === "true") {
return new Response(renderToString(<Home searchParams={searchParams} />), { return compressResponse(renderToString(<Home searchParams={searchParams} />));
headers: {
"Content-Type": "text/html",
},
});
} }
return new Response( return compressResponse(
renderToString( renderToString(
<AppShell searchParams={searchParams}> <AppShell searchParams={searchParams}>
<Home searchParams={searchParams} /> <Home searchParams={searchParams} />
</AppShell>, </AppShell>,
), )
{
headers: {
"Content-Type": "text/html",
},
},
); );
}, },
"/profile-picture.webp": () => { "/profile-picture.webp": () => {
@@ -128,15 +118,9 @@ Bun.serve({
}, },
"/*": (req) => { "/*": (req) => {
if(req.headers.get("shell-loaded") === "true") { if(req.headers.get("shell-loaded") === "true") {
return new Response(renderToString(<NotFound />), { return compressResponse(renderToString(<NotFound />), 404);
status: 404,
headers: { "Content-Type": "text/html" },
});
} }
return new Response(renderToString(<AppShell><NotFound /></AppShell>), { return compressResponse(renderToString(<AppShell><NotFound /></AppShell>), 404);
status: 404,
headers: { "Content-Type": "text/html" },
});
} }
}, },
}); });

85
init-db.ts Normal file
View File

@@ -0,0 +1,85 @@
const { Database } = require("bun:sqlite");
async function waitForDatabase() {
const serverProcess = Bun.spawn(["bun", "run", "index.tsx"], {
env: { ...process.env, NODE_ENV: "development" },
stdout: "pipe",
stderr: "pipe"
});
// Count expected markdown files - this gives us total potential posts
const files = [];
for await (const file of new Bun.Glob("**/*.md").scan("./content")) {
files.push(file);
}
const totalMarkdownFiles = files.length;
console.log(`Found ${totalMarkdownFiles} markdown files to process`);
const maxWait = 30000; // 30 seconds total timeout
const checkInterval = 100; // Check every 100ms
let waited = 0;
let stableCount = 0;
let previousCount = -1;
while (waited < maxWait) {
if (await Bun.file("blog.sqlite").exists()) {
try {
const db = new Database("blog.sqlite", { readonly: true });
// Count only published posts (routes ending without .md)
// This matches the logic used in the actual application queries
const result = db.query("SELECT COUNT(*) as count FROM posts WHERE path NOT LIKE '%.md'").get();
const publishedPosts = result.count;
db.close();
if (publishedPosts === previousCount) {
stableCount++;
} else {
stableCount = 0;
previousCount = publishedPosts;
console.log(`Importing progress: ${publishedPosts}/${totalMarkdownFiles} published posts`);
}
// If the count has been stable for 10 consecutive checks (1 second), consider import complete
// We check against available posts, not total markdown files, since some may be drafts
if (stableCount >= 10 && publishedPosts >= 1) {
console.log(`Import complete: ${publishedPosts} published posts ready`);
break;
}
} catch (e) {
// Database might still be writing or locked
}
}
await new Promise(resolve => setTimeout(resolve, checkInterval));
waited += checkInterval;
}
// Check if we succeeded
if (!(await Bun.file("blog.sqlite").exists())) {
console.error("Database file was not created");
await serverProcess.kill();
process.exit(1);
}
// Final verification - check published posts count
const db = new Database("blog.sqlite", { readonly: true });
const result = db.query("SELECT COUNT(*) as count FROM posts WHERE path NOT LIKE '%.md'").get();
const finalPublishedPosts = result.count;
// Also get total posts for informational purposes
const totalResult = db.query("SELECT COUNT(*) as count FROM posts").get();
const totalPosts = totalResult.count;
db.close();
if (finalPublishedPosts < 1) {
console.error(`No published posts found! Total: ${totalPosts}, Published: ${finalPublishedPosts}`);
await serverProcess.kill();
process.exit(1);
}
await serverProcess.kill();
console.log(`Database initialization complete: ${totalPosts} total posts (${finalPublishedPosts} published)`);
}
waitForDatabase();

View File

@@ -2,8 +2,11 @@
"name": "portfolio", "name": "portfolio",
"version": "1.0.50", "version": "1.0.50",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1", "dev": "bun run --watch ./index.tsx",
"dev": "bun run --watch ./index.tsx" "build": "bun run docker:build",
"docker:build": "docker build --no-cache -t blog:latest .",
"docker:run": "docker run -d -p 3000:3000 --name blog blog:latest",
"docker:stop": "docker stop blog && docker rm blog"
}, },
"dependencies": { "dependencies": {
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",