I Built a Time Capsule App to Learn Spring Boot — Here's How It Went

Took a long break from writing. Didn't plan to, just happened. But I'm back, and I figured what better way to return than tearing apart something I actually built. I wanted a real project to learn Spring Boot properly. Not a tutorial. Not a todo app. Something with enough moving parts that I'd actually have to figure things out — auth, background jobs, file storage, the whole thing. So I built Time Capsule: a web app where you can create sealed digital capsules with messages and photos, lock them, and have them automatically delivered on a future date you set.
The idea is simple. The implementation was not.
What It Actually Does
You create a capsule, add content to it — text, images, files — set an unlock date, and that's it. The capsule is sealed. Nobody (including you) can see the contents until that date hits. You can invite other people as contributors or viewers. When the unlock date arrives, everyone gets an email notification and the capsule opens.
The features list sounds clean. Getting there was... a process.
The Stack
Frontend: Next.js 14 (App Router), Tailwind CSS, Radix UI, Zustand for state, React Hook Form + Zod for validation.
Backend: Spring Boot 3.x, Java 21, PostgreSQL, Spring Security + JWT, Spring Data JPA, Flyway for migrations, Amazon S3 for file storage, Resend for email notifications, and Spring's @Scheduled for background jobs.
I picked this stack specifically because I was weak on the backend side. I knew React well enough. Spring Boot was the gap I wanted to close.
What Broke Me: JWT Auth
I'll be honest — I underestimated this completely.
The filter chain, the OncePerRequestFilter, plugging everything into SecurityFilterChain correctly — it took way longer than it should have. Here's the actual filter I ended up with:
@Component
@RequiredArgsConstructor
@Slf4j
public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final UserDetailsServiceImpl userDetailsService;
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String servletPath = request.getServletPath();
return servletPath.startsWith("/api/v1/oauth2/") ||
servletPath.startsWith("/api/v1/login/oauth2/") ||
servletPath.startsWith("/api/v1/auth/");
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
String token = authHeader.substring(7);
try {
if (jwtUtil.isTokenValid(token)) {
String email = jwtUtil.extractEmail(token);
UserDetails principal = userDetailsService.loadUserByUsername(email);
var authToken = new UsernamePasswordAuthenticationToken(
principal, null, principal.getAuthorities()
);
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
} catch (JwtException e) {
request.setAttribute("jwt_exception", e);
SecurityContextHolder.clearContext();
return;
} catch (UsernameNotFoundException e) {
request.setAttribute("username_not_found_exception", e);
SecurityContextHolder.clearContext();
return;
}
filterChain.doFilter(request, response);
}
}
The filter itself wasn't the hard part. The surprise was that exceptions thrown here never reach your @ControllerAdvice / GlobalExceptionHandler — because this filter runs before the request ever enters the controller layer. I kept wondering why my exception handler was doing nothing.
The fix was a CustomAuthenticationEntryPoint registered directly in SecurityConfig:
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling(exception -> exception
.authenticationEntryPoint(authenticationEntryPoint));
One line, but it took me an embarrassingly long time to figure out why it was needed. Spring Security's error handling is a different world from what you're used to coming from Express or Next.js API routes.
Role-based access was its own thing. Three roles: OWNER, CONTRIBUTOR, and VIEWER. Enforcing this at the service layer rather than just the controller made the code cleaner — but also meant more places for something to silently fail.
What Else Broke Me: S3
File uploads felt straightforward on paper. In practice, I had three separate issues:
CORS. When a capsule unlocks and the frontend tries to render images via presigned S3 URLs, the browser blocks the request if your S3 bucket CORS policy doesn't allow your origin. The error message is useless — it took me a while to trace it back to a missing CORS rule in the bucket config rather than anything in my own code.
File size limits. Spring Boot has a default multipart upload limit of 1MB. My app is supposed to support "high-quality images." I kept getting MaxUploadSizeExceededException until I added this to application.properties:
spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=10MB
Presigned URLs for access control. Uploads go frontend → Spring Boot → S3 — the server handles all file storage. But the more interesting part is how files are served back. The S3 bucket stays private by default; nothing is publicly accessible. When a capsule unlocks, the backend generates a presigned URL for each file: a temporary, expiring S3 link valid for a limited window. That URL gets handed to the frontend, which renders the image. The file was sitting in S3 the whole time, completely unreachable until unlock. It's a clean way to enforce the sealed capsule mechanic at the storage layer, not just the app layer.
The Part I Actually Liked: Scheduled Jobs
The core mechanic of the whole app is that capsules unlock automatically. No cron job on the server, no manual trigger. Spring's @Scheduled annotation makes this almost embarrassingly easy.
@Transactional
@Scheduled(fixedDelay = 60000) // runs every 60 secs
public void unlockDueCapsules(){
List<Capsule> dueCapsules = capsuleRepository
.findAllDueCapsulesWithDetails(Instant.now());
for(Capsule capsule : dueCapsules){
capsule.setStatus(CapsuleStatus.UNLOCKED);
capsuleRepository.save(capsule);
log.info("Capsule: {}, unlocked from scheduler at: {}", capsule.getSlug(), Instant.now());
// Send emails to capsule members
resendEmailService.sendUnlockNotification(capsule);
}
}
Every 60 seconds, it checks for capsules whose unlock date has passed, flips their status, and fires off email notifications. That's it. I used Resend for the emails — clean API, took maybe 30 minutes to integrate.
This was the part of the backend I felt most confident building, which says something about how the auth and S3 sections went.
Architecture
Here's the full system diagram:
Two layers — client and server — with clear separation between them.
Client layer: The user hits the Next.js frontend over HTTPS. All API calls go to Spring Boot with a JWT attached in the Authorization header. That's it for the frontend's job.
Server layer: Spring Boot is the hub. It handles three distinct flows:
Data flow — REST requests from the frontend hit the API, which reads/writes capsule data to PostgreSQL via JPA. Standard CRUD, nothing unusual here.
File flow — When a user attaches a file to a capsule, it routes frontend → Spring Boot → S3. The bucket is private by default. When a capsule unlocks, the backend generates presigned URLs — temporary, expiring S3 links — so the frontend can render the files without ever making them publicly accessible.
Scheduler flow — The
Task Scheduler(Spring's@Scheduled) runs in the background independently of any HTTP request. When a capsule's unlock date passes, it updates the capsule state in PostgreSQL and triggers the SMTP mail server to send email notifications out to all participants.
That last flow — the async, time-driven one — is what makes the app actually work. Everything else is just a web app. The scheduler is what gives it its whole point.
What I'd Do Differently
The app works. It's deployed at timecapsule.akshanshsingh.com. But if I started over:
I'd plan the data model first. I made a few schema decisions early that I had to undo later. Flyway migrations saved me here, but the back-and-forth was avoidable.
I'd handle errors more consistently on the frontend. Some API errors surface nicely. Others fail silently. Users deserve better than that.
I'd containerize earlier. Docker is on the roadmap but I kept pushing it off. Starting with a docker-compose.yml would've made the local dev setup way less painful to explain to anyone who wanted to run it.
I'd generate presigned URLs more granularly. Right now they're generated at unlock time for all files in a capsule. In hindsight I'd scope them tighter — shorter expiry, per-request generation — so there's no window where a URL stays valid longer than it should.
What's Next
Full Dockerization for one-click deployment
In-app audio player for sound-based memories
Search and filter for public vaults
The core feature set is solid. The polish is what's left.
This was the most complete full-stack project I've shipped so far. Spring Boot clicked for me around week three. Java stopped feeling verbose and started feeling structured. The backend patterns — services, repositories, controllers — make a lot of sense once you build something non-trivial with them.
If you're coming from a JavaScript background and considering Spring Boot: the learning curve is real, but it's worth it. Just expect JWT to humble you first.
What part of a full-stack project do you find hardest to get right — auth, storage, or something else entirely?



