Back to Blog

Connecting Internal Services Without Exposing Them to the Internet

A few months ago, a team migrating from Heroku to Convox reached out with a problem. Their security audit had flagged an admin API that was publicly accessible on the internet. The API handled sensitive operations like user management and billing adjustments. Anyone with the URL could attempt to access it. The only thing standing between attackers and their admin endpoints was authentication middleware they had written themselves.

The team was surprised. They had assumed their admin API was internal because it was only called by their main web application. But on Heroku, every process type with a web dyno gets a public URL. There is no concept of a private service that exists only inside your infrastructure. The team had carried this mental model into their new environment without questioning it.

This pattern is more common than you might expect. Teams coming from Heroku often expose services to the public internet simply because they have never had another option. Convox provides a better approach: the internal: true attribute that keeps services completely off the public internet while enabling seamless communication between your application components.

The Public-by-Default Problem

Heroku's architecture was groundbreaking when it launched. Every dyno with a web process type gets a public URL, making it trivially easy to deploy web applications. But this design decision has an unintended consequence: it trains development teams to think of all services as public by default.

Consider a typical application architecture. You have a public-facing web application that serves your users. Behind it, you might have an admin API for internal tooling, a payment processing service that talks to Stripe, a background worker that processes jobs from a queue, and maybe a machine learning service that scores user behavior. Of these five services, only one truly needs to be accessible from the public internet.

On Heroku, all of these services would get public URLs if they expose a port. The admin API would be accessible at something like admin-api.herokuapp.com. The payment service would be at payments.herokuapp.com. Even if you never share these URLs, they exist. They are discoverable. They are attack surface.

Security professionals call this "defense in depth." The more layers between an attacker and your sensitive systems, the better. Network isolation is one of the most effective layers because it eliminates entire classes of attacks. If your admin API is not reachable from the internet, attackers cannot exploit vulnerabilities in your authentication middleware, cannot attempt to brute force credentials, and cannot probe for injection flaws.

How Convox Internal Services Work

Convox runs on Kubernetes, but you do not need to understand Kubernetes to use it effectively. When you mark a service as internal: true in your convox.yml, Convox handles all the underlying infrastructure changes automatically.

At the infrastructure level, setting internal: true does several things. The service is removed from the public load balancer, so no external traffic can reach it. The service receives a private DNS name in the format [service].[app].convox.local. This DNS name only resolves inside your Convox rack, making it unreachable from outside your cluster. The service still gets a port assignment and can receive traffic, but only from other services running in the same rack.

Here is the simplest possible example:

services:
  web:
    build: .
    port: 3000
  admin:
    build: ./admin
    port: 4000
    internal: true

In this configuration, the web service is publicly accessible via HTTPS. The admin service is only reachable from inside the rack. When you run convox services, you will see the difference immediately:

$ convox services
SERVICE  DOMAIN                                PORTS
admin    admin.myapp.convox.local              4000
web      web.myapp.0a1b2c3d4e5f.convox.cloud   443:3000

Notice that the web service has a public domain ending in .convox.cloud with HTTPS on port 443. The admin service has a .convox.local domain, which is the telltale sign of an internal service. That domain will not resolve outside your rack.

Service Discovery with convox.local

The .convox.local domain is Convox's internal DNS system for service discovery. Every service in your rack, whether internal or public, receives a .convox.local hostname. Your services can use these hostnames to communicate with each other without knowing anything about the underlying infrastructure.

When your web service needs to call your admin API, it simply makes a request to http://admin.myapp.convox.local:4000. Convox's internal DNS resolver handles the rest, routing the request to one of the running admin processes. If you have multiple replicas of the admin service running, Convox automatically load balances requests across them.

This is significantly simpler than what you would need to configure manually in Kubernetes:

Concern Raw Kubernetes Convox
Internal Service Create Service with ClusterIP type, configure selectors, define ports internal: true
DNS Resolution Use CoreDNS, understand namespace conventions like service.namespace.svc.cluster.local service.app.convox.local
Network Policy Write NetworkPolicy YAML to restrict ingress/egress Handled automatically
Total Config 50+ lines across multiple files 1 line

The important detail is that internal services do not receive automatic SSL termination. Traffic between your services travels over HTTP within the cluster. If you need encryption for internal service communication, you would need to handle SSL inside the service itself. For most applications, this is unnecessary because the traffic never leaves your private network.

A Complete Working Example

Let us build a realistic application architecture. We will have a public web application, an internal admin API, and a background worker that processes jobs. This is a common pattern for SaaS applications.

environment:
  - DATABASE_URL
  - REDIS_URL
  - ADMIN_API_URL=http://admin.myapp.convox.local:4000

