Docker & Kubernetes Security (Part 1): Building and Securing a Phishing URL Scanner Locally
In this series, I’m learning Docker and Kubernetes security by building a phishing URL scanner and applying security practices along the way. This is Part 1, where everything runs locally on Minikube, giving me a space to experiment with core security concepts before taking the project to AWS.
The phishing URL scanner is a Golang web application that accepts a URL, analyses it against a set of detection rules, and returns a verdict. It runs as a containerized workload inside a local Kubernetes cluster provisioned with Minikube, backed by a Postgres database for storing scan results and history.
The source code of the project is available on GitHub.
Architecture Overview
Tech stack: Golang · Docker · Kubernetes · Minikube · Postgres
Kubernetes architecture diagram
The Phishing URL Scanner
The scanner is a REST API built in Golang using the Gin web framework. When a scan request comes in, it flows through a Gin handler into the core scanning logic, which does three things in parallel. First, it submits the URL to the VirusTotal API and checks how many security vendors have flagged it. Second, it checks the URL against the Google Safe Browsing API, which maintains a constantly updated list of known phishing and malware sites. Third, it performs a domain age check by querying RDAP records, as most phishing campaigns uses newly registered domain. The results of all three checks are aggregated into a single verdict and stored into a Postgres database via GORM.
I chose Golang for the application as I am regularly reviewing Golang code in my current job, and I wanted to deepen my understanding of the language. Building a real service from scratch allowed me to better understand how Go applications are structured, how requests flow through a Gin handler into a service layer, and how GORM abstracts database interactions. This hands-on experience gave me a clearer idea of how the Golang applications that I review in my job actually work.
Containerizing with Docker
As I previously did not have much experience with Docker, I wanted to first get a working Docker container before diving into the security side of things. My first Dockerfile was relatively simple. I set up a multi-stage build that compiled the binary in the builder stage, and run the binary using Alpine as the base image so that the final image size is small. I also used Docker Compose to spin up both the application and Postgres in a single command to speed up local development. At this point in time, I also set up a CI pipeline using GitHub Action to build the Docker image and push it to Docker Hub.
Securing the Container
My next step was to implement security into my Dockerfile. I started with switching the image from using Alpine to using a Distroless image, which contains only the application and its runtime dependencies, with no shell, no package manager, and no debugging tools. This significantly limits an attacker's capability if they somehow managed to gain code execution inside the container. Next, I stripped the debug information at compile time by using the build flags -ldflags="-s -w -buildid=". By removing the symbol table and DWARF debug information, it makes it much harder for an attacker to reverse engineer the binary and identify exploitable paths. Finally, I configured the application process to run as a non-root user, so that in case a container vulnerability exist, the escaped process runs as an unprivileged user in the host, limiting the blast radius of the escape.
Apart from hardening the Dockerfile, the CI/CD pipeline is another place where security can be baked in. I configured Semgrep in my GitHub Actions to scan my Golang code at every pull request using the p/golang ruleset, along with Gitleaks for secret scanning. The merge is prevented if Semgrep has any finding at ERROR severity or when a secret is detected. This helps me catch any security issues in my source code before they make it into a built image. I also set up Trivy in the CI/CD pipeline to scan the built container image for known CVEs in OS packages and language dependencies. If Trivy finds any vulnerability rated HIGH or CRITICAL, the image will be blocked from being pushed onto Docker Hub.
Kubernetes
I decided to use Minikube to run Kubernetes locally so that I can learn about Kubernetes by playing around with it. Initially, I chose to keep it simple and create one single namespace for both my application and the Postgres database, a decision that eventually caused me some trouble down the road. I configured Postgres to run as a StatefulSet as it made sense for databases to have a persistent volume that is preserved should the pod ever restart. To manage traffic routing to my app, I used an nginx ingress controller, which helps me to route incoming requests to the appropriate services based on the rules defined.
For my application, I configured a Horizontal Pod Autoscaler to watch CPU utilisation and automatically scale the number of pods based on the load. This helps with the reliability of the application as it reduces the risk of the application overloading and crashing under sustained load or a DOS attack. I played around with it by running a load generator against the application, and it was quite interesting to see the number of pods go up when load increases, and eventually going back down after I stopped the load generator.
Kubernetes Security
Starting from pod security, I implemented pod security context at both the pod level and the container level for my application. At pod level, I configured runAsNonRoot: true and runAsUser: 1001 to ensure that the container process never runs as root. I also added seccompProfile: RuntimeDefault to restrict the set of Linux system calls the container is allowed to make, reducing the kernel attack surface. At the container level, I had allowPrivilegeEscalation: false to prevent the process from gaining more privileges than it is allowed to have, and readOnlyRootFilesystem: true to prevent an attacker from dropping files or modifying the container environment at runtime. I also dropped all Linux capabilities to further remove the unrequired privilege that the container has.
Next, I enforced Pod Security Admission at the namespace level by applying a restricted enforcement level to the namespace. This is where my initial decision to only have a single namespace came back to bite me. My Postgres database failed to start as I did not configure pod security context for it, and I quickly realised that things was not as simple as adding the security context onto Postgres to fit the enforcement. The official Postgres image requires root level access to initialise the database and set file permission, which the restricted policy explicitly blocks. To solve this issue, I decided to create another namespace for Postgres that runs under baseline enforcement, so that my application can preserve a strong security posture while giving Postgres the permissions it actually needs.
For network security, I added a Network Policy to my application, only allowing ingress traffic from the nginx ingress controller and egress traffic to the Postgres pod through port 53. Since my application does not need to communicate with the Kubernetes API at all, I created a dedicated service account for it with the default token automount disabled. These settings will help to limit the capability of an attacker to conduct lateral movement in case of the pod getting compromised.
Conclusion
Building and securing a phishing URL scanner from scratch has really helped me learn about Docker and Kubernetes from both operational and security perspectives. By experimenting with different configurations, I gained a level of understanding that wouldn’t have come from reading the documentation alone. Honestly, I was surprised by how much privilege containers have by default, and how much configuration is required to properly enforce the principle of least privilege. With Part 1 complete, I look forward to starting Part 2 of the project, where I will deploy the application to AWS EKS.