I built pulstack (pulumi+stack), a CLI tool that lets you deploy static websites either to:
βοΈ AWS S3 + CloudFront
π GitHub Pages
All powered by Pulumi Automation API, GitHub, and AWS.π
With one command, you go from a folder of static files to a live website in less than one minuteπ. No need of writing any code or No clicking around GitHub. 100% Automated π
How It Works
Itβs a 2-step flow, depending on what youβre targeting:
For GitHub Pages:
node index.js init --github# prompts for GitHub token and repo info
node index.js deploy --target github-pages --dir ./public
# creates repo, pushes gh-pages branch, enables github Pages
For AWS (S3 + CloudFront):
node index.js init
# prompts for project name, stack name, and AWS region
node index.js deploy --target aws --dir ./public
# provisions an S3 bucket, uploads files, sets up CloudFront distro
It uses Pulumiβs Automation API under the hood to manage stacks, config, resources, and output all in code β no YAML.
β‘ pulstack β Instant Static Site Deployment with Pulumi
pulstack is a developer-friendly tool that lets you deploy static websites to AWS (S3 + CloudFront) or GitHub Pages with zero configuraton. It uses Pulumi under the hood to treat infrastructure as code, so your deployments are fully automated and version-controlled.
β¨ Features
π Deploy static sites to AWS S3 with CloudFront CDN
π Automatically create and publish to GitHub Pages
π Secure AWS deployments using best practices (no public buckets!)
π‘ Clean CLI prompts to guide you through setup
𧨠One-command destroy of your whole stack when you're done
π¦ Prerequisites
Before using pulstack, make sure you have the following installed and configured:
This I started as a fun weekend challenge to explore the Pulumi Automation API. Initially, I just wanted to automate AWS S3 site deploys. But then I thought β why stop there? Letβs do GitHub Pages too.
Why Pulstack?π€
When I ran pulumi new aws-javascript, it generated the default Pulumi YAML files and a basic index.js where I had to manually define the S3 bucket, policy, CloudFront distribution, etc.
So I decided to build Pulstack, a zero-config CLI where you answer a few prompts and boom: it handles stack creation, configuration, deployment, and even GitHub repo setup (if youβre going the Pages route).
Hereβs how it went:π
π index.js
I started off by setting up the CLI with commander.js. Its simple, lightweight, and does exactly what I need.
#!/usr/bin/env node
const{Command}=require("commander");const{deploy}=require("./deploy");const{destroy}=require("./destroy");const{initProject}=require("./init");const{deployGithub}=require("./deployGithub");constprogram=newCommand();program.name("pulstack").description("Deploy static site to AWS S3 or GitHub using Pulumi instantly").version("0.1.0");program.command("deploy").description("Deploy static site to AWS or GitHub Pages").requiredOption("-d, --dir <path>","Path to static site files").option("-e, --env <name>","Environment/stack name","dev").option("-t, --target <provider>","Target platform: aws | github-pages","aws").action(async (opts)=>{consttarget=opts.target;if (target==="github-pages"){awaitdeployGithub(opts.dir);}elseif (target==="aws"){awaitdeploy(opts.dir,opts.env);}else{console.error(`β Unsupported target: ${target}`);process.exit(1);}});program.command("init").description("Initialize project and config").option("--github","Initialize for GitHub Pages").action(async (opts)=>{awaitinitProject({github:opts.github});});program.command("destroy").description("Destroy project").action(async ()=>{awaitdestroy();});program.parse();
The CLI has three main commands:
init β sets up the Pulumi project config
deploy β handles deployments to AWS or GitHub Pages
destroy β To destroy the stack you created
Depending on the target platform passed via --target, it routes to either AWS (deploy.js) or GitHub Pages (deployGithub.js). I also made the static folder path a required option so users donβt forget it.
init.js
Before anything gets deployed, we need to gather some info β and thatβs what init.js handles. It sets up the project depending on whether you want to deploy to AWS or GitHub Pages.
constfs=require("fs");constpath=require("path");constprompts=require("prompts");const{LocalWorkspace}=require("@pulumi/pulumi/automation");const{execSync}=require("child_process");functioncheckCLI(command,name){try{execSync(command,{stdio:"ignore"});console.log(`β ${name} CLI is installed`);returntrue;}catch{console.error(`β ${name} CLI is not installed. Please install it first.`);returnfalse;}}functioncheckPulumiLogin(){try{constuser=execSync("pulumi whoami",{stdio:"pipe"}).toString().trim();console.log(`π Logged in as ${user}`);returntrue;}catch{console.error("β οΈ Pulumi CLI is not logged in. Run `pulumi login` and try again.");returnfalse;}}functioncheckAwsConfigured(){try{constidentity=execSync("aws sts get-caller-identity",{stdio:"pipe"}).toString();constparsed=JSON.parse(identity);console.log(`π§Ύ AWS Configured for Account: ${parsed.Account}, ARN: ${parsed.Arn}`);returntrue;}catch{console.error("β AWS CLI is not configured. Run `aws configure` with your IAM credentials first.");returnfalse;}}asyncfunctioninitProject(options={}){constuseGitHub=options.github||false;console.log("π Checking environment...");constPulumiCheck=checkCLI("pulumi version","Pulumi");if (!PulumiCheck)process.exit(1);if (useGitHub){const{repoName,description,deployDir,stackName,githubToken}=awaitprompts([{type:"text",name:"repoName",message:"GitHub repo name:",initial:path.basename(process.cwd()),},{type:"text",name:"description",message:"Repo description:",},{type:"text",name:"deployDir",message:"Directory to deploy (e.g., ./build):",initial:"./build",},{type:"text",name:"stackName",message:"Stack name:",initial:"github-pages",},{type:"password",name:"githubToken",message:"Enter your github token",},]);constgithubConfig={projectName:repoName,description,deployDir,stackName,githubToken,target:"github",};fs.writeFileSync("config.json",JSON.stringify(githubConfig,null,2));console.log("β GitHub Pages project initialized and saved to config.json");return;}// For AWS S3 setupconsthasAws=checkCLI("aws --version","AWS");constisPulumiLoggedIn=checkPulumiLogin();constisAwsConfigured=checkAwsConfigured();if (!hasAws||!isPulumiLoggedIn||!isAwsConfigured){process.exit(1);}constresponse=awaitprompts([{type:"text",name:"projectName",message:"Project name:",initial:"Pulumi",},{type:"text",name:"stackName",message:"Stack name:",initial:"dev",},{type:"text",name:"projectDescription",message:"Project Description:",initial:"This is a cool project",},{type:"text",name:"region",message:"AWS region:",initial:"us-east-1",},{type:"confirm",name:"generateSite",message:"Create a sample index.html?",initial:true,},]);constconfig={projectName:response.projectName,stackName:response.stackName,projectDescription:response.projectDescription,region:response.region,target:"aws",};fs.writeFileSync("config.json",JSON.stringify(config,null,2));console.log("π¦ Saved all config β config.json");// Create sample static siteconstpublicDir=path.join(process.cwd(),"public");if (response.generateSite&&!fs.existsSync(publicDir)){fs.mkdirSync(publicDir);fs.writeFileSync(path.join(publicDir,"index.html"),`<html><body><h1>Pulumi is awesome broo!π₯</h1></body></html>`);console.log("π Created sample static site in ./public/");}// Initialize Pulumi stack for AWS onlyconststack=awaitLocalWorkspace.createOrSelectStack({stackName:response.stackName,projectName:response.projectName,program:async ()=>{},});awaitstack.setConfig("aws:region",{value:response.region});console.log("β Pulumi stack initialized!");}module.exports={initProject};
Once you run:
node index.js init
# or
node index.js init --github
It does the following:
β Checks for required CLIs (Pulumi, AWS CLI)
π§ Validates Pulumi login and AWS credentials (for AWS mode)
π£οΈ Prompts you for config, like project name, stack name, region, and target(GitHub Access Token if you want to deploy on GitHub)
π Saves everything to config.json β so you donβt have to answer again
π (Optional) Creates a sample index.html in a public/ folder, so you can test deployments instantly
Make sure that the IAM user has necessary permissions and also GitHub token has the repo and delete permissions. Visit my GitHub repo to see all the required permissions.
π pulumiProgram.js β Infra as Code
Here I am defining all the AWS infra as code.
// pulumiProgram.js"use strict";constaws=require("@pulumi/aws");constpulumi=require("@pulumi/pulumi");//const mime = require("mime");constfs=require("fs");constpath=require("path");functioncreatePulumiProgram(staticDir){returnasync ()=>{// Create a bucket and expose a website index documentconstconfig=JSON.parse(fs.readFileSync("config.json","utf-8"));constbucketName=config.projectName;letsiteBucket=newaws.s3.BucketV2(bucketName,{});letsiteBucketWebsiteConfig=newaws.s3.BucketWebsiteConfigurationV2("s3-website-bucket-config",{bucket:siteBucket.id,indexDocument:{suffix:"index.html",},});newaws.s3.BucketPublicAccessBlock("public-access-block",{bucket:siteBucket.id,blockPublicAcls:true,blockPublicPolicy:true,ignorePublicAcls:true,restrictPublicBuckets:true,});// Create CloudFront Origin Access Identityconstoai=newaws.cloudfront.OriginAccessIdentity("pulumi-oai",{comment:`Access Identity for ${bucketName}`,});// Upload files from the staticDirconstfiles=fs.readdirSync(staticDir);for (constfileoffiles){constfilePath=path.join(staticDir,file);constcontentType=getMimeType(file);newaws.s3.BucketObject(file,{bucket:siteBucket,source:newpulumi.asset.FileAsset(filePath),contentType,});}constaddFolderContents=(staticDir,prefix)=>{for (letitemoffs.readdirSync(staticDir)){letfilePath=path.join(staticDir,item);letisDir=fs.lstatSync(filePath).isDirectory();// This handles adding subfolders and their contentif (isDir){constnewPrefix=prefix?path.join(prefix,item):item;addFolderContents(filePath,newPrefix);continue;}letitemPath=prefix?path.join(prefix,item):item;itemPath=itemPath.replace(/\\/g,'/');// convert Windows paths to something S3 will recognizeletobject=newaws.s3.BucketObject(itemPath,{bucket:siteBucket.id,source:newpulumi.asset.FileAsset(filePath),// use FileAsset to point to a filecontentType:getMimeType(filePath),// set the MIME type of the file});}}// Attach bucket policy for OAInewaws.s3.BucketPolicy("pulumi-bucket-policy",{bucket:siteBucket.bucket,policy:pulumi.all([siteBucket.bucket,oai.iamArn]).apply(([bucket,iamArn])=>JSON.stringify({Version:"2012-10-17",Statement:[{Effect:"Allow",Principal:{AWS:iamArn},Action:"s3:GetObject",Resource:`arn:aws:s3:::${bucket}/*`,},],})),});// Upload static filesconstuploadFiles=(dir,prefix="")=>{for (constitemoffs.readdirSync(dir)){constfilePath=path.join(dir,item);conststat=fs.statSync(filePath);if (stat.isDirectory()){uploadFiles(filePath,path.join(prefix,item));}else{constrelativePath=path.join(prefix,item).replace(/\\/g,"/");newaws.s3.BucketObject(relativePath,{bucket:siteBucket.id,source:newpulumi.asset.FileAsset(filePath),contentType:getMimeType(filePath),});}}};uploadFiles(staticDir);// CloudFront Distributionconstdistribution=newaws.cloudfront.Distribution("pulumi-cdn",{enabled:true,defaultRootObject:"index.html",origins:[{originId:siteBucket.arn,domainName:siteBucket.bucketRegionalDomainName,s3OriginConfig:{originAccessIdentity:oai.cloudfrontAccessIdentityPath,},},],defaultCacheBehavior:{targetOriginId:siteBucket.arn,viewerProtocolPolicy:"redirect-to-https",allowedMethods:["GET","HEAD"],cachedMethods:["GET","HEAD"],forwardedValues:{queryString:false,cookies:{forward:"none"},},compress:true,},priceClass:"PriceClass_100",restrictions:{geoRestriction:{restrictionType:"none",},},viewerCertificate:{cloudfrontDefaultCertificate:true,},});return{bucketName:siteBucket.bucket,cloudfrontUrl:distribution.domainName.apply((domain)=>`https://${domain}`),};};}// Simple mime type guesserfunctiongetMimeType(file){if (file.endsWith(".html"))return"text/html";if (file.endsWith(".css"))return"text/css";if (file.endsWith(".js"))return"application/javascript";if (file.endsWith(".json"))return"application/json";if (file.endsWith(".png"))return"image/png";if (file.endsWith(".jpg")||file.endsWith(".jpeg"))return"image/jpeg";return"text/plain";}module.exports={createPulumiProgram};
πͺ£ S3 Bucket Creation: First, we create an S3 bucket to host the static files.
π« Blocking Public Access(For Security): To keep it private, we block all public access by default.
π΅οΈ CloudFront OAI (Origin Access Identity): Instead of making the bucket public, we use a CloudFront OAI to access the bucket securely. That means only CloudFront can fetch objects from S3.
π Upload Static Files: Then it recursively uploads everything from the provided --dir into the S3 bucket, preserving folder structure and setting proper MIME types. I wrote a custom uploadFiles() function for this.
π deploy.js β Deployment to AWS with Just One Command
This file is what gets executed when the user runs:
node index.js deploy --target aws --dir ./public
NOTE: I'm using ./publicdir here but you can pass any directory.
e.g If you have built a react app, you should pass ./builddir here.
// deploy.jsconst{LocalWorkspace}=require("@pulumi/pulumi/automation");constpath=require("path");constfs=require("fs");const{createPulumiProgram}=require("./pulumiProgram");asyncfunctiondeploy(staticDir){if (!fs.existsSync(staticDir)){console.error(`Directory "${staticDir}" does not exist.`);process.exit(1);}constconfigPath=path.resolve("config.json");if (!fs.existsSync(configPath)){console.error("β Missing config.json β have you run `init`?");process.exit(1);}constconfig=JSON.parse(fs.readFileSync(configPath,"utf8"));//const token = process.env.PULUMI_ACCESS_TOKEN || config.pulumiAccessToken;conststackName=config.stackName;constprojectName=config.projectName;console.log("β³ Initializing Pulumi stack...");conststack=awaitLocalWorkspace.createOrSelectStack({stackName,projectName,program:createPulumiProgram(staticDir),});console.log("β Stack initialized");awaitstack.setConfig("aws:region",{value:config.region||"us-east-1"});console.log("π Deploying to AWS...");constupRes=awaitstack.up();console.log("\nβ Deployment complete!");console.log(`π¦ Bucket Name: ${upRes.outputs.bucketName.value}`);console.log(`π Site URL: ${upRes.outputs.cloudfrontUrl.value}`);}module.exports={deploy};
TL;DR
β Reads static site and config
π οΈ Provisions infra via Pulumi Automation
π‘ Uploads all files to s3 bucket
π Returns live site URL β all in one command
π deployGithub.js β Deploy to GitHub Pages in One Shot
This function automates the full lifecycle of a GitHub Pages deployment:
NOTE: I'm using ./publicdir here but you can pass any directory.
e.g If you have built a react app, you should pass ./builddir here.
constfs=require("fs");constpath=require("path");const{LocalWorkspace}=require("@pulumi/pulumi/automation");constsimpleGit=require("simple-git");require("dotenv").config();asyncfunctiondeployGithub(){constconfigPath=path.resolve("config.json");if (!fs.existsSync(configPath)){console.error("β Missing config.json β please run `init` first.");process.exit(1);}constconfig=JSON.parse(fs.readFileSync(configPath,"utf8"));const{projectName,description,deployDir,stackName}=config;letenablePages=false;letfullName="";letrepoUrl="";if (!fs.existsSync(deployDir)){console.error(`β Deploy directory "${deployDir}" does not exist.`);process.exit(1);}consttoken=process.env.GITHUB_TOKEN||config.githubToken;if (!token){console.error("β GitHub token not found. Please set GITHUB_TOKEN as an env variable.");process.exit(1);}constprogram=async ()=>{constgithub=require("@pulumi/github");constrepo=newgithub.Repository(projectName,{name:projectName,description,visibility:"public",...(enablePages&&{pages:{source:{branch:"gh-pages",path:"/",},},}),});return{repoUrl:repo.htmlUrl,fullName:repo.fullName,};};conststack=awaitLocalWorkspace.createOrSelectStack({stackName,projectName,program,});awaitstack.setAllConfig({"github:token":{value:token,secret:true},});console.log("π¦ Creating GitHub repo...");constresult=awaitstack.up();fullName=result.outputs.fullName.value;repoUrl=result.outputs.repoUrl.value;console.log("β GitHub repo created:",repoUrl);// Step 2: Push static site to gh-pagesconsole.log("π€ Pushing site content to `gh-pages` branch...");constgit=simpleGit(deployDir);awaitgit.init();awaitgit.checkoutLocalBranch("gh-pages");// β Create gh-pages branchawaitgit.add(".");awaitgit.commit("Deploy to GitHub Pages from statik");constremotes=awaitgit.getRemotes(true);if (remotes.find(r=>r.name==="origin")){awaitgit.removeRemote("origin");}awaitgit.addRemote("origin",`https://github.com/${fullName}`).catch(()=>{});awaitgit.push("origin","gh-pages",["--force"]);// Step 3: Enable GitHub Pagesconsole.log("π Enabling GitHub Pages...");enablePages=true;constupdatedStack=awaitLocalWorkspace.createOrSelectStack({stackName,projectName,program,});awaitupdatedStack.setAllConfig({"github:token":{value:token,secret:true},});awaitupdatedStack.up();// β re-run with updated programconst[owner,repoName]=fullName.split("/");constsiteUrl=`https://${owner.toLowerCase()}.github.io/${repoName}/`;console.log(`π GitHub Pages deployed at: ${siteUrl}`);}module.exports={deployGithub};
β Creates a repo via Pulumi
β Pushes static content to gh-pages (used simple-git to manage git pushes programmatically.)
β Enables GitHub Pages via Pulumi
β Outputs a live site URL
I followed the two-step process to enable GitHub Pages:
First, create the repo without pages set
Push the static content to the gh-pages branch
Then re-run the Pulumi program with pages enabled
Why? Because GitHub Pages requires the branch to exist first before Pulumi can activate it
π₯ destroy.js β Destroys the stacks
This function will destroy the stack which is present in the config file.
constfs=require("fs");constpath=require("path");const{LocalWorkspace}=require("@pulumi/pulumi/automation");asyncfunctiondestroy(){constconfigPath=path.resolve("config.json");if (!fs.existsSync(configPath)){console.error("β Missing config.json β have you run ` init`?");process.exit(1);}constconfig=JSON.parse(fs.readFileSync(configPath,"utf8"));//const token = process.env.PULUMI_ACCESS_TOKEN || config.pulumiAccessToken;conststackName=config.stackName;constprojectName=config.projectName;console.log(`𧨠Destroying stack "${stackName}" from project "${projectName}"...`);conststack=awaitLocalWorkspace.selectStack({stackName,projectName,program:async ()=>{},// noop});awaitstack.destroy({onOutput:console.log});console.log(`β Stack "${stackName}" destroyed successfully.`);}module.exports={destroy};
By running:
node index.js destroy
The stack name and project name will be fetched from the config.json file.
Challenges Faced
Biggest challenge? GitHub Pages needs the gh-pages branch to exist before you enable it. That was super annoying. I ended up creating the repo first, pushing the site content, and then updating the repo again to enable Pages.
GitHub Access Token Permission for deleting the Repo when you run destroy command
Getting CloudFront to work with private S3 buckets required setting up a (OAI) and properly configuring the S3 bucket policy to allow access via that identity. So, I reviewed the AWS documentation and carefully constructed a Pulumi-based BucketPolicy that grants s3:GetObject permission specifically to the OAI. Once configured properly, it worked correctly..
What I Learned
Pulumi is powerful tool - being able to define infra as code in JavaScript (supports many languages) and deploy it programmatically made automation feel seamless.
To be honest, I never defined infra as code before. I always used direct aws GUI but after using pulumi I learned it.
Also never used simple-git and made commits and push programatically.
I started with a simple idea of automating but ended up with lot of learnings and handy CLI tool π
Currently it supports only AWS cloud but I will be adding Azure and GCP as well so that user can choose on which cloud service they want to deploy.
Using Pulumi with GitHub
In this project, I used Pulumi to automate the creation and management of cloud(AWS) and GitHub infrastructureβmaking it easy to go from local code to a live site with just a single command.
π What I Used Pulumi For?
AWS Deployment:
Pulumi provisions an S3 bucket (with static website hosting), sets up a CloudFront distribution, and securely connects them using an Origin Access Identity (OAI).
GitHub Repository Management:
Using the Pulumi GitHub provider, I automated:
Creating a public repo from code
Pushing content to the gh-pages branch
Enabling GitHub Pages for instant static hosting
Used Pulumi inlinePrograms for stacks.
Pulumi Copilot
I used the Debug with Copilot feature in Pulumi dashboard whenever stack updates failed. It analyzed the problem and provided me the cause of stack failure π