services:
  web:
    build: .
    command: bin/web
    port: 3000
    health: /health
    scale:
      count: 2-10
      targets:
        cpu: 70

  admin:
    build: .
    command: bin/admin
    port: 4000
    internal: true
    health: /health
    environment:
      - ADMIN_SECRET

  worker:
    build: .
    command: bin/worker
    internal: true
    scale:
      count: 1-5

resources:
  database:
    type: postgres
  cache:
    type: redis

Let us walk through what this configuration does.

The web service is your public-facing application. It exposes port 3000, which Convox automatically wraps with HTTPS on port 443. The health check at /health ensures that only healthy instances receive traffic. The scale configuration enables autoscaling between 2 and 10 instances based on CPU utilization.

The admin service is marked as internal: true. It listens on port 4000 but is only accessible from inside the rack. Notice that it has its own environment variable ADMIN_SECRET that is not shared with other services. This service handles sensitive operations and should never be exposed to the internet.

The worker service does not expose a port at all. It processes background jobs from a Redis queue. Because it has no port attribute, it is automatically internal. You cannot make a portless service public because there is nothing to load balance to.

The global environment section sets ADMIN_API_URL to the internal hostname of the admin service. Your web service can use this environment variable to call the admin API:

# In your web application code
admin_url = ENV['ADMIN_API_URL']
response = HTTP.get("#{admin_url}/users/#{user_id}")

This pattern keeps your service URLs out of your application code. If you ever need to change how services communicate, you update the environment variable rather than redeploying code.

Verifying Network Isolation

After deploying your application, you should verify that your internal services are actually internal. The first check is the convox services command:

$ convox services -a myapp
SERVICE  DOMAIN                                PORTS
admin    admin.myapp.convox.local              4000
web      web.myapp.0a1b2c3d4e5f.convox.cloud   443:3000
worker   (no port exposed)

Any service with a .convox.local domain is internal. Any service with a .convox.cloud domain (or your custom domain) is public.

The second verification is to attempt external access. Try to resolve the internal hostname from your local machine:

$ nslookup admin.myapp.convox.local
** server can't find admin.myapp.convox.local: NXDOMAIN

The domain does not exist in public DNS. It only resolves inside your Convox rack.

The third verification is to confirm internal communication works. Use convox run to execute a command inside your web service and test the connection:

$ convox run web curl http://admin.myapp.convox.local:4000/health
{"status":"ok"}

The request succeeds from inside the rack but would fail from outside. This is exactly the network isolation you want.

Debugging Internal Services

Sometimes you need to access an internal service for debugging purposes. Maybe you want to test an admin API endpoint or inspect the state of a background worker. Convox provides the convox resources proxy command for exactly this situation.

While resources proxy is typically used for database connections, you can also use convox run to interact with internal services:

$ convox run admin bash
Running bash on admin... OK
root@admin-abc123:/app# curl localhost:4000/admin/users
[{"id":1,"email":"admin@example.com"}]

This gives you a shell inside an internal service container where you can run commands, inspect logs, and debug issues. The session is authenticated through your Convox CLI credentials, so access is controlled by your organization's permissions.

For viewing logs from internal services, use the standard convox logs command with a service filter:

$ convox logs -a myapp --service admin --since 1h

Security Benefits of Network Isolation

Marking services as internal provides several concrete security benefits that go beyond just hiding URLs.

Reduced attack surface. If a service is not reachable from the internet, attackers cannot probe it for vulnerabilities. They cannot attempt SQL injection, authentication bypass, or any other exploit. The attack simply cannot reach the service.

Defense in depth. Even if an attacker compromises your public web service, they still face another layer of security. The internal services require network access that the attacker may not have. This buys you time to detect and respond to incidents.

Simplified authentication. Internal services can use simpler authentication mechanisms because they are already protected by network isolation. Instead of implementing full OAuth flows, you might use shared secrets or mutual TLS. This reduces code complexity and potential implementation bugs.

Compliance benefits. Many compliance frameworks like SOC 2 and HIPAA require network segmentation. Being able to demonstrate that sensitive services are isolated from the public internet simplifies your compliance audits.

Get Started

Network isolation is one of those features that seems simple but has profound implications for your application security. The internal: true attribute is a single line in your convox.yml, but it represents a fundamental shift in how you think about service architecture.

If you are coming from Heroku, take a moment to audit your services. Which ones actually need to be publicly accessible? For most applications, the answer is fewer than you think. Your admin APIs, background workers, internal microservices, and data processing pipelines can all be internal services that communicate securely within your cluster.

The Service Discovery documentation covers additional patterns for internal and external service communication. The convox.yml reference documents all available service attributes.

Create a free account and deploy your first application with proper network isolation. The Getting Started Guide walks you through the process step by step.

For compliance requirements or enterprise deployments, reach out to our team.

Let your team focus on what matters.