Modern application development increasingly demands security-first approaches, but setting up a secure local development environment that mirrors production constraints is often overlooked. In this post, I'll walk you through a Docker-based Java development environment that enforces network segmentation, egress filtering, secure ingress with TLS, and production-like browser behavior all running locally.
The Problem: Security Theater in Development
Most development environments are security-free zones. Your application can reach any endpoint, pull dependencies from anywhere, and communicate freely with the outside world. Developers typically use http://localhost:8080, which creates a dangerous illusion: if it works on your laptop, it's ready for production.
The reality? Production environments have:
- Strict firewall rules blocking unexpected egress
- Network segmentation preventing lateral movement
- Ingress proxies enforcing TLS and security headers
- HTTPS with all its browser security implications
- Audit logging of all network activity
Discovering these constraints during deployment is expensive. Worse, it trains developers to see security as friction rather than design.
This article presents a different approach : a Docker-based development environment where security constraints are the default, not an afterthought. You'll learn production network patterns while writing code, not while debugging a failed deployment.
The patterns shown here translate directly to production Docker deployments. Whether you scale to orchestration later or keep it simple with Docker Compose in production, these principles remain the same.
The Solution: Network-Segmented Docker Environment
This architecture enforces network boundaries at the container level, giving you production-like security constraints during development.
Architecture Overview
Internet Egress egress-net (Squid) Ingress ingress-net App (Caddy) (Java) ports 80/443 (host exposed) Database db-net (PostgreSQL) Four isolated networks enforce strict communication paths:
- db-net : App Database only
- ingress-net : App Ingress proxy only
- egress-net : App Egress proxy only
- internet : Egress External internet only
The app container cannot reach the internet directly all outbound traffic must go through the Squid proxy.
What This Environment Provides
This setup gives you:
Network segmentation - Services can only communicate on explicitly allowed networks
Egress filtering - Outbound traffic is whitelisted by domain
Ingress hardening - HTTPS termination, security headers, log sanitization
Production parity - The same constraints your production environment should have
HTTPS in development - Real browser security behavior, not localhost shortcuts
Portable security - Works identically on any machine running Docker
This is not :
A Kubernetes replacement (it's simpler by design)
A complete security solution (you still need authentication, authorization, input validation, etc.)
A performance-optimized production setup (though it can run in production for simpler applications)
Philosophy : Docker Compose is sufficient for many production applications. This architecture scales from development through production without requiring orchestration complexity unless you actually need it.
Implementation Deep Dive
1. Docker Compose Network Definition
The base docker-compose.yml defines the infrastructure layer:
networks: ingress-net: internal: true egress-net: internal: true internet:x-proxy-env: &proxy-env http_proxy: http://egress:3128 https_proxy: http://egress:3128services: ingress: image: mandraketech.internal/ingress build: context: ./ingress ports: - name: https published: 443 target: 443 - name: http published: 80 target: 80 networks: - ingress-net depends_on: - app egress: image: mandraketech.internal/egress build: context: ./egress networks: - egress-net - internet volumes: - ./egress/squid.conf:/etc/squid/squid.conf:ro - ./egress/domain-lists.d:/etc/squid/domain-lists.d:ro app: environment: <<: *proxy-env networks: - ingress-net - egress-net Key points:
- Networks marked
internal: truehave no external connectivity - The
x-proxy-envYAML anchor injects proxy settings into the app container - Only the
internetnetwork allows external access, and only the egress container uses it
Understanding Docker's Internal Networks
The internal: true flag prevents networks from routing to external destinations. However, Docker's port publishing bypasses this restrictionit operates at the host level, not the network level.
This might seem contradictory: How can ingress-net be internal yet expose ports 80/443?
The answer : Internal networks restrict container-to-external routing, not host-to-container port forwarding. The ingress container:
- Cannot initiate connections to the internet (internal: true enforces this)
- Can receive connections from the host (port publishing allows this)
- Can communicate with other containers on the same network
This is intentional architecture, not a workaround. It demonstrates an important principle: network isolation is about controlling who can initiate connections, not just who can receive them.
If you want to understand Docker networking at a deeper level, explore the iptables rules Docker creates: sudo iptables -L DOCKER-ISOLATION-STAGE-2 -n -v. You'll see how internal networks are enforced at the packet filtering level.
2. Egress Filtering with Squid
The egress proxy uses Squid to whitelist allowed domains. Here's the core configuration:
http_port 3128# Define local network (containers)acl localnet src 172.16.0.0/12acl localnet src 10.0.0.0/8acl localnet src 192.168.0.0/16# Load allowed domains from consolidated fileacl allowed_domains dstdomain "/etc/squid/all-allowed-domains.txt"acl Safe_ports port 80 # httpacl Safe_ports port 443 # httpsacl HTTPS_ports port 443acl CONNECT method CONNECT# Access ruleshttp_access deny !Safe_portshttp_access allow localnet allowed_domains HTTPS_ports# Deny everything elsehttp_access deny all The whitelist approach means only explicitly allowed domains are accessible. Domain lists are organized by purpose:
allowed-domains-dev.txt (development tools):
# github vscode extensionraw.githubusercontent.comapi.github.com# version controlgithub.comgitlab.com# vscode extensions marketplacemarketplace.visualstudio.commain.vscode-cdn.net.vsassets.io allowed-domains-app.txt (Java ecosystem):
# java dependenciesapi.spring.iorepo.maven.apache.orgmaven.apache.orgsearch.maven.orgservices.gradle.orgdocs.oracle.com 3. Dynamic Configuration Reload
The egress container includes a file watcher that automatically reloads Squid when domain lists change:
#!/usr/bin/env shconsolidate_files() { echo "Consolidating domain lists..." CONSOLIDATED_DOMAINS_FILE="/etc/squid/all-allowed-domains.txt" echo "" > "$CONSOLIDATED_DOMAINS_FILE" for file in /etc/squid/domain-lists.d/*.txt; do if [-f "$file"]; then cat "$file" >> "$CONSOLIDATED_DOMAINS_FILE" fi done}# Initial consolidationconsolidate_files# Watch for changes and reloadinotifywait -m -e modify,create,delete,move /etc/squid/domain-lists.d |while read -r path action file; do consolidate_files squid -k reconfiguredone &exec /usr/sbin/squid -NYCd 1 This means you can add new allowed domains without restarting containers.
4. Secure Ingress with Caddy
The ingress proxy provides HTTPS termination with security headers baked in. Critically, it also sanitizes logs to prevent credential leaks:
{ admin off skip_install_trust log "console_logger" { format filter { wrap json fields { request>headers>Authorization delete request>headers>Cookie cookie { replace session REDACTED delete secret } request>remote_ip ip_mask { ipv4 16 ipv6 32 } } } format console }}www.localhost { tls internal log "console_logger" header { # HSTS Strict-Transport-Security max-age=31536000; # Prevent MIME sniffing X-Content-Type-Options nosniff # Hide server identity server "super-secure" # Clickjacking protection X-Frame-Options DENY # Referrer policy Referrer-Policy no-referrer # XSS Protection X-XSS-Protection "1; mode=block" } handle /healthy { respond "Running !!!!" 200 } handle /* { reverse_proxy app:8080 } handle_errors { @5xx expression `{err.status_code} >= 500 && {err.status_code} <600` handle @5xx { respond "It's a 5xx error." } }} Key security features in this configuration:
- Log sanitization : Authorization headers are completely removed, session cookies are marked as REDACTED, and IP addresses are masked to /16 and /32 to protect user privacy
- Security headers : HSTS, X-Frame-Options, X-Content-Type-Options protect against common web vulnerabilities
- TLS handling : Caddy automatically generates and manages self-signed certificates for local development
TLS Configuration for Development
www.localhost { tls internal # ... rest of config} The tls internal directive tells Caddy to generate a self-signed certificate for www.localhost. This gives you:
- Real TLS handshakes : Your application sees HTTPS requests exactly as it would in production
- Browser security context : APIs that require secure contexts work correctly
- Security header testing : HSTS, CSP, and other headers behave as they would in production
First-time setup : When you access https://www.localhost, your browser will warn about the self-signed certificate. This is expected. Click through the warning (the exact process varies by browser):
- Chrome/Edge : Click "Advanced" "Proceed to www.localhost (unsafe)"
- Firefox : Click "Advanced" "Accept the Risk and Continue"
- Safari : Click "Show Details" "visit this website"
After accepting once, your browser remembers the exception for this domain.
Why not use localhost? The domain www.localhost is used instead of plain localhost because it allows testing subdomain behavior (cookies, CORS policies) and more closely mimics production domain structures.
The Hidden Value of TLS in Development
Most developers use http://localhost:8080 during development and only encounter HTTPS in production. This creates a dangerous blind spot: browsers behave fundamentally differently under HTTPS.
What you miss without HTTPS in development:
Mixed Content Blocking : Browsers block HTTP resources (scripts, stylesheets, images) loaded from HTTPS pages. You won't discover this until production.
Secure Cookie Behavior : Cookies marked
Secureare only sent over HTTPS. Session management that works on localhost can fail in production.Service Worker Restrictions : Service Workers only function over HTTPS (except localhost). Progressive Web App features won't work without TLS.
CORS Preflight Differences : Cross-Origin Resource Sharing behaves differently for HTTPS vs HTTP, particularly with credentials.
HTTP/2 and HTTP/3 : Modern protocols require TLS. Performance characteristics differ significantly from HTTP/1.1.
Browser Security Features : Features like geolocation, camera access, and clipboard API require secure contexts (HTTPS).
Referrer Policy Enforcement : Browsers strip or modify referrer headers differently based on protocol security level.
Caddy makes this effortless : It automatically generates and manages self-signed certificates for local development. You get real TLS behavior without certificate management overhead.
This environment uses https://www.localhost instead of http://localhost:8080. Your browser will show a certificate warning (expected for self-signed certificates), but after accepting it once, you experience production-like HTTPS behavior during development.
Teaching Moment : The first time you encounter a mixed content warning during development instead of production, you'll understand why this matters. HTTPS in development isn't about securityit's about production parity.
5. The Java Application Container
The app container uses BellSoft Liberica JDK on Alpine for a minimal footprint:
ARG JDK_VERSION=25FROM bellsoft/liberica-openjdk-alpine-musl:${JDK_VERSION}RUN apk update && \ apk add --no-cache \ bash \ git \ curl \ tar \ unzip \ libstdc++ \ ca-certificates \ && mkdir /codeVOLUME ["/code"]CMD ["sleep", "infinity"] The CMD ["sleep", "infinity"] is intentional for this development environment. This keeps the container alive for interactive developmentyou exec into it, run builds, start services manually. This is a development pattern, not a production deployment strategy. It allows you to iterate on your application without rebuilding containers constantly.
The application config extends this in docker-compose.override.yml:
networks: db-net: internal: trueservices: database: image: postgres:16-alpine environment: POSTGRES_USER: psqladmin POSTGRES_PASSWORD: secret POSTGRES_DB: app_db networks: - db-net app: environment: DB_HOST: database DB_PORT: 5432 volumes: - code_m2:/home/dev/.m2 - code_vol:/code networks: - db-net depends_on: - database Debugging Network Restrictions
When egress filtering blocks a request, you need a systematic approach to diagnose and resolve it.
Testing Egress Filtering
The repository includes a test script that validates your proxy configuration:
docker compose exec egress /usr/local/bin/tester.sh This script attempts connections to various domains and shows which are allowed. View the full script to understand the test cases.
Manual Testing from Application Container
Test specific domains from within the app container:
# This should succeed (if whitelisted)docker compose exec app curl -v https://repo.maven.apache.org# This should fail (not whitelisted)docker compose exec app curl -v https://random-site.com Reading Squid Logs
When a request is blocked, Squid logs the denial:
docker compose logs egress | grep TCP_DENIED You'll see entries like:
1733500800.123 0 172.18.0.5 TCP_DENIED/403 3918 CONNECT random-site.com:443 - HIER_NONE/- text/html This tells you:
- Timestamp : When the request occurred
- Client IP : Which container made the request (172.18.0.5 is the app container)
- Action : TCP_DENIED/403 means the request was blocked
- Domain : What the app tried to reach
Decision Tree: Whitelist or Refactor?
When you find a blocked domain, ask:
1. Is this domain expected?
- Yes Add to appropriate whitelist in
egress/domain-lists.d/ - No You've found unexpected behavior (possible supply chain issue, investigate)
2. Does my application really need this dependency?
- Example: A logging library that phones home for telemetry
- Consider: Do I need this feature, or can I use a simpler alternative?
3. Can I cache or vendor this resource?
- Maven dependencies Cache in your own artifact repository
- External APIs Consider if you need real-time access during development
The friction is intentional. It forces you to understand your application's network footprint.
Adding Allowed Domains
When you determine a domain should be whitelisted:
# For application dependenciesecho "api.example.com" >> egress/domain-lists.d/allowed-domains-app.txt# For development toolsecho "vscode-extension-cdn.com" >> egress/domain-lists.d/allowed-domains-dev.txt The proxy automatically reloads within seconds (via inotifywait). No container restart needed.
Common Issues
Maven dependencies fail to download:
# Check if Maven Central is whitelistedgrep "repo.maven.apache.org" egress/domain-lists.d/allowed-domains-app.txt# Verify proxy environment variables are setdocker compose exec app env | grep proxy HTTPS connections timeout:
# Ensure port 443 is in Safe_portsdocker compose exec egress grep "Safe_ports port 443" /etc/squid/squid.conf# Check if CONNECT method is alloweddocker compose exec egress grep "acl CONNECT method CONNECT" /etc/squid/squid.conf Domain is whitelisted but still blocked:
- Check for typos in the domain list (extra spaces, wrong TLD)
- Verify the file is mounted:
docker compose exec egress cat /etc/squid/all-allowed-domains.txt - Ensure Squid was reloaded:
docker compose logs egress | grep reconfigure
TLS and Certificate Issues
Browser shows certificate warning every time:
This suggests your browser isn't remembering the certificate exception. Check:
- Are you using incognito/private mode? These don't persist certificate exceptions.
- Is your browser profile corrupt? Try a fresh profile.
- Did the ingress container restart? Self-signed certificates regenerate on restart, invalidating previous exceptions.
Mixed content warnings in browser console:
Mixed Content: The page at 'https://www.localhost/' was loaded over HTTPS, but requested an insecure resource 'http://api.example.com/data'. This request has been blocked. This is expected behavior under HTTPSbrowsers block insecure resources. Solutions:
- Update your code to use HTTPS URLs:
https://api.example.com/data - Use protocol-relative URLs:
//api.example.com/data(inherits page protocol) - For local resources, ensure they're served through the Caddy proxy
Service Worker registration fails:
// This fails: Service Workers need HTTPSnavigator.serviceWorker.register('/sw.js') .catch(err => console.error('SW registration failed:', err)); Verify you're accessing via https://www.localhost, not http://localhost. Check browser console for security context errors.
Cookies not being sent:
If you're setting cookies with Secure flag:
Cookie cookie = new Cookie("session", sessionId);cookie.setSecure(true); // Only sent over HTTPS Ensure your application is accessed via HTTPS, and the cookie domain matches www.localhost.
Why This Matters for Your Team
1. Security Becomes Muscle Memory
When egress filtering is part of your daily workflow, you naturally think about:
- What external services does this library contact?
- Do I trust this dependency's network behavior?
- Can I vendor or cache this resource?
These aren't theoretical security questionsthey're practical development constraints you encounter immediately.
2. Production Surprises Become Development Discoveries
Scenario : Your application uses a Java library that contacts a license server.
Without this environment : You discover this during production deployment when your firewall blocks it. Now you're in an emergency change request to whitelist an unknown domain.
With this environment : You discover it when you add the dependency. You research the library, understand why it needs network access, and make an informed decisionbefore it's an emergency.
3. Supply Chain Visibility
Modern applications depend on hundreds of transitive dependencies. Some of these dependencies make network calls you don't expect:
- Analytics that phone home
- License validation checks
- Automatic update checks
- Telemetry collection
Egress filtering makes these visible immediately. You can't ignore what's explicitly blocked.
4. Simplified Compliance
Many compliance frameworks (PCI-DSS, HIPAA, SOC 2) require:
- Network segmentation documentation
- Egress traffic controls
- Audit logs of network activity
This environment provides all three by default. Your development setup becomes documentation of your security model.
5. Docker in Production, Simplified
This architecture scales to production for applications that don't need orchestration complexity:
- Small to medium workloads
- Internal tools and dashboards
- Applications with stable traffic patterns
- Teams that value simplicity over horizontal scaling
The same docker-compose.yml that runs on your laptop can run in production with environment-specific overrides. No translation layer, no impedance mismatch.
Design Decisions
Why Network Isolation Over Firewall Rules
Instead of using firewall rules to prevent communication, we use Docker networks to make prohibited communication impossible. This is more reliable because:
- Network isolation is enforced by the platform itself, not by rules that can be misconfigured
- It's self-documentinglooking at the compose file shows exactly what can communicate
- There's no "forgot to add a rule" risk
Why Whitelist Over Blacklist for Egress
The egress proxy uses domain whitelisting rather than blacklisting. This fail-closed approach is superior because:
- You can't blacklist threats you haven't thought of
- A whitelist explicitly documents what your application needs
- Unknown domains are denied by defaultthe secure path of least resistance
Why Containerized Infrastructure
Every component (app, database, ingress, egress) is containerized. This provides:
- Consistency : Same behavior across development machines and production
- Reproducibility : New team members get the exact same environment
- Resource management : CPU and memory limits prevent runaway containers
- Clear dependencies : Each service's requirements are documented in code
Why Docker Compose Override Pattern
Separating base infrastructure from application configuration in two compose files allows:
- Reusability : The same infrastructure can support multiple projects
- Customization : Applications can override settings without modifying core infrastructure
- Clean separation : Infrastructure concerns stay separate from application concerns
Real-World Applications
This architecture is particularly valuable for:
Teams New to Security : The egress proxy makes security violations visible and immediately actionable. It's harder to ignore a blocked request than to overlook a security best practice document.
Regulated Industries : Healthcare, finance, and government applications need defense-in-depth from day one. Starting with network segmentation in development means your security model is baked in, not bolted on.
Supply Chain Risk Management : With increasing supply chain attacks (SolarWinds, Log4Shell), understanding your dependencies' network behavior is critical. This environment makes that behavior explicit.
Docker Production Deployments : If you're running Docker Compose in production (and many successful companies do), this gives you a development environment with identical security constraints.
Progressive Web Apps (PWAs): Service Workers require HTTPS to function. With this environment, you can develop and test PWA features locally without deploying to a staging server or dealing with certificate management.
API Development with Secure Cookies : When building APIs that use Secure and SameSite cookies, HTTPS in development ensures your authentication flow works identically locally and in production.
Third-Party Integrations : Many OAuth providers and payment gateways require HTTPS redirect URIs, even for development. This environment satisfies those requirements without tunneling services like ngrok.
Microservices Training : Even if you plan to use Kubernetes eventually, understanding network segmentation at the Docker level builds intuition for NetworkPolicies and service mesh concepts.
Security-Conscious Startups : Early-stage companies can afford to build security in from the start. This environment makes secure-by-default the path of least resistance.
Getting Started
Clone the repository and run:
mkdir -p secretsdocker compose build --no-cachedocker compose up -d Access your app at https://www.localhost.
Important : Your browser will show a certificate warning because this uses a self-signed certificate. This is expected and safe for local development. Accept the warning to proceed.
Why HTTPS and not HTTP? Because production applications use HTTPS, and browsers behave differently under HTTPS. This environment helps you discover HTTPS-specific issues during development:
- Mixed content blocking
- Secure cookie requirements
- Service Worker restrictions
- API security contexts
Test the HTTPS setup:
# Should return "Running !!!!" with a 200 statuscurl -k https://www.localhost/healthy# The -k flag tells curl to accept self-signed certificates You can also verify the TLS configuration:
# View certificate detailsopenssl s_client -connect localhost:443 -servername www.localhost < /dev/null# You'll see Caddy's self-signed certificate information Test egress filtering:
docker compose exec egress /usr/local/bin/tester.sh Customization
Add new allowed domains by creating files in egress/domain-lists.d/:
echo "api.example.com" >> egress/domain-lists.d/allowed-domains-app.txt The proxy automatically reloads within seconds.
Conclusion
Security shouldn't start at the deployment gate. By bringing production security constraints into your development environment, you:
- Learn production patterns while writing code
- Catch network issues before they're emergencies
- Build intuition about your application's security posture
- Create self-documenting network architectures
- Experience real browser security behavior with HTTPS
This Docker-based approach is simple enough for daily use but sophisticated enough to translate directly to production. No orchestration required unless you actually need it.
Start by cloning the repository and running docker compose up. The first time egress filtering blocks something unexpected, you'll understand why this matters.
The full source code, including the tester script and additional examples, is available at java-secure-dev-env.
Next Steps:
- Clone the repository and explore the configurations
- Add your own application to the
appservice - Customize domain whitelists for your dependencies
- Use the patterns as a template for your team's projects
If you adapt this for other languages or find interesting edge cases, open an issue or PR. The goal is a reusable pattern for secure-by-default development across ecosystems.
Quick Reference
| Task | Command |
| Start environment | docker compose up -d |
| Stop environment | docker compose down |
| Access application | https://www.localhost (note HTTPS) |
| View all logs | docker compose logs -f |
| View app logs | docker compose logs -f app |
| Enter app container | docker compose exec app bash |
| Access database | docker compose exec app psql -h database -U psqladmin -d app_db |
| Test egress filtering | docker compose exec egress /usr/local/bin/tester.sh |
| Test HTTPS locally | curl -k https://www.localhost/healthy |
| Rebuild all services | docker compose build --no-cache && docker compose up -d |
| Add allowed domain | echo "domain.com" >> egress/domain-lists.d/allowed-domains-app.txt |
| Test proxy manually | docker compose exec app curl -x http://egress:3128 https://example.com |
| View certificate details | openssl s_client -connect localhost:443 -servername www.localhost < /dev/null |
Credits
The code for this blog was written manually first, and fine tuned with rigorous usage. The initial article draft was generated using AmpCode Free (https://ampcode.com), and then substantially revised and enhanced with Claude (Anthropic) to improve technical depth, add comprehensive debugging sections, emphasize TLS/HTTPS production parity, and refine the strategic positioning.
Back Link
This article was originally published here on the MandrakeTech Blog
About the Author
The Author, Navneet Karnani, began coding with Java in 1997 and has been a dedicated enthusiast ever since. He strongly believes in the "Keep It Simple and Stupid" principle, incorporating this design philosophy into all the products he has developed.
Navneet works as a freelancer, mentor, advisor and a fractional CTO in startups that build technology products.
Driven software engineer (Java since 1997) with a hands-on passion for building impactful tech products. Possesses over 25 years of experience crafting solutions to complex business and technical challenges.
Have questions or improvements? Open an issue or PR on the repository!
]]>
Top comments (0)