Build a Serverless Gist App in Java with Quarkus and S3
A practical Quarkus guide to storing and rendering Markdown snippets in S3 using LocalStack Dev Services — fast, durable, and zero configuration.
Ever wanted to share a quick code snippet or Markdown note without logging in, managing a database, or spinning up an entire app?
Let’s build a tiny anonymous gist service that does exactly that.
Each gist is just a small JSON file. Markdown in, HTML out. And all stored in Amazon S3 for durability and served at unique URLs for static, CDN-ready access.
No database. No accounts. No complexity.
Quarkus handles the REST API and Markdown rendering.
S3 takes care of persistence.
LocalStack makes it all run locally with zero setup using Quarkus Dev Services.
Why S3 Works Perfectly for Gists
S3-compatible object storage hits the sweet spot for lightweight, immutable data.
Store each gist as a simple JSON file.
Infinitely scalable and cheap to run.
Plays well with CDNs for fast global delivery.
Runs locally through LocalStack and deploys seamlessly to AWS or MinIO.
You get full persistence and static hosting, without introducing a database, ORM, or queue.
Prerequisites
Before you start coding, make sure your environment is ready.
Java 21
Quarkus 3.29+
Maven 3.9+
Podman or Docker (for Quarkus Dev Services / LocalStack)
AWS CLI (optional but helpful for testing)
If any of these are missing, fix them now before proceeding.
Installing the AWS CLI on macOS
We’ll use the AWS CLI later to inspect the LocalStack bucket.
If you don’t have it installed:
Using Homebrew (recommended)
brew install awscliVerify installation:
aws --versionExpected output (similar to below):
aws-cli/2.31.33 Python/3.13.9 Darwin/25.1.0 source/arm64You can now use the aws command to interact with LocalStack — no real AWS account required.
Project Bootstrap and Dependencies
Let’s create the Quarkus project that will serve as our gist API. If you just want to sneak at the code, make sure to check out my companion Github repository.
mvn io.quarkus:quarkus-maven-plugin:create \
-DprojectGroupId=com.example \
-DprojectArtifactId=quarkus-gists-s3 \
-DclassName="com.example.gists.GistResource" \
-Dextensions="rest-jackson,qute,amazon-s3"
cd quarkus-gists-s3Once the project is generated, we’ll add a few libraries to handle Markdown parsing, HTML sanitization, and unique ID generation.
These go into your pom.xml inside the <dependencies> section.
<dependency>
<groupId>com.vladsch.flexmark</groupId>
<artifactId>flexmark-all</artifactId>
<version>0.64.6</version>
</dependency>
<dependency>
<groupId>com.googlecode.owasp-java-html-sanitizer</groupId>
<artifactId>owasp-java-html-sanitizer</artifactId>
<version>20240325.1</version>
</dependency>
<dependency>
<groupId>de.huxhorn.sulky</groupId>
<artifactId>de.huxhorn.sulky.ulid</artifactId>
<version>8.3.0</version>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>url-connection-client</artifactId>
</dependency>These dependencies provide:
Flexmark for Markdown rendering.
OWASP Sanitizer for safe HTML.
ULID for short unique IDs.
AWS SDK client for S3 operations.
Configuration
Next, we configure Quarkus to automatically start LocalStack and create our S3 bucket.
Open src/main/resources/application.properties and add:
# Bucket name used for gists
quarkus.s3.devservices.buckets=gists-bucketMarkdown and Model Classes
Let’s define our basic data structures and Markdown service.
Create src/main/java/com/example/gists/model/Gist.java
package com.example.gists.model;
import java.time.Instant;
public class Gist {
public String id;
public String title;
public String language;
public String markdown;
public String html;
public Instant createdAt;
}This class represents the data that will be saved as JSON to S3.
Request DTO
Create src/main/java/com/example/gists/model/CreateGistRequest.java:
package com.example.gists.model;
public class CreateGistRequest {
public String title;
public String language;
public String markdown;
}This is the structure the API will expect from clients when creating new gists.
Markdown rendering and sanitization
Before saving, we’ll render Markdown to HTML and sanitize it to prevent injection attacks.
Create src/main/java/com/example/gists/MarkdownService.java:
package com.example.gists;
import java.util.List;
import org.owasp.html.HtmlPolicyBuilder;
import org.owasp.html.PolicyFactory;
import com.vladsch.flexmark.ext.emoji.EmojiExtension;
import com.vladsch.flexmark.html.HtmlRenderer;
import com.vladsch.flexmark.parser.Parser;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class MarkdownService {
private final Parser parser;
private final HtmlRenderer renderer;
private final PolicyFactory policy;
public MarkdownService() {
var opts = new com.vladsch.flexmark.util.data.MutableDataSet();
opts.set(Parser.EXTENSIONS, List.of(EmojiExtension.create()));
parser = Parser.builder(opts).build();
renderer = HtmlRenderer.builder(opts).escapeHtml(false).build();
policy = new HtmlPolicyBuilder()
.allowElements(”a”, “p”, “pre”, “code”, “em”, “strong”, “ul”, “ol”, “li”, “blockquote”, “h1”, “h2”,
“h3”, “hr”, “br”, “span”)
.allowAttributes(”href”).onElements(”a”)
.allowUrlProtocols(”http”, “https”)
.toFactory();
}
public String toSafeHtml(String markdown) {
var doc = parser.parse(markdown == null ? “” : markdown);
return policy.sanitize(renderer.render(doc));
}
}Now, whenever a gist is submitted, we’ll safely convert its Markdown into sanitized HTML.
S3 Gist Store
We’ll store gists in S3 as individual JSON files.
This simple service abstracts all S3 interaction so our REST layer remains clean.
Create src/main/java/com/example/gists/store/S3GistStore.java:
package com.example.gists.store;
import java.util.List;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import com.example.gists.model.Gist;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.quarkus.runtime.Startup;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.CreateBucketRequest;
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
import software.amazon.awssdk.services.s3.model.HeadBucketRequest;
import software.amazon.awssdk.services.s3.model.ListObjectsV2Request;
import software.amazon.awssdk.services.s3.model.NoSuchKeyException;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.model.S3Exception;
import software.amazon.awssdk.services.s3.model.S3Object;
@ApplicationScoped
@Startup
public class S3GistStore {
@Inject
ObjectMapper mapper;
@Inject
S3Client s3;
@ConfigProperty(name = “quarkus.s3.bucket”)
String bucket;
public void save(Gist gist) {
try {
if (!bucketExists(bucket)) {
createBucket(bucket);
}
String key = “gists/” + gist.id + “.json”;
byte[] data = mapper.writeValueAsBytes(gist);
s3.putObject(PutObjectRequest.builder()
.bucket(bucket)
.key(key)
.contentType(”application/json”)
.build(), RequestBody.fromBytes(data));
} catch (Exception e) {
throw new RuntimeException(”Failed to save gist”, e);
}
}
public Gist find(String id) {
String key = “gists/” + id + “.json”;
try (var obj = s3.getObject(GetObjectRequest.builder()
.bucket(bucket)
.key(key)
.build())) {
return mapper.readValue(obj, Gist.class);
} catch (NoSuchKeyException e) {
return null;
} catch (Exception e) {
throw new RuntimeException(”Failed to read gist”, e);
}
}
public List<String> listIds() {
var res = s3.listObjectsV2(ListObjectsV2Request.builder()
.bucket(bucket)
.prefix(”gists/”)
.build());
return res.contents().stream()
.map(S3Object::key)
.map(k -> k.substring(k.lastIndexOf(’/’) + 1, k.indexOf(”.json”)))
.toList();
}
private boolean bucketExists(String name) {
try {
s3.headBucket(HeadBucketRequest.builder().bucket(name).build());
return true;
} catch (S3Exception e) {
return false;
}
}
private void createBucket(String name) {
s3.createBucket(CreateBucketRequest.builder().bucket(name).build());
}
}REST API
Now that we can render and store gists, let’s expose them over HTTP.
We’ll provide endpoints to create, view, and fetch them as JSON or HTML.
Modify src/main/java/com/example/gists/GistResource.java:
package com.example.gists;
import java.time.Instant;
import com.example.gists.model.CreateGistRequest;
import com.example.gists.model.Gist;
import com.example.gists.store.S3GistStore;
import de.huxhorn.sulky.ulid.ULID;
import io.quarkus.qute.Location;
import io.quarkus.qute.Template;
import jakarta.inject.Inject;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path(”/”)
@Produces(MediaType.APPLICATION_JSON)
public class GistResource {
private static final ULID ULID = new ULID();
@Inject
S3GistStore store;
@Inject
MarkdownService markdown;
@Location(”gist.html”)
Template gistTemplate;
@POST
@Path(”/gists”)
@Consumes(MediaType.APPLICATION_JSON)
public Response create(CreateGistRequest req) {
if (req == null || req.markdown == null || req.markdown.isBlank())
throw new BadRequestException(”Markdown required”);
Gist gist = new Gist();
gist.id = ULID.nextULID().toLowerCase();
gist.title = req.title;
gist.language = req.language;
gist.markdown = req.markdown;
gist.html = markdown.toSafeHtml(req.markdown);
gist.createdAt = Instant.now();
store.save(gist);
return Response.ok(gist).build();
}
@GET
@Path(”/gists/{id}.json”)
public Gist get(@PathParam(”id”) String id) {
Gist gist = store.find(id);
if (gist == null)
throw new NotFoundException();
return gist;
}
@GET
@Path(”/g/{id}”)
@Produces(MediaType.TEXT_HTML)
public String page(@PathParam(”id”) String id) {
Gist gist = store.find(id);
if (gist == null)
throw new NotFoundException();
return gistTemplate.data(”gist”, gist).render();
}
}This class defines:
POST /giststo create new gists.GET /gists/{id}.jsonto fetch JSON.GET /g/{id}to view HTML.
Qute Template
The template defines how our HTML version looks.
Create src/main/resources/templates/gist.html:
<!DOCTYPE html>
<html lang=”en”>
<head>
<meta charset=”UTF-8”>
<title>{gist.title ?: ‘Anonymous gist’}</title>
<meta name=”viewport” content=”width=device-width, initial-scale=1”>
<style>
<!-- skipped -->
</style>
</head>
<body>
<h1>{gist.title ?: ‘Anonymous gist’}</h1>
<p><small>ID: {gist.id} · {gist.language ?: ‘text’} · {gist.createdAt}</small></p>
<article>{gist.html.raw}</article>
</body>
</html>You can later customize the style or include syntax highlighting if you want. I made my example look a little more like GitHub gists. You can find it in the Github repository for this article.
Run in Dev Mode (LocalStack as S3 DevService)
Time to see it in action.
Start Quarkus in development mode:
./mvnw quarkus:devNow, create a gist with curl:
curl -sX POST http://localhost:8080/gists \
-H 'Content-Type: application/json' \
-d '{"title":"Hello S3","language":"md","markdown":"**Hello Quarkus!**"}'Output (Similar to below):
{
“id”: “01k9sefq11ehkd891srq758tkx”,
“title”: “Hello S3”,
“language”: “md”,
“markdown”: “**Hello Quarkus!**”,
“html”: “<p><strong>Hello Quarkus!</strong></p>\n”,
“createdAt”: “2025-11-11T12:33:09.154456Z”
}Open the browser and visit:
http://localhost:8080/g/<id>You’ll see your Markdown rendered safely as HTML.
Inspect S3 Contents (via AWS CLI)
Let’s confirm that your gist is actually stored inside S3 (through LocalStack).
First, configure a local profile:
aws configure --profile localstack
AWS Access Key ID [None]: test
AWS Secret Access Key [None]: test
Default region name [None]: us-east-1
Default output format [None]:Next, find your LocalStack port in Quarkus Dev UI under “Dev Services.”
It’ll look like:
quarkus.s3.endpoint-override=http://127.0.0.1:46115List the objects:
aws --profile localstack --endpoint-url=http://localhost:46115 s3 ls s3://gists-bucket/gists/Retrieve one gist:
aws --profile localstack --endpoint-url=http://localhost:46115 s3 cp s3://gists-bucket/gists/<id>.json -Which will look similar to this:
{
“id”: “01k9sevm4dx47tykt11jcwkwnm”,
“title”: “Hello S3”,
“language”: “md”,
“markdown”: “**Hello Quarkus!**”,
“html”: “<p><strong>Hello Quarkus!</strong></p>\n”,
“createdAt”: “2025-11-11T12:39:39.424106Z”
}Optional Enhancements
Store Markdown and pre-rendered HTML as separate S3 objects for CDN distribution.
Add
Cache-Control: public,max-age=31536000,immutablemetadata to S3 uploads.Use S3 Object Versioning for immutable history.
Add TTL lifecycle policies for auto-expiration.
Integrate a simple rate limiter (Bucket4j or Quarkus
@Blockingfilter).
Tiny app. Full durability. Zero setup.
That’s Quarkus and S3 done right.




