While development setups have been relatively straightforward in the past,
requirements nowadays are way more complex, considering you want to have a production-like environment on your local machine.
The rise of headless systems, decentralized e-commerce approaches like Shopware Apps, and more, have led to developer environments
with way more than just a single domain and a single server.
My setups consist of things like NGINX reverse proxies, Vue.js apps, a couple of Symfony applications either as web based API, or as scaled
self-sufficient background workers. In addition to this an ELK stack based on Opensearch as well as Mailcatcher, REDIS, Grafana, Typesense, Swagger and more.
And that all in a single project.
This approach of multiple applications in a single project often comes with the requirement of having multiple domains to access these.
I usually register these domains on my host by adding them to the /etc/hosts file.
But as soon as you need to have dynamic domains, like in a multi-tenant system, or you want to have a wildcard domain,
then you reach the limits of /etc/hosts.
Limitations of /etc/hosts
By editing the /etc/hosts file on your host, you can add static domain entries that resolve to a specific IP address.
So you can make up a domain and let it point to your localhost, that runs e.g. a Docker container on port 80/443.
127.0.0.1 my-project.dev
But this comes with a couple of limitations.
- Every new domain requires manual intervention. If you are an agency with multiple customers, this means that every developer needs to initially add the required domains of a new project to their local /etc/hosts file. If existing projects add new domains, you need to make sure that developers know about this and add them on their hosts again.
- If you have a multi-tenant system where each tenant has a unique and randomly generated subdomain like https://x5V12KLW3kbv32ksgc.my-project.dev, then you would need to add that generated domain to your hosts file. This means whenever you create a new tenant in the database or application, you get a new domain and need to manually add it again and again to your file. This is just not feasible for a production-like developer environment.
- Let's bring it to the next level. In your multi-tenant system, you need to call the tenant's domain from a container inside your Docker network. Assume we have just configured our tenant domain on our host machine, how would your container know about these domains too? And again, what if you create new tenants and get lots of new generated domains?
If you face such challenges, you might be looking for a more flexible and automated solution that can handle dynamic domains and resolve them to the appropriate container.
In this article, I'll guide you through the process of setting up a DNS server using dnsmasq in your Docker setup, enabling seamless communication within your development environment.
Our Project
We will use a simple Docker setup with a NGINX reverse proxy, a Shopware container, and an API container for this article.
In our project, every tenant has a unique domain to call our API, like https://mandate-123.project.dev.
The call however, should not only be working from our host machine, but also from our Shopware container from within our Shopware plugin (server-side request from container to container).
1. Docker Setup
Here is our sample docker-compose.yml file.
version: '3'
services:
proxy:
image: dockware/proxy:latest
container_name: proxy
ports:
- "80:80"
- "443:443"
networks:
- web
volumes:
- "./docker/proxy:/etc/nginx/conf.d"
shopware:
container_name: shopware
image: dockware/play:6.5.8.2
networks:
- web
api:
container_name: api
image: dockware/flex:latest
networks:
- web
networks:
web:
external: false
2. NGINX Configuration
Here is 1 sample of our NGINX configuration for the reverse proxy.
The domains in our project are:
- shopware.project.dev
- api.project.dev
The proxy listens to our ports 80 and 443 for both domains and routes the requests to the appropriate container, using the proxy_pass directive.
server {
listen 80;
server_name api.project.dev;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name api.project.dev;
ssl_certificate /etc/nginx/ssl/selfsigned.crt;
ssl_certificate_key /etc/nginx/ssl/selfsigned.key;
location / {
proxy_set_header Host $host;
proxy_pass https://api;
}
}
3. Add our DNS Server
Usually we would add the following domains to our /etc/hosts file on our host:
127.0.0.1 api.project.dev
127.0.0.1 shopware.project.dev
But because of the limitations of /etc/hosts, we want to use a DNS server that resolves these domains to our Docker containers.
So we add a new container for dnsmaq to our docker-compose.yml.
dnsmasq:
image: jpillora/dnsmasq
ports:
- "53:53/udp"
networks:
- web
The container exposes the port 53 to our localhost, which is the default port for DNS servers.
Make sure that your port 53 is not already in use on your host.
If you use Orbstack, you're good to go, if you use Docker Desktop, you might need to open the settings, navigate to Resources/Network and
disable the feature "Use kernel networking for UDP".
The next step is to tell our host to use this DNS, but only for requests to our domain project.dev.
If you are on a Linux or MAC system, you can easily adjust the resolver configuration.
To lock down the resolver to only resolve the project.dev domains, we just need to create a new file in the /etc/resolver directory, with the name of our domain.
The file should contain the content nameserver 127.0.0.1 which means that the nameserver will be searched on our localhost on port 53, which is our dnsmasq container (if running).
sudo mkdir -p /etc/resolver
sudo rm -rf /etc/resolver/project.dev
sudo sh -c 'echo "nameserver 127.0.0.1" >> /etc/resolver/project.dev'
Congratulations! When you now request any domain ending with project.dev,
your host will already start to communicate with the dnsmasq container.
4. DNS Server Configuration
The configuration for dnsmasq is pretty simple.
We just need to tell it to resolve all project.dev domains to a specific IP.
address=/project.dev/(targetIP)
But what IP do we need?
It's the IP of our reverse proxy Docker container, because that one does all the routing magic.
Unfortunately our proxy container does not have a static IP that we know.
So let's give it one.
This can be done by configuring our network in the docker-compose.yml file and assign a subnet.
Afterwards we can assign a static IP based on that subnet to our proxy container.
Our network gets the 10.0.0.0/24 range and our proxy gets the IP address 10.0.0.100.
proxy:
image: dockware/proxy:latest
...
networks:
web:
ipv4_address: 10.0.0.100
networks:
web:
external: false
ipam:
config:
- subnet: 10.0.0.0/24
Now let's create a configuration for our DNS server.
Create a file dnsmasq.conf file and add this line to it.
address=/project.dev/10.0.0.100
We also need to mount this file to our dnsmasq container in the docker-compose.yml file.
dnsmasq:
image: jpillora/dnsmasq
...
volumes:
- "./dnsmasq.conf:/etc/dnsmasq.conf"
And that's it!
If you now open a project.dev domain on your host,
it will use the nameserver of your dnsmasq Docker container, that resolves the domain by pointing to our reverse proxy container in Docker.
The reverse proxy will then route the request to either the Shopware container or the API container, as long as the subdomains are recognized
according to the NGINX configuration.
Now just imagine you automate these steps. Especially the ones that write the resolver on the developer machines.
None of your developers need to configure anything manually anymore. It just works with the provided docker-compose.yml file and your setup script automation.
5. Multi-Tenant Systems
How can we now resolve our randomly generated domains like https://x5V12KLW3kbv32ksgc.project.dev?
The good thing is, we don't need to do anything special for this.
Our setup is already fully prepared. The only thing we need to adjust is the NGINX configuration.
Our reverse proxy just needs to listen to these wildcard domains and route them to the appropriate container.
Here is an example of a configuration that listens to *.project.dev and forwards it to our API container.
server {
listen 80;
server_name *.project.dev;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name *.project.dev;
ssl_certificate /etc/nginx/ssl/selfsigned.crt;
ssl_certificate_key /etc/nginx/ssl/selfsigned.key;
location / {
proxy_set_header Host $host;
proxy_pass https://api;
}
}
6. Multi-Tenant Systems (inside Docker Containers)
We also want our Shopware container to be able to request the domain https://x5V12KLW3kbv32ksgc.project.dev.
The requests are not done on the host system but directly from within the container, which means our current setup is not enough.
But the good thing is, we just need to repeat almost the same steps as on our host system.
More specific, we just need to prepare the resolver in our container, and that's it.
So what is missing in theory are these bash commands:
sudo mkdir -p /etc/resolver
sudo rm -rf /etc/resolver/project.dev
sudo sh -c 'echo "nameserver 127.0.0.1" >> /etc/resolver/project.dev'
Unfortunately, the IP address 127.0.0.1 is wrong.
While our host system uses the localhost with port 53 to detect the dnsmasq container, our container needs to use the IP address of the dnsmasq container inside the Docker network.
So we need to give our dnsmasq container a static IP address, as we just did with our proxy.
We use 10.0.0.99 in our sample.
dnsmasq:
image: jpillora/dnsmasq
...
networks:
web:
ipv4_address: 10.0.0.99
Now we can add nameserver entries inside our Docker containers, that point to the IP address 10.0.0.99.
However, assigning that manually would be too much effort. Especially when we have a system of multiple Docker containers.
We want to automate this process.
Let's create a script configure_nameservers.sh.
It iterates through all running containers and executes the commands to adjust the nameserver values.
You can also provide a static list of containers instead of iterating through running containers of course.
Please note that the requirements and concept of the "sudo" and "echo" commands might be a bit different for you, depending on what Docker images you use.
But the idea is the same.
#!/bin/bash
# dynamically get all running containers
containers=$(docker ps --format '{{.Names}}' )
# use a static list of containers
#containers="shopware api"
for container in $containers
do
docker exec -it $container bash -c "sudo sh -c 'echo \"nameserver 10.0.0.99\" > /etc/resolv.conf'"
docker exec -it $container bash -c "sudo sh -c 'echo \"options ndots:0\" >> /etc/resolv.conf'"
done
Now run your command:
sh configure_nameservers.sh
That's it!
Your Shopware container can just request https://x5V12KLW3kbv32ksgc.project.dev directly from within the container, even though
no one did ever configure it to exist somewhere.
The request will be passed on to the Docker reverse proxy and route to the API container, according to your configuration.
Now imagine running this script in the automation process of your project setup.
It will all work out of the box for every developer and every container.
Please keep in mind, that you need to consider that you might have self-signed certificates when working with HTTPS.
So send requests from host and containers with disabled SSL checks, or make sure your containers have the certificates installed as trusted ones.
But this is a new topic and not part of this article.
Conclusion
By using a specific dnsmasq container along with a custom resolver configuration, we prepared not only our host system,
but also our Docker containers to call whatever domain we want.
And this all without any configuration interaction from a developer.
What's next?!
I've created a new blog post about enabling trusted SSL certificates inside your Docker containers. This means valid HTTPS connections between your containers without any SSL troubles. Read more about it here: Trusted self-signed certificates between Docker containers
Links: