What Really Happens After SCC Admission in OpenShift?

A Deep Dive from Security Context Constraints to Linux Kernel Capability Computation

A Deep Dive from Security Context Constraints to Linux Kernel Capability Computation

A Debugging Story That Will Change How You Think About Container Capabilities

You are a platform engineer running OpenShift. A development team runs a monitoring sidecar as a non-root user that needs to perform ICMP ping health checks. They need CAP_NET_RAW – the capability required for raw socket access. Straightforward enough.

The SCC is configured to allow the capability:

apiVersion: security.openshift.io/v1
kind: SecurityContextConstraints
metadata:
  name: net-raw-scc
allowedCapabilities:
  - NET_RAW
requiredDropCapabilities:
  - ALL
runAsUser:
  type: MustRunAsRange
  uidRangeMin: 1000
  uidRangeMax: 65534
fsGroup:
  type: MustRunAs
seLinuxContext:
  type: MustRunAs

And the PodSpec requests it:

apiVersion: v1
kind: Pod
metadata:
  name: ping-test
spec:
  containers:
  - name: monitor
    image: registry.example.com/monitor:latest
    securityContext:
      runAsUser: 1000
      capabilities:
        add:
        - NET_RAW
        drop:
        - ALL

The Pod is admitted. The container starts successfully. But inside the container:

$ ping -c 1 10.0.0.1
ping: Operation not permitted

The developer checks the process capabilities:

$ cat /proc/1/status | grep Cap
CapInh: 0000000000000000
CapPrm: 0000000000000000
CapEff: 0000000000000000
CapBnd: 0000000000002000
CapAmb: 0000000000000000

CapEff is empty. The capability is not effective. ping fails with Operation not permitted.

Everything looks correct. SCC allows it. PodSpec requests it. CRI-O starts the container successfully. But the privilege is not there at runtime.

If you have never traced the full lifecycle from SCC admission through CRI-O’s OCI spec generation to the Linux kernel’s execve() capability computation, this will seem like a bug. It is not. To understand why, we need to follow the entire security pipeline from the OpenShift API server’s admission decision down to the capability evaluation in CRI-O.

What SCC Actually Does (and What It Does Not)

Security Context Constraints are OpenShift’s mechanism for controlling what security configuration is allowed to enter the cluster. SCC predates Kubernetes Pod Security Admission (PSA) and provides significantly more granular control. While PSA operates on a coarse three-tier model (Privileged, Baseline, Restricted), SCCs allow fine-grained field-level policies: which capabilities are allowed, which UIDs, which volume types, whether privilege escalation is permitted, and so on.

SCC Selection and Admission

When a Pod is created, the OpenShift API server’s admission controller evaluates it against all SCCs available to the requesting user’s service account. For a deeper treatment of SCC management, see the Red Hat blog on managing SCCs.

The Critical Distinction

Here is what engineers frequently misunderstand: SCC does not enforce runtime privileges. SCC is an admission-time gate. It determines whether a particular security configuration is allowed to be submitted to the cluster. Once the pod passes admission, the SCC’s job is done. It has no runtime component. It does not communicate with CRI-O. It does not write iptables rules, adjust kernel parameters, or configure capabilities on processes.

The actual runtime enforcement happens in an entirely separate stack: kubelet -> CRI-O -> OCI runtime -> Linux kernel.

How SCC Translates Into Runtime Configuration

CRI-O has no concept of SCCs. It receives a CreateContainerRequest and it translates that into an OCI runtime specification. Let’s trace the key SCC-derived fields through this pipeline.

For SCC fields like allowHostNetwork, allowHostPID, and allowHostIPC, the SCC-to-runtime translation is straightforward. CRI-O receives namespace mode flags via CRI and directly configures whether the container joins the host’s namespace or gets its own. The mapping is direct and predictable.

Capabilities are where the translation becomes non-obvious.

Privilege and Capability Configuration

This is where the pipeline becomes consequential for our debugging scenario. SCC fields like allowPrivilegedContainer, allowedCapabilities, defaultAddCapabilities, and requiredDropCapabilities gate what can appear in the PodSpec.

CRI-O receives the security config from CreateContainerRequest and processes them in SpecSetPrivileges():

func (c *container) SpecSetPrivileges(ctx context.Context, securityContext *types.LinuxContainerSecurityContext, cfg *config.Config) error {
    specgen := c.Spec()
    if c.Privileged() {
        specgen.SetupPrivileged(true)
    } else {
        caps := securityContext.GetCapabilities()
        if err := c.SpecSetupCapabilities(caps, cfg.DefaultCapabilities, cfg.AddInheritableCapabilities); err != nil {
            return err
        }
    }
    // ...
}

This is the fork point. Privileged containers get a fundamentally different treatment than non-privileged ones. To understand what happens in each branch and why our non-root container loses its capability, we need to understand how Linux capabilities actually work at the kernel level.

How CRI-O Handles Capabilities

Linux capabilities decompose the traditional superuser model into distinct privilege units. Capabilities exist in five distinct sets, and the kernel evaluates each set differently:

  • Bounding (CapBnd): Upper limit on what capabilities a process can ever acquire.
  • Permitted (CapPrm): The pool from which effective capabilities can be drawn. A capability must be permitted before it can be effective.
  • Effective (CapEff): What the kernel actually checks during privileged operations. If CAP_NET_RAW is absent from this set, creating a raw socket for ping returns Permission Denied regardless of what the other sets contain.
  • Inheritable (CapInh): Capabilities that can carry across execve() only when the executed binary also has matching file inheritable capabilities set.
  • Ambient (CapAmb): Introduced in Linux 4.3 specifically to solve the non-root capability problem. Ambient capabilities automatically populate both the Permitted and Effective sets after execve(), without requiring file capabilities on the binary.

For the full specification of these sets, see capabilities(7).

Now, with this context, let’s examine what CRI-O actually writes into each set and why the kernel’s treatment of those sets differs between root and non-root processes.

Privileged Containers

When a container is privileged, CRI-O calls SetupPrivileged(true) on the spec generator. This populates all five capability sets with every known capability (41 at the time of writing):

func (g *Generator) SetupPrivileged(privileged bool) {
    if privileged {
        // ...
        g.Config.Process.Capabilities.Bounding = append(..., finalCapList...)
        g.Config.Process.Capabilities.Effective = append(..., finalCapList...)
        g.Config.Process.Capabilities.Inheritable = append(..., finalCapList...)
        g.Config.Process.Capabilities.Permitted = append(..., finalCapList...)
        g.Config.Process.Capabilities.Ambient = append(..., finalCapList...)
    }
}

It is worth noting that dropCapabilities in the PodSpec’s security context has no effect when a container runs in privileged mode. The entire add/drop logic is bypassed. All capabilities are unconditionally granted across all five sets, regardless of what the PodSpec requests.

Non-Privileged Containers

For non-privileged containers, CRI-O first builds the full list of capabilities to apply. Before processing the PodSpec’s add/drop lists, CRI-O merges in a set of 9 default capabilities unless the PodSpec specifies drop: ALL:

if !addAll && !dropAll {
    caps.AddCapabilities = append(caps.AddCapabilities, defaultCaps...)
}

These defaults are defined in CRI-O’s configuration and include: CHOWN, DAC_OVERRIDE, FSETID, FOWNER, SETGID, SETUID, SETPCAP, NET_BIND_SERVICE, and KILL. This means that even if a PodSpec adds no capabilities at all, the container still receives these 9 by default. They can be overridden via the default_capabilities setting in crio.conf.

When the PodSpec.SecurityContext.Capabilities specifies drop: ALL, CRI-O clears the entire list first including these defaults and then applies individual adds on top. This is why the drop: ALL + add: [specific cap] pattern is common in production: it gives you a clean, minimal capability set.

After building the final list, CRI-O populates three capability sets Bounding, Effective, and Permitted for each capability:

if err := specgen.AddProcessCapabilityBounding(capPrefixed); err != nil {
    return err
}
if err := specgen.AddProcessCapabilityEffective(capPrefixed); err != nil {
    return err
}
if err := specgen.AddProcessCapabilityPermitted(capPrefixed); err != nil {
    return err
}

The capability section of the OCI spec is identical regardless of whether the container runs as root or non-root. But the runtime outcome is drastically different.

Root User (UID 0): Capabilities Work

There is no UID transition. The process starts as UID 0 and stays as UID 0. The kernel never clears any capability sets. What CRI-O writes into the OCI spec is exactly what the process ends up with: CapEff contains the requested capabilities.

Non-Root User (UID != 0): Capabilities Vanish

The OCI runtime starts the process as root, applies the capability sets from the OCI spec, then eventually switches to the target UID (e.g., 1000). During this UID transition from 0 to non-zero, the kernel clears the Permitted and Effective sets:

Capability Set After UID Transition
CapBnd Preserved
CapPrm Cleared by kernel
CapEff Cleared by kernel
CapInh Empty (never set by CRI-O)
CapAmb Empty (never set by CRI-O)

The only mechanism that survives a UID transition and populates the Effective set for a non-root process is ambient capabilities. CRI-O intentionally does not set them:

// Make sure to remove all ambient capabilities. Kubernetes is not yet ambient capabilities aware
// and pods expect that switching to a non-root user results in the capabilities being
// dropped. This should be revisited in the future.

This is a deliberate security decision: Kubernetes assumes runAsNonRoot drops capabilities. Setting ambient capabilities would break that contract.

There is an existing enhancement proposal to add ambient capability support to Kubernetes, which would allow selectively granting effective capabilities to non-root container processes.

So What Can You Actually Do About It?

Knowing that the kernel clears CapEff during the UID transition, and that CRI-O intentionally omits ambient capabilities, what options does our engineer have to make CAP_NET_RAW work for UID 1000?

File Capabilities (setcap) — Recommended

The most targeted approach. Apply the capability directly to the binary during image build:

setcap 'cap_net_raw=+ep' /usr/bin/ping

This sets file-level Permitted and Effective bits. When the kernel executes this binary, it sees the file capability and promotes it into the process’s Permitted and Effective sets, even for non-root users as long as the capability remains in the Bounding set (which it does, as we saw in our CapBnd: 0000000000002000 output).

Tradeoff: Requires modifying the container image. File capabilities are stored as extended attributes, so your build pipeline and storage driver must preserve them. Also, since any capability can be embedded in the image filesystem, this shifts the trust boundary to image provenance. You need to trust that the image hasn’t been tampered with.

Running as Root

As we traced above, running as UID 0 avoids the UID transition entirely. No transition, no clearing.

Tradeoff: The most common SCC in production (restricted-v2) prohibits this. Running as root while explicitly dropping all unnecessary capabilities can reduce the exposure.

Privileged Containers

Privileged mode populates all five sets including Ambient, and disables SELinux, AppArmor, and seccomp.

Tradeoff: This is using a sledgehammer for a thumbtack. As we saw in the CRI-O code, privileged mode bypasses the entire capability add/drop logic. You cannot selectively restrict a privileged container. It gets everything unconditionally.

Conclusion

Let’s return to our debugging scenario. The engineer’s SCC was correct. The PodSpec was correct. CRI-O did exactly what it was supposed to: it wrote CAP_NET_RAW into the Bounding, Effective, and Permitted sets of the OCI spec. But none of that mattered, because:

  1. SCC admitted the pod and its job ended there.
  2. CRI-O translated the CRI request into an OCI spec with the capability in three sets, but not in Ambient.
  3. The OCI runtime applied the spec, then switched to UID 1000.
  4. The kernel cleared Permitted and Effective during the UID transition.
  5. The process started with CapEff: 0000000000000000.

Once you understand where SCC ends and where Linux capability semantics begin, container privilege debugging becomes far less mysterious. The kernel is not ignoring SCC — it is faithfully applying capability transition rules exactly as designed.

The next time you see CapEff: 0000000000000000 in a non-root container, you will know exactly where to look.

Every Developer Is Lying About Something — And AI Won’t Fix It

Yes, all of us are lying. And you are probably lying too. Let me prove it 😉

Oh, I have so many article topics in my head right now. The really exciting kind. But this week absolutely steamrolled me 😅 I finally finished preparing my JSNation talk, and at the same time two other amazing opportunities appeared — one professional, and one that feels more like a childhood dream coming true ☺️ But I don’t want to jinx it yet.

And just so it doesn’t sound like everything magically works out for me — I’ve also had a few CFPs rejected recently. The ones I actually cared about! But honestly? That’s just part of the game. One conference may reject your talk this year and happily accept another one from you the next.

So yes, the really big topics will have to wait a little bit longer 🙂 Which doesn’t mean this one is trivial.

We talk about AI everywhere now, as if it’s going to solve all our problems. But as we’ve already noticed, there’s no AI without humans, and behind every “smart” model there’s still some human being — at least for now 😉

Which is probably why, in practice, coding agents do speed things up… but not nearly as much as many people expected. Some studies even suggest they slow developers down.

Because the real problem is often not the code itself. The real problem is the people writing or generating that code.

And unfortunately, all of us lie in one way or another. Sometimes to others. Sometimes to ourselves.

Some Developers Lie About Knowing What They’re Doing

One of the best developers I know once confessed something to me.

This guy is genuinely brilliant. The kind of engineer companies fight over. He currently works on optimizing drivers for LLMs, gets promoted constantly, and even in this lovely “tech crisis” era, he still had multiple job offers to choose from when he considered switching jobs.

And yet, every single time he joins a new project, he feels like a complete idiot.

Everybody else seems productive. People are delivering tickets. Writing code. Moving confidently through the project. Meanwhile, he sits there staring at the codebase wondering:

What exactly is happening here?
What are we even building?
Why does this work like this?
Why are we implementing it this way and not differently?

And then he starts asking questions.

Usually, it turns out nobody really knows what they’re doing, why they’re doing it, or whether they’re even solving the right problem in the first place.

It reminds me of that old joke about lumberjacks cutting down a forest. Eventually, the team leader climbs the tallest tree, looks around, and screams:

“Guys! We’re cutting down the wrong forest!”

And the workers below shout back:

“Who cares? We’re making great progress!”

And honestly, no LLM will save us here. Especially if we never even ask the right questions.

Others Lie About Knowing How To Do Something

This is basically an extension of the previous problem.

A less experienced developer picks up a task and confidently says:
“Yeah, I know how to do this.”

Unfortunately, what they often mean is:
“I hope I’ll somehow figure it out.” 😅

In reality, they may not know how to solve the problem, which tools to use, what architecture makes sense… or even what prompt to write to get useful help from a coding agent.

But they’re afraid to ask.

Because how will they look in front of the team? What will the manager think? The tech lead?

Best-case scenario: they eventually ask questions… just way too late.

Worst-case scenario: they never ask at all and deliver something completely wrong. Which often isn’t even caught properly because…

Someone Else Is Lying About Having Time

Now we’re entering senior and leadership territory 😅

The motivations differ. Some people build their self-worth around being “the reliable one.” Others are terrified of losing status, influence, or even their job.

So they keep taking on more:

  • the hardest tasks,
  • endless meetings,
  • refinements,
  • estimations,
  • discussions with support,
  • discussions with business,
  • code reviews,
  • architecture decisions,
  • documentation.

And let’s be honest. Something eventually has to break.

Human beings are not made of steel. Nobody can operate at 100% forever.

So what happens?
People start half-listening during calls. Code reviews become rushed. Documentation quietly dies in a corner somewhere.

But they still refuse to admit — either to others or to themselves — that they’re simply overloaded.

Some Developers Lie About How Long Things Will Take

I see this constantly with more advanced juniors and mids.

They throw around hilariously optimistic estimates with absolute confidence.

And sure — if they could work uninterrupted for eight straight hours, the application contained no legacy code, edge cases didn’t exist, and other humans never interacted with the system… then maybe the estimate would actually be correct 😄

Then sprint review arrives, and suddenly everybody is shocked that the team didn’t deliver everything.

But this is not the only problem with estimations.

I once worked with an especially funny senior developer.

He treated estimations like sacred truth. He would aggressively defend his numbers during planning sessions, insisting that this specific task was definitely extremely complicated. The rest of the team usually suspected it wasn’t that bad, but eventually we’d surrender just to end the discussion.

And then — at least three separate times — he personally picked up the exact task he had massively overestimated… and finished it in about an hour.

Which basically meant the estimation discussion itself took longer than implementing the feature 😅

Did this experience change his behavior?

Absolutely not.

And Some Lie About Knowing Everything Best

These are probably the most dangerous ones.

I avoid people who say things like:

“Use THIS technology and ONLY this technology because everything else is garbage.”

Insert your favorite holy war here:
Angular vs React, Java vs Python, Rust vs literally anything else 😄

I never write like that myself. Even when I publish something titled “I Love Tailwind. Sorry Not Sorry”, I still talk about its downsides and explain where it absolutely doesn’t make sense.

If one day I start claiming some technology is objectively perfect for every possible situation, please leave a comment saying:

“Sylwia, go touch grass immediately.” 😅

Honestly, I’ve always wondered where this sense of absolute certainty comes from.

Because not all of these people are even paid influencers. Some genuinely seem emotionally attached to technological holy wars. Others maybe only know one stack deeply, so everything else automatically feels “bad.”

And while this behavior is very common among tech influencers, you absolutely see it inside companies too.

The problem is that this kind of certainty can seriously damage projects. People stop questioning decisions. Other developers become afraid to speak up. Stakeholders assume “the confident person” must be right simply because they sound convinced.

Best-case scenario: you end up with an annoying developer who knows more about frameworks than the actual business domain.

Worst-case scenario: you end up with a terrible stack choice and spaghetti architecture held together by ego.

People Are Just… People

Of course, I’m not innocent either.

I’ve used many of these lies myself in the past. Maybe I’m older now. Maybe slightly wiser. Or maybe I just recognize these patterns more easily.

But I’m definitely still lying somewhere too. Maybe not to others — maybe to myself.

Because a lot of our problems in software development aren’t really technical problems at all. They’re deeply human problems:

  • ego,
  • insecurity,
  • fear of looking stupid,
  • fear of admitting mistakes,
  • fear of saying “I don’t know.”

And honestly, I don’t have some magical solution for this. We’re not going to require three years of therapy from every developer before allowing them into a sprint planning meeting 😄

But I have noticed one thing.

Very often, admitting you don’t know something, openly discussing uncertainty during planning, or simply saying:

“Sorry, I still don’t understand this. Could you explain it one more time?”

doesn’t make people see you as a worse developer.

Quite often, the opposite happens.

Suddenly communication inside the team improves. Other people start asking questions too. Conversations become more honest. Problems get discovered earlier.

Seriously. Try it at least once. You might be surprised 🙂

So… what kinds of developer lies do you see most often in your team?

Spring Boot Dockerfile Best Practices: Smaller, Faster, Safer Images

Spring Boot Dockerfile

Published 2026-05-21 by Shubham Bhati — Backend Engineer (Java 17, Spring Boot, Microservices).

We’ve all been there – stuck with a bloated Spring Boot Dockerfile that’s slowing down our development cycle and increasing the risk of security vulnerabilities. A well-crafted spring boot dockerfile is essential for ensuring our applications are efficient, scalable, and secure. In our production environment, we’ve seen firsthand the impact of poorly optimized Docker images, with deployment times increasing by up to 50% due to unnecessary layers and dependencies.

  • Introduction to Spring Boot Dockerfile Best Practices
  • Understanding Docker Layers
  • Multi-Stage Build for Smaller Images
  • Optimizing Dependencies for Faster Builds
  • Security Considerations for Spring Boot Docker Images
  • Common Mistakes to Avoid
  • Frequently Asked Questions
  • Conclusion and Next Steps

Introduction to Spring Boot Dockerfile Best Practices

When building a Spring Boot application, it’s essential to follow best practices for creating efficient and secure Docker images. One of the key concepts to understand is the use of docker layers, which allows us to break down our image into smaller, reusable components. By doing so, we can reduce the overall size of our image and improve build times. For example, we can use the following Dockerfile to create a basic Spring Boot image:

FROM openjdk:21-jdk-alpine
ARG JAR_FILE=target/myapp.jar
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["java","-jar","/app.jar"]

This Dockerfile uses the openjdk:21-jdk-alpine base image and copies our Spring Boot application jar file into the container.

Understanding Docker Layers

Docker layers are a fundamental concept in Docker, allowing us to build images in a modular and efficient way. Each layer represents a set of changes to the previous layer, and by using docker layers, we can avoid duplicating effort and reduce the overall size of our image. For example, if we have a Dockerfile that installs dependencies, copies our application code, and sets environment variables, each of these steps will create a new layer. We can use the docker history command to view the layers in our image:

docker history -H myapp

This will show us the layers in our image, along with the size of each layer and the command that created it.

Multi-Stage Build for Smaller Images

One of the most effective ways to reduce the size of our Docker image is to use a multi-stage build. This involves creating a separate stage for building our application, and then copying the resulting artifact into a smaller runtime stage. For example:

# Stage 1: Build
FROM maven:3.8.6-jdk-21 as build
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src src
RUN mvn package

# Stage 2: Runtime
FROM openjdk:21-jdk-alpine
ARG JAR_FILE=target/myapp.jar
COPY --from=build ${JAR_FILE} app.jar
ENTRYPOINT ["java","-jar","/app.jar"]

This Dockerfile uses two stages: one for building our application using Maven, and another for creating the runtime image. By doing so, we can avoid including unnecessary build dependencies in our runtime image.

Optimizing Dependencies for Faster Builds

When building a Spring Boot application, it’s essential to optimize our dependencies to reduce build times. One way to do this is to use the spring-boot-starter dependencies, which include only the necessary dependencies for our application. For example:

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web:3.2.0'
}

This will include only the necessary dependencies for a basic Spring Boot web application. We can also use tools like Baeldung’s Dependency Analyzer to identify and remove unnecessary dependencies.

Security Considerations for Spring Boot Docker Images

When creating a Spring Boot Docker image, it’s essential to consider security best practices. One way to do this is to use a non-root user to run our application, which can help prevent privilege escalation attacks. For example:

RUN groupadd -r spring && useradd -r -g spring spring
USER spring:spring

This will create a new user and group called spring, and then switch to that user to run our application. We can also use tools like OWASP’s Docker Security Cheat Sheet to identify and mitigate security vulnerabilities.

Common Mistakes

Here are some common mistakes to avoid when creating a Spring Boot Dockerfile:

  • Using an unnecessary base image
  • Including unnecessary dependencies
  • Not using a non-root user to run the application
  • Not optimizing Docker layers
  • Not using a multi-stage build

Frequently Asked Questions

What is the best base image to use for a Spring Boot application?

The best base image to use for a Spring Boot application is openjdk:21-jdk-alpine, which includes the OpenJDK 21 runtime and the Alpine Linux distribution.

How can I optimize my Docker layers for better performance?

To optimize your Docker layers, use the docker history command to view the layers in your image, and then use the --squash flag to combine unnecessary layers.

What is the difference between a single-stage and multi-stage build?

A single-stage build involves creating a single stage for building and running our application, while a multi-stage build involves creating separate stages for building and running our application.

How can I ensure my Spring Boot Docker image is secure?

To ensure your Spring Boot Docker image is secure, use a non-root user to run your application, and follow security best practices like those outlined in the Spring Security documentation.

Conclusion and Next Steps

In conclusion, creating an efficient and secure Spring Boot Dockerfile requires careful consideration of several factors, including Docker layers, dependencies, and security best practices. By following the tips and best practices outlined in this article, we can create smaller, faster, and safer images that improve our development cycle and reduce the risk of security vulnerabilities. To learn more about Spring Boot and Docker, check out the official Spring Boot documentation and the Docker documentation.

Spring Boot Dockerfile in production

Further Reading

Written by **Shubham Bhati* — Backend Engineer at AlignBits LLC, specializing in Java 17, Spring Boot, microservices, and AI integration. Connect on LinkedIn, GitHub, or read more at shubh2-0.github.io.*

Building Virtual IoT Interfaces Before Buying Hardware

Lately, I’ve been obsessed with the idea of combining frontend development with IoT.

Things like smart farm dashboards, logistics trackers, or interfaces that control real world devices genuinely interest me.

But here’s the reality:
I don’t own any hardware yet,
No microcontrollers,no sensors,no breadboards.
Just my laptop, curiosity, and the willingness to learn.

After going through absolute hell learning JavaScript (seriously… the errors were too much 😅), I decided I didn’t want to wait until I could afford hardware before getting started.

So I’m trying something different.Instead of jumping straight into electronics, I’m starting with the frontend side of IoT using simulated data.

I’ll be building dashboards with React, Vite, and Tailwind CSS while simulating sensor updates in the browser.

Things like:
🌱 Smart farm dashboards
📦 Logistics tracking interfaces
📊 Real-time UI experiments

The goal?
Learn how the interface works first, make mistakes, break things, improve, and eventually connect real hardware later.

I know there’ll be lots of errors along the way 😭, but I want to document the process honestly as I learn.

If you’ve worked with IoT before, what’s one thing you wish you knew earlier?

And if you’re learning too, what kind of project would you build first?

Drop your thoughts in the comments let’s connect 👋

I built a smarter SMS client for Android in Flutter — no AI, just clean parsing

I got tired of Google Messages mixing my OTPs, bank alerts, and promo spam into one cluttered inbox. So I built my own.
Msgs is an open-source Android SMS client I’ve been developing in Flutter. No AI, no cloud — everything is parsed on-device using pattern matching and regex.

What’s wrong with Google Messages?

Google Messages is polished, but its inbox is a mess if you get a lot of transactional SMS. OTPs, bank debits, and promotional spam all land in the same place. You have to hunt for what matters. Their “categories” tab exists but feels like an afterthought — and it doesn’t give you any visual context for your transactions.

What Msgs does differently

🔑 OTP copying from notification
When an OTP arrives, Msgs detects the code and the service name instantly. You can copy it directly from the notification — no need to open the app or the message thread.

💳 Transactions shown separately
Bank credits and debits are extracted and displayed as visual summary cards — amount, account ending, and type (debit/credit) at a glance. Not buried in a thread with 200 other bank messages.

📢 Promotional messages are blocked
Promo SMS never reaches your main inbox. Messages filters them out automatically so your inbox only shows what matters.
No AI involved
Everything is done with on-device pattern matching. Fast, private, and it works offline. Your messages never leave your phone.
Material You design
Messages follows Material 3 with full dynamic color support — it picks up your wallpaper colors and adapts the entire UI. Light and dark mode included.

Tech behind it

Flutter with BLoC state management
Isar Database for fast local indexing and search
Pattern-based classifier — no ML model, no network calls
Clean feature-driven architecture

Current status
The app is still in active development. SMS reading, OTP detection, transaction cards, and chat all work. MMS is coming next.
⭐ If this sounds useful, check out the repo and drop a star:
👉 Github Repo

Stellantis, küçük ve erişilebilir yeni elektrikli araç projesini duyurdu

StellantisÇok sayıda otomobil devini çatısı altında barındıran Stellantis, bugün küçük ve erişilebilir yeni elektrikli araç projesini duyurdu. Bu konuda şirketten yapılan açıklamada, “Stellantis, yeni nesil projeleriyle ve yatırımlarıyla sürdürülebilir büyüme hamlesini desteklemeye devam ediyor. Bu kapsamda şirket çığır açan, küçük ve erişilebilir elektrikli araç projesi E-Car’ın hayata geçirildiğini duyurdu. İlk E-Car modellerinin 2028 yılında üretim hattından çıkması hedefleniyor. Stellantis, E-Car’daki “E” harfinin; European (Avrupalı), Emotion (Duygu), Electric (Elektrikli) ve Environmental friendliness (Çevre Dostu) kavramlarını temsil ettiğini açıkladı. Avrupa’da Avrupalılar için üretilecek bu yüksek potansiyelli segment, Avrupa Komisyonu tarafından; Avrupa’daki tasarım ve üretim istihdamını destekleme potansiyeli taşıması ve günlük şehir içi mobilitede tamamen elektrikli araçların daha geniş çapta benimsenmesine katkı sağlaması nedeniyle önemli bir proje olarak değerlendiriliyor. E-Car; Avrupa’daki küçük ve erişilebilir otomobil segmentinde son yıllarda yaşanan ciddi daralmaya çözüm sunmak amacıyla, Avrupa’nın “halk için mobilite” geleneğine sadık kalınarak geliştirilen; küçük, yenilikçi, erişilebilir ve tamamen elektrikli bir araç olarak öne çıkıyor. […]

Stellantis, küçük ve erişilebilir yeni elektrikli araç projesini duyurdu yazısı ilk olarak Teknoloji haberleri – LOG üzerinde yayınlandı.

Technical SEO for Next.js: SSR, JSON-LD, and Sitemaps

I don’t offer SEO as a service. But every site I build ships with technical SEO baked in — because it’s not marketing, it’s engineering. A site that search engines can’t crawl or understand is a site that doesn’t work properly. In the pikkuna.fi e-commerce build, that meant correct hreflang across 30 languages, server-rendered product pages, and JSON-LD that survived localization. Same principles, scaled up.

slug=”mvp-development”
text=”Building a Next.js project that needs to rank from day one? SSR, structured data, and sitemaps are part of what I ship — not add-ons.”
/>

Server-Side Rendering by Default

Single-page apps with client-side rendering are invisible to most crawlers. Every project I build uses Next.js App Router with server components:

// app/products/[slug]/page.tsx
export default async function ProductPage({
  params
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params;
  const product = await getProduct(slug);

  return (
    <article>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
    </article>
  );
}

The HTML arrives fully rendered. No JavaScript required for indexing.

Dynamic Meta Tags

Every page gets proper meta tags generated from actual content:

// app/products/[slug]/page.tsx
export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>;
}): Promise<Metadata> {
  const { slug } = await params;
  const product = await getProduct(slug);

  return {
    title: `${product.name} | Store`,
    description: product.description.slice(0, 160),
    openGraph: {
      title: product.name,
      description: product.description,
      images: [product.image],
    },
  };
}

This handles SEO meta tags, Open Graph for social sharing, and Twitter cards — all from one function.

JSON-LD Structured Data

Schema.org markup tells search engines exactly what the content represents:

function ProductJsonLd({ product }: { product: Product }) {
  const schema = {
    "@context": "https://schema.org",
    "@type": "Product",
    name: product.name,
    description: product.description,
    image: product.image,
    offers: {
      "@type": "Offer",
      price: product.price,
      priceCurrency: "EUR",
      availability: "https://schema.org/InStock",
    },
  };

  return (
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
    />
  );
}

I add appropriate schemas based on content type: Product, Article, Organization, FAQPage, BreadcrumbList.

Automatic Sitemap Generation

Next.js can generate sitemaps from your data:

// app/sitemap.ts
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const products = await getAllProducts();

  return [
    { url: "https://example.com", lastModified: new Date() },
    ...products.map((p) => ({
      url: `https://example.com/products/${p.slug}`,
      lastModified: p.updatedAt,
    })),
  ];
}

No plugins, no external services. Just code that generates /sitemap.xml at build time.

This isn’t optimization work you hire someone for later. It’s the baseline for a properly built website. When I deliver a project, the technical SEO foundation is already there — because skipping it means shipping broken software.

If you want to go further with the indexing layer, read IndexNow in Next.js: Instant Indexing After Every Deploy and llms.txt for AI Discoverability. Together with the patterns above, that’s the full stack.

slug=”seo-audit”
text=”Want this same checklist applied to your existing site? Fixed-fee Technical SEO Audit — keyword research per market, schema, sitemap, hreflang, Core Web Vitals, indexing, broken links, OG images — delivered as a prioritised written report in 5 working days.”
/>

For MVP development with this foundation built in from the start — get in touch.

I built nvm-style account manager for Claude Code

If you use Claude Code with more than one account – personal and work, or one per client – you know the dance. claude opens, you realize you’re under the wrong account, you /logout, /login, sit through the OAuth browser flow, then forget which account you’re in by the time you switch projects again.

I built claudenv to make this stop happening.

claudenv use work
claude   # automatically under your work account

cd ~/projects/clientA
# auto-switches if you opted in, or:
claudenv use personal

That’s it. The same claude binary, just pointed at different config directories via the official CLAUDE_CONFIG_DIR environment variable. No symlinks, no patching files in place, no daemons.

How it works

Claude Code reads its config from ~/.claude by default, but respects CLAUDE_CONFIG_DIR if set. claudenv keeps one directory per account under ~/.claudenv/accounts/<name>/ and points the env var at the right one. Each account has its own OAuth state, settings, MCP servers, the whole thing.

There are three layers of state, exactly like nvm:

  • Global default – what you set with claudenv use work, persists across shells
  • Per-project override – a .claudenvrc file in your repo, picked up via walk-up resolution like .nvmrc
  • Shell-session override – .claudenvrc auto-switches on cd if you enable the hook
    Set a default, override per-project, forget about it.

Why not just claude-switch or claudectx?

There are other tools in this space – I want to be upfront about that.

claude-switch uses the same CLAUDE_CONFIG_DIR mechanism, but the workflow is per-launch: claude-switch work runs Claude under that profile, every time you start a session. If you prefer being explicit on every invocation, use it – it’s a fine tool.

claudectx and a few others go a different route entirely, symlinking or patching ~/.claude.json in place. That works too, but it’s stateful in a way I didn’t want. If the symlink breaks or a patch fails halfway, you end up in a weird state that’s hard to debug.

claudenv is for people who switch accounts more than once a day and don’t want to type the profile name every time. If your projects are clearly tied to specific accounts, .claudenvrc + auto-switch means you basically never think about it again.

Install

curl -fsSL https://raw.githubusercontent.com/bodasooqa/claudenv/main/install.sh | bash

Or with wget, or download and inspect first – instructions are in the README.

Bash or zsh, that’s the only requirement. No Node, no Python, no Homebrew, no extra dependencies. Twenty lines into ~/.zshrc, you’re done.

What’s next

A few things on the roadmap: a claudenv self-update command, a Homebrew tap once there’s a real release cadence, and a wrapper that makes GUI-launched IDEs (Cursor, VS Code from the Dock or Spotlight) pick up the active profile – currently that’s a known limitation with launchd not reading shell rc files, documented in the README.

If it solves your problem, a star helps me prioritize what to build next. Issues and PRs welcome.

GitHub logo

bodasooqa
/
claudenv

nvm-style account manager for Claude Code. Switch profiles via CLAUDE_CONFIG_DIR, with per-project .claudenvrc and auto-switch on cd.

claudenv

ShellCheck
License: MIT
macOS
Linux

An nvm-style account manager for Claude Code. Set a default account, override per-project with .claudenvrc, optionally auto-switch on cd. Zero dependencies.

claudenv import default       # save current ~/.claude as 'default' (auto-activated — first profile)
claudenv add work             # create new empty slot
claudenv use work             # switch this shell + set as global default
claude                        # /login here, isolated from 'default'

When accounts/ is empty, the first import or add auto-activates the new profile so you don’t have to follow with claudenv use. Subsequent ones don’t change the active profile.

Is this for you?

Claude Code already supports CLAUDE_CONFIG_DIR for account isolation. Several tools wrap this — pick the one that fits how you work:

claudenv claude-switch
Mental model Set a default, work normally Specify account on every launch
Typical command claude (after claudenv use work) claude-switch work
Per-project config .claudenvrc (walk-up resolution) ✗

…